diff options
Diffstat (limited to '.ai')
35 files changed, 4136 insertions, 56 deletions
diff --git a/.ai/metrics/work-the-backlog.jsonl b/.ai/metrics/work-the-backlog.jsonl new file mode 100644 index 0000000..1067b3a --- /dev/null +++ b/.ai/metrics/work-the-backlog.jsonl @@ -0,0 +1,5 @@ +{"ts":"2026-07-02T05:14:42-04:00","run_id":"c726f526-2e35-4513-b25c-18ef61061333","project":"rulesets","caller":"speedrun","task":"id-link-conversion-pass","outcome":"implemented-committed","defer_reason":"","upfront_decision":false,"wall_clock_s":284,"commit_sha":"78bbaae","review_findings":0} +{"ts":"2026-07-02T05:19:03-04:00","run_id":"c726f526-2e35-4513-b25c-18ef61061333","project":"rulesets","caller":"speedrun","task":"host-identity-guard-rule-plus-startup-lint","outcome":"implemented-committed","defer_reason":"","upfront_decision":true,"wall_clock_s":261,"commit_sha":"b6a977c","review_findings":0} +{"ts":"2026-07-02T05:22:11-04:00","run_id":"c726f526-2e35-4513-b25c-18ef61061333","project":"rulesets","caller":"speedrun","task":"template-sync-gitignored-only-changes","outcome":"implemented-committed","defer_reason":"","upfront_decision":true,"wall_clock_s":188,"commit_sha":"ed75d3c","review_findings":0} +{"ts":"2026-07-02T05:58:16-04:00","run_id":"a48f2977-4493-48a3-9238-9b2f5ff5383b","project":"rulesets","caller":"loop","task":"inbox-send-filename-collision-fix","outcome":"implemented-committed","defer_reason":"","upfront_decision":false,"wall_clock_s":300,"commit_sha":"8099377","review_findings":0} +{"ts":"2026-07-02T05:58:16-04:00","run_id":"a48f2977-4493-48a3-9238-9b2f5ff5383b","project":"rulesets","caller":"loop","task":"page-me-notify-info-level","outcome":"implemented-committed","defer_reason":"","upfront_decision":false,"wall_clock_s":120,"commit_sha":"a6b534f","review_findings":0} diff --git a/.ai/notes.org b/.ai/notes.org index 62eee64..e570597 100644 --- a/.ai/notes.org +++ b/.ai/notes.org @@ -76,9 +76,12 @@ Format: * Workflow State +:COMMIT_AUTONOMY: yes +:LOOP_MAY_COMMIT: yes +:LAST_SPEC_SORT: 2026-07-02 Markers maintained by workflows to record when they last ran. Read by other workflows that gate their behavior on freshness. -:LAST_AUDIT: 2026-06-24 -:LAST_INBOX_PROCESS: 2026-06-23 (chime validate-el.sh Phase 2 cd-to-tests fix applied + pushed e5aab19, reply sent; earlier same day: inbox-zero capture-guard, install-lang neutral-default CLAUDE.md, bash bundle filed [#C]) +:LAST_AUDIT: 2026-06-28 +:LAST_INBOX_PROCESS: 2026-07-01 (15 handoffs: .emacs.d convert-subtasks bundle applied + planning-line fix [19ba7cb], task-audit C.6 [356b905], sweep anchored-/.ai/ security fix + public-reachability convention + real sweep + 14-project broadcast [909b21b, bac3fe4], archsetup UI-traps promoted into spec-review [9814b94], KB orphan report filed as [#C] task; all senders replied) Format: one =:MARKER: YYYY-MM-DD= line per workflow. Workflows overwrite their own marker on completion. diff --git a/.ai/protocols.org b/.ai/protocols.org index 46bea50..5e18ab9 100644 --- a/.ai/protocols.org +++ b/.ai/protocols.org @@ -406,6 +406,15 @@ Full usage: =notify --help= or see =~/.local/bin/notify= - =atq= - list all scheduled alarms - =atrm [number]= - remove an alarm by its queue number +** Paging Craig — desktop vs. away from the machine + +"Page me" has two channels; pick by where Craig is. + +- *At his laptop/desktop* — desktop =notify ... --persist= (above). It reaches him on the machine and stays up until dismissed. +- *Away from his laptop/desktop* — page his phone over Signal via the *signal-mcp* tool =send_message_to_user=, addressed to Craig's account UUID =b1b5601e-6126-47f8-afaa-0a59f5188fde= (his primary number reads as unregistered in Signal's directory — never page a phone number). The message goes out from the dedicated pager account (+15045173983) and fires a normal mobile push. This is the live cross-device path, verified working 2026-06-30. + +Do *not* use the old =page-signal= shell script — it was removed from the rulesets canonical 2026-06-12 and its =~/.local/bin/page-signal= symlink no longer exists. The signal-mcp tool is the only supported Signal path; =notify --persist= is the only supported desktop path. + * Session Protocols ** CRITICAL: Git Commit Requirements @@ -543,6 +552,8 @@ Claude needs to add information to =.ai/notes.org=. For large amounts of informa **The gitignore set follows that same decision.** A project that gitignores =.ai/= (the code-project case) gitignores the whole personal-tooling set: =.ai/=, =.claude/=, =CLAUDE.md=, =AGENTS.md=. =.claude/= is rulesets-owned — copies of =claude-rules/*.md= plus the language bundle's rules, hooks, and settings — and re-synced from rulesets on every startup, so git isn't how it travels between machines; ignoring it also keeps those private rule copies out of the repo, which ignoring =CLAUDE.md= alone would miss. A track-mode project (personal/doc repos, or a team repo that shares config with teammates who don't run rulesets) tracks the set instead. =install-ai.sh= writes the full set at bootstrap in gitignore mode; =scripts/sweep-gitignore-tooling.sh= backfills it idempotently across existing gitignore-mode projects when the set grows. +**Public reachability decides harder than project type.** Any repo whose remotes include a non-cjennings.net host gitignores the tooling set, whatever kind of project it is — the only exception is a team repo that deliberately shares the config, decided explicitly, never by default. And a private remote is not proof of privacy: a server-side =post-receive --mirror= hook republishes invisibly from the client (the 2026-06-30 =.emacs.d= exposure rode exactly that — a cjennings.net remote mirroring to public GitHub). The sweep recognizes both the anchored (=/.ai/=) and unanchored (=.ai/=) ignore styles — an anchored-style project used to be misread as track-mode and silently skipped — and warns when tracked tooling can reach a non-cjennings.net remote. + **Credential-leak concern: gate it on project type, not on the credential itself.** A tracked secret, token, or credentials doc is only a public-leak risk where the repo can reach a public remote — that is, *code projects pushed to public GitHub*, which is exactly why those gitignore =.ai/= and =.claude/=. For *personal / documentation projects* (the =~/projects/= set: elibrary, home, finances, health, philosophy, etc.), the git remote is a private single-user repo on =cjennings.net=, so tracked credentials inside =.ai/= files are fine — that's the design, the project history IS the project. Do NOT raise a leak warning or suggest gitignoring a secret for these. When the question "is this a leak / should we gitignore this secret?" comes up, decide it on *which kind of project and remote* this is, never on the mere presence of a credential in a tracked file. **When to break out documents:** diff --git a/.ai/scripts/inbox-send.py b/.ai/scripts/inbox-send.py index 1362a1f..1ebb636 100755 --- a/.ai/scripts/inbox-send.py +++ b/.ai/scripts/inbox-send.py @@ -177,6 +177,23 @@ def build_text_org(message: str, source_name: str, timestamp: str) -> str: ) +def uniquify(dest: Path) -> Path: + """Return dest, or dest with a -2/-3/... stem suffix when it already exists. + + Two sends in the same minute whose text starts with the same phrase + derive identical filenames, and the second silently overwrote the + first (a message was lost this way, 2026-07-02). Never overwrite. + """ + if not dest.exists(): + return dest + n = 2 + while True: + candidate = dest.with_name(f"{dest.stem}-{n}{dest.suffix}") + if not candidate.exists(): + return candidate + n += 1 + + def send_text( target_inbox: Path, message: str, @@ -191,7 +208,7 @@ def send_text( if not slug: raise ValueError(f"could not derive a slug from text: {message!r}") filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org" - dest = target_inbox / filename + dest = uniquify(target_inbox / filename) dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT))) return dest @@ -211,7 +228,7 @@ def send_file( raise ValueError(f"could not derive a slug from file: {src_path}") ext = src_path.suffix filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}" - dest = target_inbox / filename + dest = uniquify(target_inbox / filename) shutil.copy2(src_path, dest) return dest diff --git a/.ai/scripts/lint-org.el b/.ai/scripts/lint-org.el index 3633dba..90b1b1d 100644 --- a/.ai/scripts/lint-org.el +++ b/.ai/scripts/lint-org.el @@ -29,6 +29,13 @@ ;; link-to-local-file broken file: links ;; invalid-fuzzy-link broken *Heading refs ;; suspicious-language-in-src-block unknown source-block language +;; org-table-standard table wider than budget / missing rules +;; level-2-dated-header ** dated header instead of a keyword +;; indented-heading whitespace before stars (demoted to body) +;; 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: @@ -393,6 +400,136 @@ Emits one judgment item per offending heading (checker "level-2 dated header is a completion defect (todo-format.md): a ** task or VERIFY closes with DONE/CANCELLED + CLOSED:, not a dated heading — convert it so --archive-done can archive it")))) ;;; --------------------------------------------------------------------------- +;;; structural heading checks (mistakes org-lint does not cover) +;; +;; org-lint validates links, drawers, blocks, and babel — but not heading +;; well-formedness. These four catch hand-edit defects it misses, all +;; judgment-only (each repair is a human call) and regex-based (no dependence on +;; which TODO keywords the batch Emacs happens to recognize): +;; +;; indented-heading leading whitespace before two-or-more stars; org +;; demotes it to body text, so the task vanishes from +;; the agenda and never archives. The worst case — an +;; invisible task — and silent. Single `*' is left +;; alone (a valid indented plain-list bullet). +;; empty-heading a line of bare stars with no title. +;; malformed-priority-cookie a `[#x]'-shaped token org rejected (lowercase, +;; multi-char, non-letter) sitting where a cookie +;; would be. +;; level2-done-without-closed a level-2 DONE/CANCELLED with no CLOSED line — +;; directly relevant to todo-cleanup's aging step, +;; which archives an undated completed task at once. + +(defconst lo-done-keywords '("DONE" "CANCELLED") + "Heading keywords treated as completed for `lo--check-level2-done-without-closed'.") + +(defun lo--check-indented-headings () + "Flag lines that are whitespace + two-or-more stars + space outside any block. +Org parses a heading only at column 0, so leading whitespace silently demotes a +would-be heading to body text. Two-or-more stars is required: an indented +single `*' is a valid plain-list bullet, not a lost heading, so flagging it +false-positives on legitimate lists; `**'+ is never a bullet, so an indented one +is unambiguously a demoted level-2+ heading turned invisible. Lines inside +`#+begin_/#+end_' blocks are skipped — indented asterisks there are legitimate +content." + (save-excursion + (goto-char (point-min)) + (let ((in-block nil)) + (while (not (eobp)) + (cond + ((looking-at-p "^[ \t]*#\\+begin_") (setq in-block t)) + ((looking-at-p "^[ \t]*#\\+end_") (setq in-block nil)) + ((and (not in-block) (looking-at-p "^[ \t]+\\*\\*+[ \t]")) + (lo--emit-judgment + 'indented-heading (line-number-at-pos) + "indented heading: leading whitespace before the stars demotes this to body text — org won't treat it as a heading (it vanishes from the agenda and never archives); dedent to column 0"))) + (forward-line 1))))) + +(defun lo--check-empty-headings () + "Flag headings that are bare stars with no title text. +A line of nothing but stars is an empty heading — a stray heading-star carrying +no content." + (save-excursion + (goto-char (point-min)) + (while (re-search-forward "^\\*+[ \t]*$" nil t) + (lo--emit-judgment + 'empty-heading (line-number-at-pos) + "empty heading: a line of stars with no title — delete it or give it a title")))) + +(defun lo--check-malformed-priority-cookies () + "Flag a heading whose first cookie-shaped token is not a valid priority. +A valid cookie is a single uppercase letter in `[#A]' form. Verbatim-wrapped +cookies (`=[#D]=' quoted in a dated-log title) are skipped. Only the first +token on the line is checked, so a real cookie earlier on the line means a +later `[#x]' in the title is left alone." + (save-excursion + (goto-char (point-min)) + ;; Case-sensitive: a cookie is uppercase only, and case-fold-search defaults + ;; to t (which would accept [#a] as valid). + (let ((case-fold-search nil)) + (while (re-search-forward "^\\*+ " nil t) + (let ((eol (line-end-position)) (hline (line-number-at-pos))) + (when (re-search-forward "\\[#\\([^]]*\\)\\]" eol t) + (let ((inner (match-string 1)) + (before (char-before (match-beginning 0))) + (after (char-after (match-end 0)))) + (unless (or (eq before ?=) (eq after ?=) + (string-match-p "\\`[A-Z]\\'" inner)) + (lo--emit-judgment + 'malformed-priority-cookie hline + (format "malformed priority cookie [#%s] — a cookie is a single uppercase letter ([#A]) right after the keyword; fix or remove it" + inner))))) + (goto-char eol)))))) + +(defun lo--check-level2-done-without-closed () + "Flag a level-2 DONE/CANCELLED heading with no CLOSED line in its own entry. +todo-cleanup's `--archive-done' aging step archives a completed task with no +parseable CLOSED date immediately, so an undated completed task silently leaves +the live file on the next `task-sorted'." + (save-excursion + (goto-char (point-min)) + ;; Case-sensitive: DONE/CANCELLED are uppercase keywords, not the words + ;; "done"/"cancelled" in a heading title (case-fold-search defaults to t). + (let ((case-fold-search nil) + (re (format "^\\*\\* \\(%s\\) " + (mapconcat #'regexp-quote lo-done-keywords "\\|")))) + (while (re-search-forward re nil t) + (let ((hline (line-number-at-pos)) + (entry-end (save-excursion (outline-next-heading) (point)))) + (save-excursion + (forward-line 1) + (unless (re-search-forward "^[ \t]*CLOSED:[ \t]*\\[" entry-end t) + (lo--emit-judgment + 'level2-done-without-closed hline + "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) @@ -428,6 +565,12 @@ left unmodified and mechanical entries are recorded with :preview t." (lo--check-tables) ;; Same shape: flag level-2 dated headers (completion defects). (lo--check-level2-dated-headers) + ;; Structural heading defects org-lint doesn't cover. + (lo--check-indented-headings) + (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/route-batch b/.ai/scripts/route-batch new file mode 100755 index 0000000..8f27d19 --- /dev/null +++ b/.ai/scripts/route-batch @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""route-batch — the wrap-up router's mechanical go path. + +The wrap-up cross-project router (wrap-it-up.org Step 3; wrapup-routing spec +D7/D8/D9) surfaces the local tasks that inbox process mode stamped with +:ROUTE_CANDIDATE: <destination> at file time, and on "go" delivers each to its +destination project's inbox. This script does the mechanical half so the +subtree surgery is deterministic: + + route-batch --list [--todo todo.org] + One "<destination>\t<heading>" line per :ROUTE_CANDIDATE:-tagged task. + Silent with exit 0 when there are no candidates (the workflow's + empty-set-equals-zero-interaction rule). Read-only. + + route-batch --go [--todo todo.org] + For each candidate, bottom-up: extract the task's whole subtree + (children ride along), drop the :ROUTE_CANDIDATE: line (and the + property drawer if that leaves it empty), promote the subtree so its + top heading is level 1, write it to a temp file, and deliver it via + the sibling inbox-send.py to the destination's inbox/ (one file per + task, from-<source> provenance stamped by inbox-send). Only after a + successful send is the subtree removed from the local todo.org — a + failed send leaves that task in place, is reported, and the run exits + non-zero after attempting the rest. + +The candidate set is exactly the tagged tasks — never the standing backlog. +Discovery, roots, and the source-project name all come from inbox-send.py +(INBOX_SEND_ROOTS sandboxes it in tests). The reject-from-another-project +flow in inbox process mode is the mis-route recovery; that path is why +removing the local source after a successful send is safe. +""" + +import argparse +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +HEADING_RE = re.compile(r"^(\*+)\s+(.*)$") +MARKER_RE = re.compile(r"^\s*:ROUTE_CANDIDATE:\s+(\S+)\s*$") + + +def find_candidates(lines): + """[(heading_idx, end_idx, marker_idx, destination, heading_text)] — + end_idx is one past the subtree's last line.""" + candidates = [] + for i, line in enumerate(lines): + m = MARKER_RE.match(line) + if not m: + continue + head_idx = None + for j in range(i, -1, -1): + hm = HEADING_RE.match(lines[j]) + if hm: + head_idx = j + level = len(hm.group(1)) + heading = hm.group(2) + break + if head_idx is None: + continue + end = len(lines) + for k in range(head_idx + 1, len(lines)): + km = HEADING_RE.match(lines[k]) + if km and len(km.group(1)) <= level: + end = k + break + candidates.append((head_idx, end, i, m.group(1), heading)) + return candidates + + +def extract_handoff(lines, head_idx, end): + """The subtree as handoff text: every :ROUTE_CANDIDATE: line dropped + (a marker is meaningless at the destination), empty drawers pruned, + headings promoted so the task is level 1.""" + sub = [l for l in lines[head_idx:end] if not MARKER_RE.match(l)] + + pruned = [] + i = 0 + while i < len(sub): + if sub[i].strip() == ":PROPERTIES:" and i + 1 < len(sub) and sub[i + 1].strip() == ":END:": + i += 2 + continue + pruned.append(sub[i]) + i += 1 + + shift = len(HEADING_RE.match(pruned[0]).group(1)) - 1 + if shift > 0: + pruned = [l[shift:] if HEADING_RE.match(l) else l for l in pruned] + return "\n".join(pruned).rstrip() + "\n" + + +def send(destination, handoff_text, slug): + inbox_send = Path(__file__).with_name("inbox-send.py") + with tempfile.NamedTemporaryFile( + "w", suffix=".org", prefix=f"route-{slug}-", delete=False, encoding="utf-8" + ) as tf: + tf.write(handoff_text) + tmp = tf.name + try: + result = subprocess.run( + [sys.executable, str(inbox_send), destination, "--file", tmp], + capture_output=True, text=True, + ) + return result.returncode == 0, (result.stderr or result.stdout).strip() + finally: + os.unlink(tmp) + + +def main(): + ap = argparse.ArgumentParser(prog="route-batch") + mode = ap.add_mutually_exclusive_group(required=True) + mode.add_argument("--list", action="store_true", dest="list_mode") + mode.add_argument("--go", action="store_true") + ap.add_argument("--todo", default="todo.org") + args = ap.parse_args() + + todo_path = Path(args.todo) + if not todo_path.is_file(): + return 0 # no todo file, no candidates + lines = todo_path.read_text(encoding="utf-8").splitlines() + candidates = find_candidates(lines) + + # Two markers in one task's drawer are one candidate, not two: same span + + # same destination dedupes. Everything else that overlaps — a tagged child + # inside a tagged parent, one task tagged for two destinations — is a + # conflict: routing either span would silently take the other (or, with a + # stale end index, a bystander task) along. Conflicts are left in place + # and reported; the human untangles which project the pieces belong to. + deduped = [] + for cand in candidates: + if not any(c[0] == cand[0] and c[1] == cand[1] and c[3] == cand[3] for c in deduped): + deduped.append(cand) + conflicted = set() + for a in deduped: + for b in deduped: + if a is not b and a[0] <= b[0] and b[1] <= a[1]: + conflicted.add(a) + conflicted.add(b) + routable = [c for c in deduped if c not in conflicted] + + if not deduped: + return 0 + + if args.list_mode: + for _h, _e, _m, dest, heading in deduped: + flag = "\tCONFLICT (overlapping candidates — resolve by hand)" if (_h, _e, _m, dest, heading) in conflicted else "" + print(f"{dest}\t{heading}{flag}") + return 0 + + failures = 0 + for _h, _e, _m, dest, heading in sorted(conflicted): + failures += 1 + print(f"CONFLICT: {dest}\t{heading}\t(overlapping candidate subtrees — left in place, resolve by hand)") + + # Bottom-up so earlier indices stay valid as subtrees are removed; the + # file is rewritten after every successful send so a crash mid-run never + # leaves an already-sent task still present locally. + for head_idx, end, _marker_idx, dest, heading in sorted(routable, reverse=True): + handoff = extract_handoff(lines, head_idx, end) + slug = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-")[:40] or "task" + ok, detail = send(dest, handoff, slug) + if ok: + del lines[head_idx:end] + todo_path.write_text("\n".join(lines).rstrip("\n") + "\n", encoding="utf-8") + print(f"routed: {dest}\t{heading}") + else: + failures += 1 + print(f"FAILED: {dest}\t{heading}\t({detail})") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.ai/scripts/route_recommend.py b/.ai/scripts/route_recommend.py new file mode 100644 index 0000000..7b36405 --- /dev/null +++ b/.ai/scripts/route_recommend.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Wrap-up routing recommendation engine. + +Given an inbox keeper's text and a list of candidate project names, infer which +project the item belongs to, with a confidence tier: + + strong a project's name (or its dot-stripped form, or a path containing it) + appears literally in the item + weak a distinctive name token overlaps, but the full name doesn't + none no overlap; the item stays put + +A multi-way tie at the top tier is ambiguous, so it downgrades to weak with a +deterministic pick (most token overlap, then alphabetical). An empty candidate +list yields none. + +The pure core is `recommend(item, projects) -> (destination, confidence)` — the +shape the wrap-up router (Phase 4) and the process-inbox marker (Phase 2) both +call. The CLI wires it to inbox-send.py's `discover_projects` so the candidate +set is the same project universe inbox-send already knows. + +CLI: + route_recommend.py --item "<text>" [--exclude <current-project>] +prints "<destination>\\t<confidence>" on a match, or "none". +""" + +import argparse +import importlib.util +import re +import sys +from pathlib import Path + +# A distinctive-enough token for weak matching; shorter tokens (of, to, id) are +# too noisy to route on. +MIN_WEAK_TOKEN = 4 + +_TOKEN_RE = re.compile(r"[a-z0-9]+") + + +def _tokens(text: str) -> set[str]: + return set(_TOKEN_RE.findall(text.lower())) + + +def _name_variants(name: str) -> set[str]: + """A project name and its dot-stripped alias (.emacs.d -> emacsd).""" + return {v for v in (name.lower(), name.replace(".", "").lower()) if v} + + +def _literal_present(name: str, item_lower: str) -> bool: + """True if a name variant appears in the item on word-ish boundaries. + + Boundaries keep 'home' from matching inside 'homeowner' while still + matching it inside a path ('~/code/home/...') or a hyphenated name. + """ + for variant in _name_variants(name): + if re.search(r"(?<![a-z0-9])" + re.escape(variant) + r"(?![a-z0-9])", item_lower): + return True + return False + + +def _tiebreak(candidates: list[str], item_tokens: set[str]) -> str: + """Most token overlap first, then alphabetical — deterministic.""" + return sorted(candidates, key=lambda p: (-len(_tokens(p) & item_tokens), p))[0] + + +def recommend(item: str, projects: list[str]) -> tuple[str | None, str]: + """Infer the destination project for `item` from `projects`. + + Returns (destination, confidence). confidence is "strong" / "weak" / "none"; + destination is None exactly when confidence is "none". + """ + if not projects: + return (None, "none") + + item_lower = item.lower() + item_tokens = _tokens(item) + + strong: list[str] = [] + weak: list[str] = [] + for project in projects: + if _literal_present(project, item_lower): + strong.append(project) + continue + name_tokens = {t for t in _tokens(project) if len(t) >= MIN_WEAK_TOKEN} + if name_tokens & item_tokens: + weak.append(project) + + if len(strong) == 1: + return (strong[0], "strong") + if len(strong) > 1: + return (_tiebreak(strong, item_tokens), "weak") + if len(weak) == 1: + return (weak[0], "weak") + if len(weak) > 1: + return (_tiebreak(weak, item_tokens), "weak") + return (None, "none") + + +def _load_inbox_send(): + """Load the sibling kebab-named inbox-send.py as a module for its discovery.""" + path = Path(__file__).with_name("inbox-send.py") + spec = importlib.util.spec_from_file_location("inbox_send", path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def discover_destination_names(exclude: str | None = None) -> list[str]: + """The candidate project names, reusing inbox-send's discovery. + + `exclude` drops the current project (matched by exact name or dot-stripped + alias) so the engine never recommends routing an item to where it already is. + """ + mod = _load_inbox_send() + names = [p.name for p in mod.discover_projects(mod.resolve_roots())] + if exclude: + drop = _name_variants(exclude) + names = [n for n in names if not (_name_variants(n) & drop)] + return names + + +def main() -> int: + parser = argparse.ArgumentParser(description="Recommend a routing destination for an inbox keeper.") + parser.add_argument("--item", required=True, help="the keeper's text") + parser.add_argument("--exclude", help="current project to exclude from candidates") + args = parser.parse_args() + + projects = discover_destination_names(exclude=args.exclude) + destination, confidence = recommend(args.item, projects) + print("none" if destination is None else f"{destination}\t{confidence}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.ai/scripts/self-inject.sh b/.ai/scripts/self-inject.sh new file mode 100755 index 0000000..e7340c1 --- /dev/null +++ b/.ai/scripts/self-inject.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# self-inject.sh — type text into the tmux pane running this agent session. +# +# The building block for AUTO-FLUSH: an agent checkpoints its session-context, +# then has tmux type "/clear" and a resume prompt at its own idle prompt, so a +# session flushes with no human at the keyboard. +# +# Usage: +# self-inject.sh -t %PANE <delay> <text> [<delay2> <text2> ...] +# self-inject.sh <delay> <text> [...] # derive pane from ancestry +# self-inject.sh [-t %PANE] # no pairs: report the pane +# +# Each pair: sleep <delay> seconds, then type <text> literally and press Enter. +# +# TWO HARD-WON GOTCHAS (2026-07-02, archsetup session): +# 1. A detached child (setsid/nohup/&) of an agent tool call DIES when the +# tool call ends — the harness cleans up the process group. The arm step +# must run under the tmux SERVER instead: +# tmux run-shell -b "self-inject.sh -t %1 25 '/clear' 15 'go — resume...'" +# 2. Under tmux run-shell the process is a child of the tmux server, so +# ancestry-based pane detection CANNOT work there. Derive the pane FIRST, +# synchronously from the agent's own shell (no -t), then pass it +# explicitly with -t when arming. +# +# Collision hazard: if the user happens to be typing when the send fires, the +# injected text merges into their input line (a real /clear became "/clearto" +# mid-word). Auto-flush is for sessions running unattended; warn the user to +# keep hands off for the armed window if they're present. + +PANE="" +if [ "$1" = "-t" ]; then + PANE=$2; shift 2 +fi + +ppid_of() { + # /proc/<pid>/stat: pid (comm) state ppid ... — comm may contain spaces, + # so take the 2nd field after the LAST ')'. + stat=$(cat "/proc/$1/stat" 2>/dev/null) || return 1 + # shellcheck disable=SC2086 # word-splitting the stat tail is the point + set -- ${stat##*) } + echo "$2" +} + +find_pane() { + anc=" " + pid=$$ + while [ -n "$pid" ] && [ "$pid" -gt 1 ] 2>/dev/null; do + anc="$anc$pid " + pid=$(ppid_of "$pid") || break + done + tmux list-panes -a -F "#{pane_pid} #{pane_id}" 2>/dev/null | \ + while read -r ppid pane; do + case "$anc" in *" $ppid "*) echo "$pane"; break;; esac + done +} + +[ -n "$PANE" ] || PANE=$(find_pane) +[ -n "$PANE" ] || { echo "self-inject: no owning pane found (pass -t %PANE)" >&2; exit 1; } + +# With no delay/text pairs, just report the pane (the derive-first step). +[ $# -ge 2 ] || { echo "$PANE"; exit 0; } + +while [ $# -ge 2 ]; do + sleep "$1" + tmux send-keys -t "$PANE" -l "$2" + tmux send-keys -t "$PANE" Enter + shift 2 +done diff --git a/.ai/scripts/spec-sort b/.ai/scripts/spec-sort new file mode 100755 index 0000000..ebfef82 --- /dev/null +++ b/.ai/scripts/spec-sort @@ -0,0 +1,715 @@ +#!/usr/bin/env python3 +"""spec-sort — one-time docs-pile retrofit for the docs-lifecycle convention. + +Classifies every docs/**/*.org outside docs/specs/ by one predicate: a doc +carrying BOTH a "Decisions" heading AND an "Implementation phases" heading is +a spec candidate; everything else is a note. For each candidate it shows an +evidence panel (Status field, decision/finding cookies, the linking todo.org +task, recent dated history, cheap existence checks on phase-named artifacts) +and proposes a lifecycle keyword the evidence supports — conservative +non-terminal (DRAFT) when inconclusive. The helper proposes; a human confirms +every move. + +Dry-run report is the default. --apply executes under the fail-safe contract: + + - Clean-worktree preflight: refuses on a dirty git tree (exit 2) unless + --allow-dirty, which prints exactly what recovery loses. + - Every candidate must be addressed with --confirm REL=KEYWORD or + --skip REL; terminal keywords (IMPLEMENTED SUPERSEDED CANCELLED) also + need --reason REL=TEXT, recorded in the status-history line. + - The full move + relink plan is computed and validated first (every + destination free, every link resolvable), written to a plan file, and + only then executed from that recorded plan. + - Bare-path mentions of a moving doc inside the rewritten roots are + reported, never rewritten; they block --apply until --acknowledge-bare + explicitly waives them. + - Mid-apply failure stops the run, names what was and wasn't applied, and + prints the git-restore recovery recipe (plus deletion of newly created + destination copies, which git restore can't remove). + - After a successful apply, a residue scan across the rewritten roots must + find no link still resolving to an old path, or spec-sort exits non-zero + naming the residue. + +Per move: rename to carry the -spec.org suffix, prepend the status heading +(:ID: UUID + dated history line), rewrite the keyword header to the +two-sequence form, mirror the keyword into the Metadata Status field, and +recompute every affected file: link (inbound links to the moved doc AND the +moved doc's own outbound relative links). Rewritten roots: todo.org, +.ai/notes.org, docs/**, .ai/project-workflows/, .ai/project-scripts/. +Reported-never-rewritten: .ai/sessions/ (frozen history) and synced template +paths (.ai/workflows/, .ai/scripts/, .ai/protocols.org — the report names +the canonical claude-templates file instead). + +Finally stamps :LAST_SPEC_SORT: YYYY-MM-DD in .ai/notes.org's +* Workflow State section (created idempotently), which permanently clears +the startup nudge. A run with zero candidates still stamps. + +Exit codes: 0 done (or clean report), 1 blocked (confirm gate, validation, +bare mentions, residue, mid-apply failure), 2 usage / preflight refusal. + +Test hook: SPEC_SORT_INJECT_FAIL_AFTER=N aborts the apply after N write +operations, exercising the recovery path in the bats suite. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +import uuid +from datetime import datetime + +LIFECYCLE = ("DRAFT", "READY", "DOING", "IMPLEMENTED", "SUPERSEDED", "CANCELLED") +TERMINAL = {"IMPLEMENTED", "SUPERSEDED", "CANCELLED"} +TODO_HEADER = [ + "#+TODO: TODO | DONE", + "#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED", +] + +# Project-owned surfaces whose file: links get rewritten. +REWRITE_ROOTS = ("todo.org", ".ai/notes.org", "docs", ".ai/project-workflows", ".ai/project-scripts") +# Frozen or synced surfaces: occurrences are reported, never rewritten. +REPORT_ROOTS = (".ai/sessions", ".ai/workflows", ".ai/scripts", ".ai/protocols.org") +# Synced template paths map to their canonical rulesets file for the report. +SYNCED_PREFIX = (".ai/workflows", ".ai/scripts", ".ai/protocols.org") + +LINK_RE = re.compile(r"\[\[file:([^\]\[]+)\](?:\[([^\]\[]*)\])?\]") +HEADING_RE = re.compile(r"^(\*+)\s+(.*)$") +COOKIE_RE = re.compile(r"\[\d+/\d+\]") +DATED_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b") + + +def read_text(path): + try: + with open(path, encoding="utf-8") as f: + return f.read() + except (UnicodeDecodeError, OSError): + return None + + +def heading_text(line): + """Heading text with the org keyword and priority cookie stripped.""" + m = HEADING_RE.match(line) + if not m: + return None + text = re.sub(r"^[A-Z]+\s+", "", m.group(2)) + text = re.sub(r"^\[#[A-Z]\]\s+", "", text) + return text.strip() + + +def has_spine(content): + """The classification predicate: Decisions AND Implementation phases.""" + dec = imp = False + for line in content.splitlines(): + t = heading_text(line) + if t is None: + continue + tl = t.lower() + if tl.startswith("decisions"): + dec = True + elif tl.startswith("implementation phases"): + imp = True + return dec and imp + + +def walk_files(root, rel_base): + """Yield project-relative paths of files under rel_base (file or dir).""" + abs_base = os.path.join(root, rel_base) + if os.path.isfile(abs_base): + yield rel_base + return + for dirpath, dirs, files in os.walk(abs_base): + dirs.sort() + for name in sorted(files): + yield os.path.relpath(os.path.join(dirpath, name), root) + + +def classify(root): + """Split docs/**/*.org outside docs/specs/ into candidates / anomalies / notes.""" + candidates, anomalies, notes = [], [], [] + docs = os.path.join(root, "docs") + if not os.path.isdir(docs): + return candidates, anomalies, notes + for rel in walk_files(root, "docs"): + if not rel.endswith(".org"): + continue + parts = rel.split(os.sep) + if len(parts) > 1 and parts[1] == "specs": + continue + content = read_text(os.path.join(root, rel)) + if content is None: + continue + if has_spine(content): + candidates.append(rel) + elif os.path.basename(rel).endswith("-spec.org"): + anomalies.append(rel) + else: + notes.append(rel) + return candidates, anomalies, notes + + +def dest_for(rel): + base = os.path.basename(rel) + if not base.endswith("-spec.org"): + base = base[: -len(".org")] + "-spec.org" + return os.path.join("docs", "specs", base) + + +# ---- Evidence panel --------------------------------------------------- + + +def todo_task_for(root, rel): + """Heading of the first todo.org task whose subtree mentions the doc.""" + content = read_text(os.path.join(root, "todo.org")) + if content is None: + return None + lines = content.splitlines() + basename = os.path.basename(rel) + for i, line in enumerate(lines): + if basename in line or rel in line: + for j in range(i, -1, -1): + if HEADING_RE.match(lines[j]): + return lines[j].lstrip("* ").strip() + return None + return None + + +def gather_evidence(root, rel, content): + ev = {} + m = re.search(r"^\|\s*Status\s*\|\s*([^|]*)\|", content, re.MULTILINE | re.IGNORECASE) + ev["status"] = m.group(1).strip() if m else None + + cookies = [] + for line in content.splitlines(): + t = heading_text(line) + if t and COOKIE_RE.search(t) and ( + t.lower().startswith("decisions") or t.lower().startswith("review findings") + ): + cookies.append(t) + ev["cookies"] = cookies + + ev["todo"] = todo_task_for(root, rel) + kw = None + if ev["todo"]: + m = re.match(r"([A-Z]+)\s", ev["todo"]) + kw = m.group(1) if m else None + ev["todo_keyword"] = kw + + dated = [ln.strip() for ln in content.splitlines() if DATED_RE.search(ln)] + ev["history"] = dated[-1][:100] if dated else None + + # Cheap artifact check: =path= tokens inside the Implementation phases section. + artifacts, exists = [], 0 + section = re.split(r"^\*+\s+.*implementation phases.*$", content, maxsplit=1, flags=re.MULTILINE | re.IGNORECASE) + if len(section) > 1: + for tok in re.findall(r"=([^=\s]+)=", section[1]): + if "/" in tok: + artifacts.append(tok) + if os.path.exists(os.path.join(root, tok)): + exists += 1 + ev["artifacts"] = (exists, artifacts) + return ev + + +def propose_keyword(ev): + s = (ev["status"] or "").lower() + words = set(re.findall(r"[a-z]+", s)) + if words & {"implemented", "shipped", "complete", "completed", "done"}: + return "IMPLEMENTED" + if words & {"superseded"}: + return "SUPERSEDED" + if words & {"cancelled", "canceled", "dead", "abandoned"}: + return "CANCELLED" + if words & {"doing", "implementing"} or "in progress" in s or "in-progress" in s: + return "DOING" + if ev["todo_keyword"] == "DOING": + return "DOING" + if words & {"ready", "approved", "accepted"}: + return "READY" + return "DRAFT" # conservative non-terminal default + + +# ---- Link scanning ---------------------------------------------------- + + +def rewrite_files(root): + """Project-relative *.org files under the rewritten roots.""" + seen = [] + for base in REWRITE_ROOTS: + if not os.path.exists(os.path.join(root, base)): + continue + for rel in walk_files(root, base): + if rel.endswith(".org") and rel not in seen: + seen.append(rel) + return seen + + +def resolve_target(root, linker_rel, raw_target, moved): + """Resolve a file: link target to a project-relative path (org semantics + first — relative to the linking file's directory — then project-root + anchoring as a fallback for root-anchored links).""" + if raw_target.startswith(("/", "~", "http:", "https:")): + return None + rel_a = os.path.normpath(os.path.join(os.path.dirname(linker_rel), raw_target)) + if rel_a in moved or os.path.exists(os.path.join(root, rel_a)): + return rel_a + rel_b = os.path.normpath(raw_target) + if rel_b in moved or os.path.exists(os.path.join(root, rel_b)): + return rel_b + return rel_a + + +def plan_link_edits(root, moved): + """Compute every link rewrite: inbound links to moved docs and moved + docs' own outbound relative links. Returns ({linker_rel: [(old, new)]}, + [ambiguity descriptions]) — a link whose file-relative and root-anchored + readings are both live and disagree about a moving doc blocks validation + rather than being rewritten against a guess.""" + edits = {} + ambiguous = [] + for linker in rewrite_files(root): + content = read_text(os.path.join(root, linker)) + if content is None: + continue + linker_post = moved.get(linker, linker) + for m in LINK_RE.finditer(content): + raw = m.group(1) + desc = m.group(2) + target_path, sep, anchor = raw.partition("::") + target = resolve_target(root, linker, target_path, moved) + if target is None: + continue + rel_a = os.path.normpath(os.path.join(os.path.dirname(linker), target_path)) + rel_b = os.path.normpath(target_path) + if rel_a != rel_b: + live_a = rel_a in moved or os.path.exists(os.path.join(root, rel_a)) + live_b = rel_b in moved or os.path.exists(os.path.join(root, rel_b)) + if live_a and live_b and (rel_a in moved or rel_b in moved): + ambiguous.append( + "%s: [[file:%s]] reads as %s (file-relative) or %s (root-anchored) " + "and a moving doc is involved — resolve the link by hand" % (linker, raw, rel_a, rel_b)) + continue + if target not in moved and linker not in moved: + continue + if target not in moved and not os.path.exists(os.path.join(root, target)): + continue # already broken before this run; not ours to guess + target_post = moved.get(target, target) + new_path = os.path.relpath(target_post, os.path.dirname(linker_post) or ".") + new_raw = new_path + (sep + anchor if sep else "") + if new_raw == raw: + continue + new_link = "[[file:%s]%s]" % (new_raw, "[%s]" % desc if desc is not None else "") + if m.group(0) != new_link: + edits.setdefault(linker, []).append((m.group(0), new_link)) + return edits, ambiguous + + +def scan_bare_mentions(root, moved): + """Bare-path mentions of moving docs in the rewritten roots — text + occurrences outside any [[...]] link. Reported, never rewritten.""" + found = [] + for base in REWRITE_ROOTS: + if not os.path.exists(os.path.join(root, base)): + continue + for rel in walk_files(root, base): + content = read_text(os.path.join(root, rel)) + if content is None: + continue + for i, line in enumerate(content.splitlines(), 1): + stripped = re.sub(r"\[\[[^\]]*\](?:\[[^\]]*\])?\]", "", line) + for src in moved: + if src in stripped: + found.append((rel, i, src)) + return found + + +def scan_report_only(root, moved): + """Occurrences of moving docs in frozen/synced surfaces.""" + reports = [] + for base in REPORT_ROOTS: + if not os.path.exists(os.path.join(root, base)): + continue + for rel in walk_files(root, base): + content = read_text(os.path.join(root, rel)) + if content is None: + continue + for src in moved: + if src in content: + if rel.startswith(SYNCED_PREFIX): + note = ("synced template, not rewritten — a local edit is reverted by the " + "next sync; edit the canonical claude-templates/%s instead" % rel) + else: + note = "frozen history; not rewritten" + reports.append((rel, src, note)) + return reports + + +# ---- Content transforms ----------------------------------------------- + + +def transform_spec(content, keyword, reason, title, doc_id, link_edits): + """Apply the retrofit rewrite to a moving spec's content: two-sequence + keyword header, prepended status heading, Status-field mirror, and the + doc's own link edits.""" + for old, new in link_edits: + content = content.replace(old, new) + lines = content.splitlines() + + todo_idx = None + kept = [] + for line in lines: + if line.startswith("#+TODO:"): + if todo_idx is None: + todo_idx = len(kept) + continue + kept.append(line) + lines = kept + if todo_idx is None: + todo_idx = 0 + while todo_idx < len(lines) and lines[todo_idx].startswith("#+"): + todo_idx += 1 + lines[todo_idx:todo_idx] = TODO_HEADER + + head_end = 0 + while head_end < len(lines) and (lines[head_end].startswith("#+") or not lines[head_end].strip()): + head_end += 1 + ts = datetime.now().astimezone().strftime("%Y-%m-%d %a @ %H:%M:%S %z") + provenance = "reason: %s" % reason if reason else "evidence-based, human-confirmed" + block = [ + "* %s %s" % (keyword, title), + ":PROPERTIES:", + ":ID: %s" % doc_id, + ":END:", + "- %s — retrofitted by spec-sort; status set to %s (%s)" % (ts, keyword, provenance), + "", + ] + lines[head_end:head_end] = block + + out = [] + mirrored = False + for line in lines: + m = re.match(r"^(\|\s*Status\s*\|)([^|]*)(\|.*)$", line, re.IGNORECASE) + if m and not mirrored: + value = " %s" % keyword.lower() + width = len(m.group(2)) + line = m.group(1) + (value.ljust(width) if len(value) <= width else value + " ") + m.group(3) + mirrored = True + out.append(line) + return "\n".join(out) + "\n" + + +def title_for(content, rel): + m = re.search(r"^#\+TITLE:\s*(.+)$", content, re.MULTILINE | re.IGNORECASE) + if m: + return m.group(1).strip() + base = os.path.basename(rel)[: -len(".org")] + return base[: -len("-spec")] if base.endswith("-spec") else base + + +# ---- Marker ------------------------------------------------------------ + + +def stamp_marker(root, date): + path = os.path.join(root, ".ai", "notes.org") + os.makedirs(os.path.dirname(path), exist_ok=True) + content = read_text(path) or "" + line = ":LAST_SPEC_SORT: %s" % date + if ":LAST_SPEC_SORT:" in content: + content = re.sub(r":LAST_SPEC_SORT:.*", line, content, count=1) + elif re.search(r"^\* Workflow State\s*$", content, re.MULTILINE): + content = re.sub(r"(^\* Workflow State\s*$)", r"\1\n" + line, content, count=1, flags=re.MULTILINE) + else: + if content and not content.endswith("\n"): + content += "\n" + content += "\n* Workflow State\n\n%s\n" % line + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +# ---- Apply ------------------------------------------------------------- + + +class ApplyFailure(Exception): + """Mid-apply failure: args are (applied_labels, remaining_ops, cause).""" + + +def apply_plan(root, plan, fail_after): + """Execute the recorded plan. Returns the applied-op labels; raises + ApplyFailure mid-way on a write error or when the test hook fires.""" + ops = [] + for mv in plan["moves"]: + ops.append(("move", mv)) + for linker, edits in plan["link_edits"].items(): + if linker in {mv["src"] for mv in plan["moves"]}: + continue # a moving doc's own edits ride along in its transform + ops.append(("relink", (linker, edits))) + + applied = [] + specs_dir = os.path.join(root, "docs", "specs") + if plan["moves"] and not os.path.isdir(specs_dir): + os.makedirs(specs_dir) + plan["created_dirs"].append(os.path.join("docs", "specs")) + + for n, (kind, payload) in enumerate(ops, 1): + if fail_after and n > fail_after: + raise ApplyFailure(applied, ops[n - 1:], "injected test failure") + try: + if kind == "move": + mv = payload + content = read_text(os.path.join(root, mv["src"])) + new = transform_spec(content, mv["keyword"], mv["reason"], mv["title"], mv["id"], + plan["link_edits"].get(mv["src"], [])) + with open(os.path.join(root, mv["dest"]), "w", encoding="utf-8") as f: + f.write(new) + os.remove(os.path.join(root, mv["src"])) + applied.append("move %s -> %s" % (mv["src"], mv["dest"])) + else: + linker, edits = payload + path = os.path.join(root, linker) + content = read_text(path) + for old, new in edits: + content = content.replace(old, new) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + applied.append("relink %s (%d link%s)" % (linker, len(edits), "s" if len(edits) != 1 else "")) + except OSError as exc: + raise ApplyFailure(applied, ops[n - 1:], str(exc)) + return applied + + +def residue_check(root, plan): + """Post-apply: no link in the rewritten roots may still resolve to an + old path; bare mentions beyond the acknowledged set fail too.""" + moved = {mv["src"]: mv["dest"] for mv in plan["moves"]} + residue = [] + for linker in rewrite_files(root): + content = read_text(os.path.join(root, linker)) + if content is None: + continue + for m in LINK_RE.finditer(content): + target_path = m.group(1).partition("::")[0] + target = resolve_target(root, linker, target_path, {}) + if target in moved: + residue.append("%s: link still resolves to %s" % (linker, target)) + # Acknowledged mentions were recorded pre-apply; a mention inside a moved + # doc now lives at the doc's destination, so map the file side through the + # moves before comparing. + acknowledged = {(moved.get(f, f), src) for f, _ln, src in plan["bare"]} + for f, ln, src in scan_bare_mentions(root, moved): + if (f, src) not in acknowledged: + residue.append("%s:%d: bare mention of %s" % (f, ln, src)) + return residue + + +def print_recovery(plan, applied, not_applied): + print("FAILURE — the apply did not complete.") + print(" applied:") + for a in applied or ["(nothing)"]: + print(" %s" % a) + print(" not applied:") + for kind, payload in not_applied: + if kind == "move": + print(" move %s -> %s" % (payload["src"], payload["dest"])) + else: + print(" relink %s" % payload[0]) + print("RECOVERY — restore the pre-run state (safe: preflight required a clean tree):") + touched = [mv["src"] for mv in plan["moves"]] + [l for l in plan["link_edits"] if l not in {mv["src"] for mv in plan["moves"]}] + print(" git restore -- %s" % " ".join(touched)) + created = [mv["dest"] for mv in plan["moves"]] + print(" rm -f -- %s # git restore can't remove the created copies" % " ".join(created)) + for d in plan.get("created_dirs", []): + print(" rmdir --ignore-fail-on-non-empty -- %s" % d) + + +# ---- Main --------------------------------------------------------------- + + +def parse_kv(pairs, label): + out = {} + for item in pairs or []: + if "=" not in item: + sys.exit("spec-sort: %s expects REL=VALUE, got %r" % (label, item)) + k, v = item.split("=", 1) + out[os.path.normpath(k)] = v + return out + + +def main(): + ap = argparse.ArgumentParser(prog="spec-sort", add_help=True) + ap.add_argument("--project-root", default=".") + ap.add_argument("--apply", action="store_true") + ap.add_argument("--allow-dirty", action="store_true") + ap.add_argument("--acknowledge-bare", action="store_true") + ap.add_argument("--confirm", action="append", metavar="REL=KEYWORD") + ap.add_argument("--reason", action="append", metavar="REL=TEXT") + ap.add_argument("--skip", action="append", metavar="REL") + ap.add_argument("--plan-file") + args = ap.parse_args() + + root = os.path.abspath(args.project_root) + confirms = parse_kv(args.confirm, "--confirm") + reasons = parse_kv(args.reason, "--reason") + skips = {os.path.normpath(s) for s in (args.skip or [])} + + candidates, anomalies, notes = classify(root) + if not candidates and not anomalies and not notes and not os.path.isdir(os.path.join(root, "docs")): + return 0 # no docs pile at all — silent no-op + + for named in list(confirms) + list(skips) + list(reasons): + if named not in candidates: + print("spec-sort: %s is not a spec candidate" % named) + return 1 + for rel, kw in confirms.items(): + if kw not in LIFECYCLE: + print("spec-sort: %r is not a lifecycle keyword (%s)" % (kw, " ".join(LIFECYCLE))) + return 1 + + # ---- Build the plan (shared by report and apply) ---- + moves = [] + for rel in candidates: + if rel in skips: + continue + if args.apply and rel not in confirms: + continue # gate failure reported below + content = read_text(os.path.join(root, rel)) + moves.append({ + "src": rel, + "dest": dest_for(rel), + "keyword": confirms.get(rel, None), + "reason": reasons.get(rel), + "title": title_for(content, rel), + "id": str(uuid.uuid4()), + }) + moved_map = {mv["src"]: mv["dest"] for mv in moves} + link_edits, ambiguous = plan_link_edits(root, moved_map) + bare = scan_bare_mentions(root, moved_map) + reports = scan_report_only(root, moved_map) + + # ---- Report ---- + for rel in candidates: + content = read_text(os.path.join(root, rel)) + ev = gather_evidence(root, rel, content) + proposed = propose_keyword(ev) + print("CANDIDATE %s -> %s" % (rel, dest_for(rel))) + suffix = " (terminal — requires --reason to apply)" if proposed in TERMINAL else "" + print(" proposed keyword: %s%s" % (proposed, suffix)) + print(" evidence:") + print(" status field: %s" % (ev["status"] or "(none)")) + print(" cookies: %s" % ("; ".join(ev["cookies"]) or "(none)")) + print(" todo.org: %s" % (ev["todo"] or "(no linking task)")) + print(" history: %s" % (ev["history"] or "(none)")) + n_exist, artifacts = ev["artifacts"] + if artifacts: + print(" artifacts: %d/%d named paths exist (%s)" % (n_exist, len(artifacts), ", ".join(artifacts))) + else: + print(" artifacts: (none named)") + for rel in anomalies: + print("ANOMALY %s: named -spec.org but lacks the spec spine (Decisions + Implementation phases); surfaced, not moved" % rel) + for rel in notes: + print("NOTE %s" % rel) + for linker, edits in sorted(link_edits.items()): + for old, new in edits: + print("RELINK %s: %s -> %s" % (linker, old, new)) + for a in ambiguous: + print("AMBIGUOUS %s" % a) + for f, ln, src in bare: + print("BARE-PATH %s:%d: %s (reported for manual handling, never rewritten)" % (f, ln, src)) + for rel, src, note in reports: + print("REPORT %s: reference to %s (%s)" % (rel, src, note)) + + if not args.apply: + if candidates or anomalies or notes: + print("DRY RUN — no changes written. Pass --apply with per-candidate --confirm/--skip to execute.") + return 0 + + # ---- Apply: preflight ---- + try: + porcelain = subprocess.run( + ["git", "status", "--porcelain"], cwd=root, + capture_output=True, text=True, check=True, + ).stdout + except (subprocess.CalledProcessError, FileNotFoundError): + print("spec-sort: --apply needs a git worktree (recovery depends on git restore)") + return 2 + if porcelain.strip(): + dirty = [ln[3:] for ln in porcelain.splitlines()] + if not args.allow_dirty: + print("spec-sort: refusing --apply on a dirty worktree (%d path%s). Commit or stash first, or pass --allow-dirty." + % (len(dirty), "s" if len(dirty) != 1 else "")) + return 2 + print("WARNING --allow-dirty: recovery via git restore would also revert your pre-existing uncommitted changes:") + for p in dirty: + print(" %s" % p) + + # ---- Apply: confirm gate ---- + unaddressed = [rel for rel in candidates if rel not in confirms and rel not in skips] + if unaddressed: + print("spec-sort: unconfirmed candidate(s) — pass --confirm REL=KEYWORD or --skip REL for each:") + for rel in unaddressed: + print(" %s" % rel) + return 1 + for mv in moves: + if mv["keyword"] in TERMINAL and not mv["reason"]: + print("spec-sort: %s -> %s is a terminal state and requires an explicit --reason %s=TEXT" + % (mv["src"], mv["keyword"], mv["src"])) + return 1 + + # ---- Apply: validation ---- + problems = [] + dests = {} + for mv in moves: + if os.path.exists(os.path.join(root, mv["dest"])): + problems.append("%s: destination exists (%s)" % (mv["src"], mv["dest"])) + if mv["dest"] in dests: + problems.append("%s and %s: destination exists twice (%s)" % (mv["src"], dests[mv["dest"]], mv["dest"])) + dests[mv["dest"]] = mv["src"] + for a in ambiguous: + problems.append("ambiguous link: %s" % a) + if bare and not args.acknowledge_bare: + problems.append("bare-path mention(s) listed above need manual handling — re-run with --acknowledge-bare to proceed without rewriting them") + if problems: + print("spec-sort: validation blocked — nothing written:") + for p in problems: + print(" %s" % p) + return 1 + + # ---- Apply: record the plan, then execute from it ---- + today = datetime.now().astimezone().strftime("%Y-%m-%d") + plan = { + "root": root, "date": today, "moves": moves, + "link_edits": link_edits, "bare": bare, + "reports": [list(r) for r in reports], "created_dirs": [], + } + plan_path = args.plan_file or os.path.join( + tempfile.gettempdir(), "spec-sort-plan-%s.json" % os.path.basename(root)) + with open(plan_path, "w", encoding="utf-8") as f: + json.dump(plan, f, indent=2) + print("plan written: %s" % plan_path) + + fail_after = int(os.environ.get("SPEC_SORT_INJECT_FAIL_AFTER", "0") or 0) + try: + applied = apply_plan(root, plan, fail_after) + except ApplyFailure as exc: + print("write failed: %s" % exc.args[2]) + print_recovery(plan, exc.args[0], exc.args[1]) + return 1 + + residue = residue_check(root, plan) + if residue: + print("spec-sort: residue after apply — old paths still referenced:") + for r in residue: + print(" %s" % r) + print_recovery(plan, applied, []) + return 1 + + stamp_marker(root, today) + for a in applied: + print("applied: %s" % a) + print("spec-sort: done — %d spec(s) sorted, :LAST_SPEC_SORT: %s stamped" % (len(moves), today)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.ai/scripts/tests/route-batch.bats b/.ai/scripts/tests/route-batch.bats new file mode 100644 index 0000000..84ded5f --- /dev/null +++ b/.ai/scripts/tests/route-batch.bats @@ -0,0 +1,202 @@ +#!/usr/bin/env bats +# +# Tests for claude-templates/.ai/scripts/route-batch — the wrap-up router's +# mechanical go path (wrapup-routing spec, Phase 4 / D7 / D9). +# +# Contract under test: +# route-batch --list one "<destination>\t<heading>" line per task +# carrying :ROUTE_CANDIDATE:; silent when none; +# never modifies anything +# route-batch --go per candidate: write the subtree (minus the +# :ROUTE_CANDIDATE: line) as a one-task handoff, +# deliver via inbox-send to the destination's +# inbox/, then remove the subtree from the local +# todo.org. Send failure leaves the task in +# place and exits non-zero. Empty set: no-op. +# +# Strategy: fixture roots under $TEST_DIR hold a source project and two +# destination projects; INBOX_SEND_ROOTS sandboxes inbox-send's discovery to +# them (the same hook inbox-send's own tests use). + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/route-batch" + +setup() { + TEST_DIR="$(mktemp -d -t route-batch-bats.XXXXXX)" + ROOTS="$TEST_DIR/roots" + SRC="$ROOTS/srcproj" + mkdir -p "$SRC/.ai" "$SRC/inbox" \ + "$ROOTS/alpha/.ai" "$ROOTS/alpha/inbox" \ + "$ROOTS/beta/.ai" "$ROOTS/beta/inbox" + touch "$ROOTS/alpha/todo.org" # alpha has a todo.org; beta deliberately not + + cat > "$SRC/todo.org" <<'EOF' +* Srcproj Open Work +** TODO [#B] Alpha-bound task :feature: +:PROPERTIES: +:ROUTE_CANDIDATE: alpha +:END: +Body line about the alpha work. +*** TODO Sub-task that rides along +** TODO [#C] Purely local task +Local body stays put. +** TODO [#C] Beta-bound task :quick: +:PROPERTIES: +:CREATED: [2026-07-01 Tue] +:ROUTE_CANDIDATE: beta +:END: +Beta body. +EOF + + export INBOX_SEND_ROOTS="$ROOTS" + cd "$SRC" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# ---- --list ------------------------------------------------------------ + +@test "route-batch --list: one destination+heading line per candidate, backlog excluded" { + run "$SCRIPT" --list + [ "$status" -eq 0 ] + [[ "$output" == *"alpha"*"Alpha-bound task"* ]] + [[ "$output" == *"beta"*"Beta-bound task"* ]] + [[ "$output" != *"Purely local task"* ]] +} + +@test "route-batch --list: empty candidate set is silent (exit 0)" { + sed -i '/:ROUTE_CANDIDATE:/d' todo.org + run "$SCRIPT" --list + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "route-batch --list: modifies nothing (skip leaves all in place)" { + before="$(cat todo.org)" + run "$SCRIPT" --list + [ "$status" -eq 0 ] + [ "$(cat todo.org)" = "$before" ] + [ -z "$(ls "$ROOTS/alpha/inbox" "$ROOTS/beta/inbox" 2>/dev/null | grep -v ':')" ] +} + +# ---- --go -------------------------------------------------------------- + +@test "route-batch --go: delivers each candidate to its destination inbox with provenance" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f) + beta_file=$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f) + [ -n "$alpha_file" ] + [ -n "$beta_file" ] + grep -q 'Alpha-bound task' "$alpha_file" + grep -q 'Sub-task that rides along' "$alpha_file" # children ride along + grep -q 'Beta-bound task' "$beta_file" + ! grep -q ':ROUTE_CANDIDATE:' "$alpha_file" + ! grep -q ':ROUTE_CANDIDATE:' "$beta_file" +} + +@test "route-batch --go: removes routed subtrees from todo.org, leaves local tasks" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + ! grep -q 'Alpha-bound task' todo.org + ! grep -q 'Sub-task that rides along' todo.org + ! grep -q 'Beta-bound task' todo.org + grep -q 'Purely local task' todo.org + grep -q 'Local body stays put' todo.org +} + +@test "route-batch --go: a kept property drawer survives minus the marker" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + beta_file=$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f) + grep -q ':CREATED: \[2026-07-01 Tue\]' "$beta_file" +} + +@test "route-batch --go: destination with inbox/ but no todo.org still delivers" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + [ ! -f "$ROOTS/beta/todo.org" ] + [ -n "$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)" ] +} + +@test "route-batch --go: empty candidate set is a silent no-op (exit 0)" { + sed -i '/:ROUTE_CANDIDATE:/d' todo.org + before="$(cat todo.org)" + run "$SCRIPT" --go + [ "$status" -eq 0 ] + [ -z "$output" ] + [ "$(cat todo.org)" = "$before" ] +} + +@test "route-batch --go: a failed send leaves that task in place, marker intact, and exits non-zero" { + sed -i 's/:ROUTE_CANDIDATE: beta/:ROUTE_CANDIDATE: ghost/' todo.org + run "$SCRIPT" --go + [ "$status" -ne 0 ] + grep -q 'Beta-bound task' todo.org # failed route stays local + grep -q ':ROUTE_CANDIDATE: ghost' todo.org # marker survives so it resurfaces next wrap + ! grep -q 'Alpha-bound task' todo.org # the good route still landed + [ -n "$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)" ] +} + +@test "route-batch --go: handoff headings are promoted to top level" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f) + grep -q '^\* TODO \[#B\] Alpha-bound task' "$alpha_file" + grep -q '^\*\* TODO Sub-task that rides along' "$alpha_file" +} + +@test "route-batch --go: a drawer emptied by the marker strip is pruned from the handoff" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f) + ! grep -q ':PROPERTIES:' "$alpha_file" +} + +# ---- Overlapping candidates (nested marker data-loss regression) -------- + +@test "route-batch --go: nested candidates conflict — both stay, bystander survives, exit non-zero" { + cat > todo.org <<'EOF' +* Srcproj Open Work +** TODO [#B] Parent bound for alpha +:PROPERTIES: +:ROUTE_CANDIDATE: alpha +:END: +Parent body. +*** TODO Child bound for beta +:PROPERTIES: +:ROUTE_CANDIDATE: beta +:END: +Child body. +** TODO [#C] Innocent bystander task +Bystander body. +EOF + run "$SCRIPT" --go + [ "$status" -ne 0 ] + [[ "$output" == *"CONFLICT"* ]] + grep -q 'Parent bound for alpha' todo.org + grep -q 'Child bound for beta' todo.org + grep -q 'Innocent bystander task' todo.org + grep -q 'Bystander body' todo.org + [ -z "$(find "$ROOTS/alpha/inbox" "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)" ] +} + +@test "route-batch: duplicate identical markers in one drawer dedupe to a single route" { + cat > todo.org <<'EOF' +* Srcproj Open Work +** TODO [#B] Double-tagged for alpha +:PROPERTIES: +:ROUTE_CANDIDATE: alpha +:ROUTE_CANDIDATE: alpha +:END: +Body. +EOF + run "$SCRIPT" --list + [ "$status" -eq 0 ] + [ "$(echo "$output" | grep -c 'Double-tagged')" -eq 1 ] + [[ "$output" != *"CONFLICT"* ]] + run "$SCRIPT" --go + [ "$status" -eq 0 ] + [ "$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f | wc -l)" -eq 1 ] +} diff --git a/.ai/scripts/tests/self-inject.bats b/.ai/scripts/tests/self-inject.bats new file mode 100644 index 0000000..482f61d --- /dev/null +++ b/.ai/scripts/tests/self-inject.bats @@ -0,0 +1,78 @@ +#!/usr/bin/env bats +# Tests for self-inject.sh — tmux is the external boundary, stubbed with a +# recording fake so no real server is needed. + +setup() { + SCRIPT="$BATS_TEST_DIRNAME/../self-inject.sh" + STUB_DIR="$BATS_TEST_TMPDIR/bin" + LOG="$BATS_TEST_TMPDIR/tmux.log" + mkdir -p "$STUB_DIR" +} + +# A tmux stub that records every invocation and answers list-panes from +# $STUB_PANES (empty by default, so pane derivation fails unless a test +# provides ancestry-matching output). +make_stub() { + cat > "$STUB_DIR/tmux" <<'EOF' +#!/bin/sh +echo "$@" >> "$LOG" +case "$1" in + list-panes) printf '%s\n' "$STUB_PANES" ;; +esac +EOF + chmod +x "$STUB_DIR/tmux" +} + +@test "self-inject: -t pane with no pairs echoes the pane and exits 0" { + make_stub + run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" -t %42 + [ "$status" -eq 0 ] + [ "$output" = "%42" ] + # Pane was supplied, nothing sent: tmux must not have been called. + [ ! -e "$LOG" ] +} + +@test "self-inject: no pane derivable and no -t exits 1 with an error" { + make_stub + run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" 0 "hello" + [ "$status" -eq 1 ] + case "$output" in *"no owning pane"*) : ;; *) false ;; esac +} + +@test "self-inject: derives the pane from process ancestry via list-panes" { + make_stub + # The stub reports the bats test process itself as a pane's pane_pid; + # the script runs as our child, so that pid is in its ancestry. + run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="$$ %7" sh "$SCRIPT" + [ "$status" -eq 0 ] + [ "$output" = "%7" ] +} + +@test "self-inject: one delay/text pair sends literal text then Enter" { + make_stub + run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" -t %3 0 "/clear" + [ "$status" -eq 0 ] + run cat "$LOG" + [ "${lines[0]}" = "send-keys -t %3 -l /clear" ] + [ "${lines[1]}" = "send-keys -t %3 Enter" ] +} + +@test "self-inject: multiple pairs send in order" { + make_stub + run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" \ + sh "$SCRIPT" -t %3 0 "/clear" 0 "go — resume" + [ "$status" -eq 0 ] + run cat "$LOG" + [ "${lines[0]}" = "send-keys -t %3 -l /clear" ] + [ "${lines[1]}" = "send-keys -t %3 Enter" ] + [ "${lines[2]}" = "send-keys -t %3 -l go — resume" ] + [ "${lines[3]}" = "send-keys -t %3 Enter" ] +} + +@test "self-inject: dangling odd argument after pairs is ignored" { + make_stub + run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" -t %3 0 "one" 99 + [ "$status" -eq 0 ] + run cat "$LOG" + [ "${#lines[@]}" -eq 2 ] +} diff --git a/.ai/scripts/tests/spec-sort.bats b/.ai/scripts/tests/spec-sort.bats new file mode 100644 index 0000000..583e458 --- /dev/null +++ b/.ai/scripts/tests/spec-sort.bats @@ -0,0 +1,453 @@ +#!/usr/bin/env bats +# +# Tests for claude-templates/.ai/scripts/spec-sort — the one-time docs-pile +# retrofit from the docs-lifecycle spec: classify docs/**/*.org outside +# docs/specs/ (spec candidate iff it carries BOTH a Decisions heading AND an +# Implementation phases heading), show an evidence panel, and on --apply +# move + rename confirmed candidates to docs/specs/*-spec.org, prepend the +# status heading (:ID:, dated history line), rewrite the keyword header to +# the two-sequence form, relink file: links across the rewritten roots, +# stamp :LAST_SPEC_SORT: in .ai/notes.org. +# +# Contract under test (docs/specs/2026-07-01-docs-lifecycle-spec.org, +# "The retrofit"): +# - dry-run report is the default; --apply writes +# - --apply refuses on a dirty worktree (exit 2) unless --allow-dirty +# - every candidate needs --confirm REL=KEYWORD or --skip REL (exit 1 +# otherwise); terminal keywords need --reason REL=TEXT +# - plan validated before the first write; destination collisions block +# - bare-path mentions in rewritten roots block --apply until +# --acknowledge-bare waives them (reported, never rewritten) +# - mid-apply failure names applied/not-applied + git restore recovery +# - idempotent: a sorted project yields no candidates, no changes +# +# Strategy: each test builds a throwaway git project fixture and runs the +# real script against it. Mid-apply failure is forced via the test-only +# SPEC_SORT_INJECT_FAIL_AFTER env hook. + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/spec-sort" + +setup() { + TEST_DIR="$(mktemp -d -t spec-sort-bats.XXXXXX)" + PROJ="$TEST_DIR/proj" + mkdir -p "$PROJ" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# Standard fixture: one spec candidate, one note, a stray root spec with a +# spine, an anomaly (-spec.org name, no spine), inbound links from todo.org, +# a sibling note, a session archive (report-only surface), and .ai/notes.org +# with a Workflow State section. +make_project() { + cd "$PROJ" + git init -q + git config user.email test@test + git config user.name test + mkdir -p docs/design .ai/sessions + + cat > docs/design/widget.org <<'EOF' +#+TITLE: Widget Feature +#+DATE: 2026-05-01 +#+TODO: DRAFT REVIEW | SHIPPED + +* Metadata +| Status | draft | +| Owner | Craig | + +* Summary +The widget feature. See [[file:scratch-note.org][the note]]. + +* Decisions [1/2] +** DONE Pick the widget shape +** TODO Pick the color + +* Implementation phases +** Phase 1 — build =src/widget.py= +EOF + + cat > docs/design/scratch-note.org <<'EOF' +#+TITLE: Scratch Note + +* Metadata +| Status | n/a | + +* Thoughts +See [[file:widget.org][the widget spec]]. +EOF + + cat > docs/rooty-spec.org <<'EOF' +#+TITLE: Rooty + +* Decisions +** DONE Only decision + +* Implementation phases +** Phase 1 — nothing +EOF + + cat > docs/lonely-spec.org <<'EOF' +#+TITLE: Lonely +Just prose, no spine. +EOF + + cat > todo.org <<'EOF' +* Open Work +** DOING [#B] Widget feature +Spec: [[file:docs/design/widget.org][widget spec]]. +Summary anchor: [[file:docs/design/widget.org::*Summary][the summary]]. +EOF + + cat > .ai/notes.org <<'EOF' +* Active Reminders + +* Workflow State +:LAST_AUDIT: 2026-06-28 +EOF + + cat > .ai/sessions/2026-06-01-old.org <<'EOF' +Old log: [[file:../../docs/design/widget.org][widget]] +EOF + + git add -A + git commit -qm init +} + +# Confirm flags that satisfy the gate for the standard fixture's candidates. +CONFIRM_ALL=(--confirm docs/design/widget.org=DRAFT --confirm docs/rooty-spec.org=DRAFT) + +# ---- Classification (dry-run) ---------------------------------------- + +@test "spec-sort: dry-run classifies the spine-carrying doc as a candidate" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"CANDIDATE docs/design/widget.org -> docs/specs/widget-spec.org"* ]] +} + +@test "spec-sort: a Metadata table alone does not qualify — note stays a note" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"NOTE docs/design/scratch-note.org"* ]] + [[ "$output" != *"CANDIDATE docs/design/scratch-note.org"* ]] +} + +@test "spec-sort: stray root spec with a spine is a candidate, suffix not doubled" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"CANDIDATE docs/rooty-spec.org -> docs/specs/rooty-spec.org"* ]] + [[ "$output" != *"rooty-spec-spec.org"* ]] +} + +@test "spec-sort: -spec.org name without a spine is an anomaly, never auto-moved" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"ANOMALY docs/lonely-spec.org"* ]] + [[ "$output" != *"CANDIDATE docs/lonely-spec.org"* ]] +} + +@test "spec-sort: docs/specs/ contents are excluded from classification" { + make_project + mkdir -p docs/specs + cp docs/design/widget.org docs/specs/sorted-spec.org + git add -A && git commit -qm more + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" != *"CANDIDATE docs/specs/sorted-spec.org"* ]] +} + +@test "spec-sort: no docs/ directory is a silent no-op" { + cd "$PROJ" + git init -q + git config user.email test@test + git config user.name test + echo x > README.md + git add -A && git commit -qm init + run "$SCRIPT" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ---- Evidence panel --------------------------------------------------- + +@test "spec-sort: evidence panel shows status field, cookies, and todo.org task" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"status field: draft"* ]] + [[ "$output" == *"Decisions [1/2]"* ]] + [[ "$output" == *"todo.org:"*"DOING"*"Widget feature"* ]] +} + +@test "spec-sort: keyword proposal follows the evidence — DOING from the linked DOING task" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + # status field says draft, but the linking todo.org task is DOING — the + # panel proposes the state the strongest evidence supports + [[ "$output" == *"proposed keyword: DOING"* ]] +} + +@test "spec-sort: an 'incomplete' status field never proposes the terminal IMPLEMENTED" { + make_project + sed -i 's/| Status | draft |/| Status | incomplete |/' docs/design/widget.org + git add -A && git commit -qm status + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" != *"proposed keyword: IMPLEMENTED"* ]] +} + +# ---- Confirm gate ----------------------------------------------------- + +@test "spec-sort --apply: refuses when a candidate is neither confirmed nor skipped" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=DRAFT + [ "$status" -eq 1 ] + [[ "$output" == *"unconfirmed"* ]] + [[ "$output" == *"docs/rooty-spec.org"* ]] + [ -f docs/design/widget.org ] # nothing moved +} + +@test "spec-sort --apply: a terminal keyword without --reason refuses" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED --skip docs/rooty-spec.org + [ "$status" -eq 1 ] + [[ "$output" == *"--reason"* ]] + [ -f docs/design/widget.org ] +} + +@test "spec-sort --apply: a terminal keyword with --reason records it in the history line" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED \ + --reason "docs/design/widget.org=shipped in v2, confirmed against src" \ + --skip docs/rooty-spec.org + [ "$status" -eq 0 ] + grep -q '^\* IMPLEMENTED Widget Feature' docs/specs/widget-spec.org + grep -q 'shipped in v2, confirmed against src' docs/specs/widget-spec.org +} + +@test "spec-sort --apply: --skip leaves the candidate in place and still stamps the marker" { + make_project + run "$SCRIPT" --apply --skip docs/design/widget.org --skip docs/rooty-spec.org + [ "$status" -eq 0 ] + [ -f docs/design/widget.org ] + grep -q ':LAST_SPEC_SORT:' .ai/notes.org +} + +# ---- Preflight -------------------------------------------------------- + +@test "spec-sort --apply: refuses on a dirty worktree (exit 2)" { + make_project + echo "drift" >> todo.org + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 2 ] + [[ "$output" == *"dirty"* ]] + [ -f docs/design/widget.org ] +} + +@test "spec-sort --apply --allow-dirty: proceeds and names what recovery loses" { + make_project + echo "drift" >> todo.org + git add todo.org && git commit -qm drift # keep the link intact; dirty a different file + echo "scratch" > untracked-note.txt + echo "local edit" >> .ai/notes.org + run "$SCRIPT" --apply --allow-dirty "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + [[ "$output" == *"pre-existing"* ]] + [[ "$output" == *".ai/notes.org"* ]] + [ -f docs/specs/widget-spec.org ] +} + +# ---- Move + rename + rewrite ------------------------------------------ + +@test "spec-sort --apply: moves, renames to -spec.org, prepends status heading with :ID: and history" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + [ -f docs/specs/widget-spec.org ] + [ ! -f docs/design/widget.org ] + grep -q '^\* DRAFT Widget Feature' docs/specs/widget-spec.org + grep -q ':ID:' docs/specs/widget-spec.org + grep -q 'retrofitted by spec-sort' docs/specs/widget-spec.org +} + +@test "spec-sort --apply: keyword header rewritten to the two-sequence form" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '^#+TODO: TODO | DONE$' docs/specs/widget-spec.org + grep -q '^#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED$' docs/specs/widget-spec.org + ! grep -q 'DRAFT REVIEW | SHIPPED' docs/specs/widget-spec.org +} + +@test "spec-sort --apply: Metadata Status field mirrors the confirmed keyword in lowercase" { + make_project + run "$SCRIPT" --apply --confirm docs/design/widget.org=READY --skip docs/rooty-spec.org + [ "$status" -eq 0 ] + grep -q '^\* READY Widget Feature' docs/specs/widget-spec.org + grep -Eq '^\| Status[[:space:]]*\|[[:space:]]*ready' docs/specs/widget-spec.org +} + +# ---- Relink ----------------------------------------------------------- + +@test "spec-sort --apply: rewrites the todo.org link, preserving the description" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:docs/specs/widget-spec.org\]\[widget spec\]\]' todo.org + ! grep -q 'docs/design/widget.org' todo.org +} + +@test "spec-sort --apply: preserves a ::anchor suffix through the rewrite" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:docs/specs/widget-spec.org::\*Summary\]\[the summary\]\]' todo.org +} + +@test "spec-sort --apply: recomputes a sibling note's relative link to the moved spec" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:../specs/widget-spec.org\]\[the widget spec\]\]' docs/design/scratch-note.org +} + +@test "spec-sort --apply: recomputes the moved spec's own outbound link to an unmoved note" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '\[\[file:../design/scratch-note.org\]\[the note\]\]' docs/specs/widget-spec.org +} + +@test "spec-sort: session archives are reported, never rewritten" { + make_project + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"REPORT .ai/sessions/2026-06-01-old.org"* ]] + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q 'docs/design/widget.org' .ai/sessions/2026-06-01-old.org +} + +@test "spec-sort: a synced template path report names the canonical rulesets file" { + make_project + mkdir -p .ai/workflows + echo 'See [[file:../../docs/design/widget.org][widget]]' > .ai/workflows/startup.org + git add -A && git commit -qm wf + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"REPORT .ai/workflows/startup.org"* ]] + [[ "$output" == *"claude-templates/.ai/workflows/startup.org"* ]] +} + +# ---- Bare-path mentions ----------------------------------------------- + +@test "spec-sort --apply: a bare-path mention in a rewritten root blocks until acknowledged" { + make_project + echo "raw mention: docs/design/widget.org needs review" >> todo.org + git add -A && git commit -qm bare + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"BARE"* ]] + [ -f docs/design/widget.org ] # nothing moved + run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q 'raw mention: docs/design/widget.org' todo.org # reported, never rewritten +} + +@test "spec-sort --apply: a moving doc's bare mention of its own old path is acknowledgeable, not post-apply residue" { + make_project + echo "History: docs/design/widget.org was drafted in May." >> docs/design/widget.org + git add -A && git commit -qm selfmention + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"BARE"* ]] + run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] # the acknowledged mention rides along to docs/specs/; not residue + grep -q ':LAST_SPEC_SORT:' .ai/notes.org +} + +# ---- Plan validation --------------------------------------------------- + +@test "spec-sort --apply: a destination collision blocks validation, nothing moved" { + make_project + mkdir -p docs/specs + echo "occupied" > docs/specs/widget-spec.org + git add -A && git commit -qm occupy + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"destination exists"* ]] + [ -f docs/design/widget.org ] + [ "$(cat docs/specs/widget-spec.org)" = "occupied" ] +} + +@test "spec-sort --apply: writes the plan file before executing" { + make_project + run "$SCRIPT" --apply --plan-file "$TEST_DIR/plan.json" "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + [ -f "$TEST_DIR/plan.json" ] + grep -q 'widget-spec.org' "$TEST_DIR/plan.json" +} + +# ---- Mid-apply failure recovery ---------------------------------------- + +@test "spec-sort --apply: forced mid-apply failure yields named recovery, not a half-migrated shrug" { + make_project + run env SPEC_SORT_INJECT_FAIL_AFTER=1 "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 1 ] + [[ "$output" == *"RECOVERY"* ]] + [[ "$output" == *"git restore"* ]] + [[ "$output" == *"applied"* ]] + [[ "$output" == *"not applied"* ]] + ! grep -q ':LAST_SPEC_SORT:' .ai/notes.org # no stamp on a failed apply +} + +# ---- Idempotence + marker ---------------------------------------------- + +@test "spec-sort --apply: stamps :LAST_SPEC_SORT: in the Workflow State section" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q ':LAST_SPEC_SORT: ' .ai/notes.org + # lands inside the Workflow State section, alongside the existing marker + awk '/^\* Workflow State/{ws=1} ws && /:LAST_SPEC_SORT:/{found=1} END{exit !found}' .ai/notes.org +} + +@test "spec-sort --apply: creates the Workflow State section when notes.org lacks it" { + make_project + printf '* Active Reminders\n' > .ai/notes.org + git add -A && git commit -qm notes + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + grep -q '^\* Workflow State' .ai/notes.org + grep -q ':LAST_SPEC_SORT: ' .ai/notes.org +} + +@test "spec-sort --apply: zero candidates still stamps the marker (clears the nudge)" { + make_project + rm docs/design/widget.org docs/rooty-spec.org docs/lonely-spec.org + git add -A && git commit -qm notes-only + run "$SCRIPT" --apply + [ "$status" -eq 0 ] + grep -q ':LAST_SPEC_SORT:' .ai/notes.org +} + +@test "spec-sort: a second run after a successful apply finds nothing to do" { + make_project + run "$SCRIPT" --apply "${CONFIRM_ALL[@]}" + [ "$status" -eq 0 ] + git add -A && git commit -qm sorted + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" != *"CANDIDATE"* ]] + run "$SCRIPT" --apply + [ "$status" -eq 0 ] + run git status --porcelain + # only the re-stamped marker (same date) may differ — tree stays clean + [ -z "$(git status --porcelain -- docs todo.org)" ] +} diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el index 242c35c..d14879f 100644 --- a/.ai/scripts/tests/test-lint-org.el +++ b/.ai/scripts/tests/test-lint-org.el @@ -685,5 +685,112 @@ 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) + +(defun lo-test--checker-lines (issues checker) + "Lines of judgment ISSUES whose :checker is CHECKER, document order." + (mapcar (lambda (i) (plist-get i :line)) + (cl-remove-if-not + (lambda (i) (and (eq (plist-get i :kind) 'judgment) + (eq (plist-get i :checker) checker))) + (reverse issues)))) + +(ert-deftest lo-indented-heading-flags-leading-whitespace () + "Error: a heading indented off column 0 is flagged (org demotes it to body)." + (let* ((out (lo-test--run "* Open\n ** TODO indented and lost\n** TODO fine\n")) + (j (lo-test--judgments (plist-get out :issues)))) + (should (member 'indented-heading (lo-test--checkers j))) + (should (= 1 (length (lo-test--checker-lines (plist-get out :issues) + 'indented-heading)))))) + +(ert-deftest lo-indented-heading-skips-stars-inside-blocks () + "Boundary: indented stars inside a #+begin_/#+end_ block are legitimate content." + (let* ((out (lo-test--run "* Open\n#+begin_example\n ** not a heading\n#+end_example\n")) + (j (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'indented-heading (lo-test--checkers j))))) + +(ert-deftest lo-indented-heading-skips-single-star-list-bullets () + "Normal: an indented single `*' is a valid plain-list bullet, not a demoted +heading, so it is not flagged — only two-or-more indented stars are." + (let* ((out (lo-test--run "* Open\nintro line\n * first bullet\n * second bullet\n * nested bullet\n")) + (j (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'indented-heading (lo-test--checkers j))))) + +(ert-deftest lo-empty-heading-flags-bare-stars () + "Error: a line of bare stars with no title is flagged." + (let* ((out (lo-test--run "* Open\n** \n** TODO real\n")) + (j (lo-test--judgments (plist-get out :issues)))) + (should (member 'empty-heading (lo-test--checkers j))))) + +(ert-deftest lo-malformed-priority-flags-lowercase-and-skips-valid () + "Error + Normal: a lowercase/oversized cookie flags; a valid [#B] stays silent." + (let* ((bad (lo-test--run "* Open\n** TODO [#a] lowercase cookie\n** TODO [#BB] oversized\n")) + (ok (lo-test--run "* Open\n** TODO [#B] valid cookie\n")) + (jo (lo-test--judgments (plist-get ok :issues)))) + (should (= 2 (length (lo-test--checker-lines (plist-get bad :issues) + 'malformed-priority-cookie)))) + (should-not (member 'malformed-priority-cookie (lo-test--checkers jo))))) + +(ert-deftest lo-malformed-priority-skips-verbatim-cookie-in-title () + "Boundary: a dated-log title quoting =[#D]= verbatim is not a real cookie." + (let* ((out (lo-test--run "* Open\n** TODO [#B] parent\n*** 2026-05-14 reprioritized =[#D]= -> =[#B]=\n")) + (j (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'malformed-priority-cookie (lo-test--checkers j))))) + +(ert-deftest lo-done-without-closed-flags-undated-level2 () + "Error: a level-2 DONE with no CLOSED line is flagged; a dated one is not." + (let* ((bad (lo-test--run "* Resolved\n** DONE undated finished\nbody\n")) + (jb (lo-test--judgments (plist-get bad :issues))) + (ok (lo-test--run "* Resolved\n** DONE dated\nCLOSED: [2026-06-29 Mon]\n")) + (jo (lo-test--judgments (plist-get ok :issues)))) + (should (member 'level2-done-without-closed (lo-test--checkers jb))) + (should-not (member 'level2-done-without-closed (lo-test--checkers jo))))) + +(ert-deftest lo-done-without-closed-ignores-deeper-levels () + "Boundary: a level-3 DONE (a dated-log sub-entry) need not carry CLOSED." + (let* ((out (lo-test--run "* Resolved\n** DONE parent\nCLOSED: [2026-06-29 Mon]\n*** DONE nested no-closed\n")) + (j (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'level2-done-without-closed (lo-test--checkers j))))) + +(ert-deftest lo-structural-checks-silent-on-clean-file () + "Normal: a well-formed file trips none of the four structural checkers." + (let* ((out (lo-test--run "* Open Work\n** TODO [#A] a task :tag:\n** DOING [#B] another\n* Resolved\n** DONE [#C] done\nCLOSED: [2026-06-29 Mon]\n")) + (checkers (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (dolist (c '(indented-heading empty-heading malformed-priority-cookie + level2-done-without-closed)) + (should-not (member c checkers))))) + (provide 'test-lint-org) ;;; test-lint-org.el ends here diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el index ad9260b..ffbf2fb 100644 --- a/.ai/scripts/tests/test-todo-cleanup.el +++ b/.ai/scripts/tests/test-todo-cleanup.el @@ -30,16 +30,20 @@ ;;; Harness (defun tc-test--reset (&optional check) - (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil tc-check-only (and check t) tc-archive-done t tc-sync-child-priority nil - tc-current-file nil)) + tc-current-file nil + ;; Aging step OFF by default so the in-file-move tests are unaffected by + ;; the wall clock; the aging harness re-enables it with fixed params. + tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil)) (defun tc-test--reset-sync (&optional check) - (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil + (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil tc-check-only (and check t) tc-archive-done nil tc-sync-child-priority t - tc-current-file nil)) + tc-current-file nil + tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil)) (defun tc-test--drop-buffer (file) (let ((buf (find-buffer-visiting file))) @@ -355,6 +359,200 @@ from the heading line through (not including) the next level-1 heading or EOF." (should (tc-test--has (plist-get out :report) "skipped")))) ;;; --------------------------------------------------------------------------- +;;; --archive-done file-aging: keep last week in-file, move older to task-archive + +(defun tc-test--age (content &optional opts) + "Run `--archive-done' with the file-aging step enabled. +OPTS is a plist: :retain (days; default 7, may be nil to disable), :ref +\(YEAR MONTH DAY reference date), :runs (default 1), :check. Writes CONTENT to a +temp todo file and points `tc-archive-file' at a not-yet-existing temp archive. +Returns a plist: :result (todo contents), :archive (archive-file contents or +nil), :archived (in-file move count), :to-file (aged count), :issues — all from +the last run." + (let* ((retain (if (plist-member opts :retain) (plist-get opts :retain) 7)) + (ref (plist-get opts :ref)) + (runs (or (plist-get opts :runs) 1)) + (check (plist-get opts :check)) + (todo (make-temp-file "tc-age-todo-" nil ".org")) + (adir (make-temp-file "tc-age-arch-" t)) + (afile (expand-file-name "task-archive.org" adir)) + last) + (unwind-protect + (progn + (with-temp-file todo (insert content)) + (dotimes (_ runs) + (tc-test--reset check) + (setq tc-archive-retain-days retain + tc-archive-reference-date ref + tc-archive-file afile) + (tc-process-file todo) + (setq last (list :archived tc-archived :to-file tc-archived-to-file + :issues tc-issues)) + (tc-test--drop-buffer todo)) + (append + last + (list :result (with-temp-buffer (insert-file-contents todo) (buffer-string)) + :archive (and (file-readable-p afile) + (with-temp-buffer (insert-file-contents afile) + (buffer-string)))))) + (tc-test--drop-buffer todo) + (delete-file todo) + (delete-directory adir t)))) + +;; Reference "today" for these fixtures is 2026-06-29; with retain 7 the cutoff +;; is 2026-06-22, so a task closed on or after 2026-06-22 stays in-file. +(defconst tc-test--age-resolved "\ +* Age Open Work +** TODO [#A] still open +* Age Resolved +** DONE [#B] recent within window +CLOSED: [2026-06-25 Thu] +recent body +** DONE [#C] old beyond window +CLOSED: [2026-05-01 Fri] +old body line +** CANCELLED [#C] old cancelled too +CLOSED: [2026-04-15 Wed] +** DONE [#B] exactly at cutoff stays +CLOSED: [2026-06-22 Sun] +** DONE [#C] undated no-date archived +no closed date in this body +") + +(defconst tc-test--age-straggler "\ +* Age Open Work +** TODO [#A] still open +** DONE [#C] old straggler +CLOSED: [2026-03-01 Sun] +straggler body +* Age Resolved +** DONE [#B] recent stays +CLOSED: [2026-06-26 Fri] +") + +(ert-deftest tc-age-moves-old-and-undated-resolved () + "Normal: closed-beyond-window AND undated subtrees leave the file; only those +closed within the window (cutoff inclusive) stay." + (let* ((out (tc-test--age tc-test--age-resolved '(:ref (2026 6 29)))) + (resolved (tc-test--section (plist-get out :result) "Age Resolved")) + (arch (plist-get out :archive))) + (should (= 3 (plist-get out :to-file))) + (should-not (tc-test--has resolved "old beyond window")) + (should-not (tc-test--has resolved "old cancelled too")) + (should-not (tc-test--has resolved "undated no-date archived")) + (should (tc-test--has resolved "recent within window")) + (should (tc-test--has resolved "exactly at cutoff stays")) + (should arch) + (should (tc-test--has arch "Resolved (archived)")) + (should (tc-test--has arch "old beyond window")) + (should (tc-test--has arch "old body line")) + (should (tc-test--has arch "old cancelled too")) + (should (tc-test--has arch "undated no-date archived")) + (should-not (tc-test--has arch "recent within window")))) + +(ert-deftest tc-age-disabled-when-retain-nil () + "Boundary: nil retain disables the aging step entirely (legacy behavior)." + (let ((out (tc-test--age tc-test--age-resolved '(:retain nil :ref (2026 6 29))))) + (should (= 0 (plist-get out :to-file))) + (should (equal tc-test--age-resolved (plist-get out :result))) + (should-not (plist-get out :archive)))) + +(ert-deftest tc-age-is-idempotent () + "Boundary: a second run finds nothing new to age; the todo file is stable." + (let ((once (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :runs 1))) + (twice (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :runs 2)))) + (should (equal (plist-get once :result) (plist-get twice :result))) + (should (= 0 (plist-get twice :to-file))))) + +(ert-deftest tc-age-check-mode-previews-without-writing () + "Boundary: --check reports the aged count but writes neither file." + (let ((out (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :check t)))) + (should (= 3 (plist-get out :to-file))) + (should (equal tc-test--age-resolved (plist-get out :result))) + (should-not (plist-get out :archive)))) + +(ert-deftest tc-age-straggler-moves-through-to-archive () + "Normal: an old-dated DONE in Open Work moves to Resolved then ages out in one run." + (let* ((out (tc-test--age tc-test--age-straggler '(:ref (2026 6 29)))) + (open (tc-test--section (plist-get out :result) "Age Open Work")) + (resolved (tc-test--section (plist-get out :result) "Age Resolved")) + (arch (plist-get out :archive))) + (should-not (tc-test--has open "old straggler")) + (should-not (tc-test--has resolved "old straggler")) + (should (tc-test--has arch "old straggler")) + (should (tc-test--has arch "straggler body")) + (should (tc-test--has resolved "recent stays")) + (should (= 1 (plist-get out :archived))) + (should (= 1 (plist-get out :to-file))))) + +(ert-deftest tc-age-append-preserves-existing-archive () + "Error/edge: appending to a populated archive keeps prior entries and one scaffold." + (let* ((adir (make-temp-file "tc-arch-" t)) + (afile (expand-file-name "task-archive.org" adir))) + (unwind-protect + (progn + (tc--append-subtrees-to-archive-file afile (list "** DONE one\n")) + (tc--append-subtrees-to-archive-file afile (list "** DONE two\n")) + (let ((content (with-temp-buffer (insert-file-contents afile) + (buffer-string))) + (n 0) (start 0)) + (should (tc-test--has content "** DONE one")) + (should (tc-test--has content "** DONE two")) + (should (tc-test--before-p content "** DONE one" "** DONE two")) + (while (string-match "\\* Resolved (archived)" content start) + (setq n (1+ n) start (match-end 0))) + (should (= 1 n)))) + (delete-directory adir t)))) + +;;; --------------------------------------------------------------------------- +;;; --archive-done aging: the archive follows the todo file's gitignore status + +(defun tc-test--age-in-git-repo (gitignore-todo) + "Init a temp git repo, write todo.org with an old Resolved entry, optionally +gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive path +(archive/task-archive.org beside the todo file). Return a plist: :gitignore (final +.gitignore contents or nil), :archive-ignored (whether git ignores the archive), +:archive-exists." + (let* ((root (make-temp-file "tc-git-" t)) + (todo (expand-file-name "todo.org" root)) + (archive (expand-file-name "archive/task-archive.org" root)) + (gi (expand-file-name ".gitignore" root))) + (unwind-protect + (let ((default-directory root)) + (call-process "git" nil nil nil "init" "-q") + (with-temp-file todo (insert tc-test--age-resolved)) + (when gitignore-todo (with-temp-file gi (insert "/todo.org\n"))) + (tc-test--reset nil) + (setq tc-archive-retain-days 7 + tc-archive-reference-date '(2026 6 29) + tc-archive-file nil) ; default path, beside the todo file + (tc-process-file todo) + (tc-test--drop-buffer todo) + (list :gitignore (and (file-readable-p gi) + (with-temp-buffer (insert-file-contents gi) + (buffer-string))) + :archive-ignored + (eq 0 (call-process "git" nil nil nil "check-ignore" "-q" archive)) + :archive-exists (file-readable-p archive))) + (delete-directory root t)))) + +(ert-deftest tc-age-self-protect-gitignores-archive-when-todo-ignored () + "When the todo file is gitignored, the aged-out archive is added to .gitignore +so it inherits the same privacy." + (let ((out (tc-test--age-in-git-repo t))) + (should (plist-get out :archive-exists)) + (should (string-match-p "task-archive" (or (plist-get out :gitignore) ""))) + (should (plist-get out :archive-ignored)))) + +(ert-deftest tc-age-self-protect-leaves-tracked-todo-archive-tracked () + "When the todo file is tracked, the archive is not gitignored — no .gitignore +entry is added for it." + (let ((out (tc-test--age-in-git-repo nil))) + (should (plist-get out :archive-exists)) + (should-not (plist-get out :archive-ignored)) + (should-not (string-match-p "task-archive" (or (plist-get out :gitignore) ""))))) + +;;; --------------------------------------------------------------------------- ;;; Realistic synthetic sample (committed under fixtures/) (defun tc-test--sample-file () @@ -570,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/tests/test_inbox_send.py b/.ai/scripts/tests/test_inbox_send.py index cb60e63..f75d7a1 100644 --- a/.ai/scripts/tests/test_inbox_send.py +++ b/.ai/scripts/tests/test_inbox_send.py @@ -401,3 +401,78 @@ class TestInboxSendErrors: assert result.returncode != 0 files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) assert files == [] + + +# ---------------------------------------------------------------------- +# Filename collisions (two sends deriving the same name must not overwrite) +# ---------------------------------------------------------------------- + +def _load_module(): + import importlib.util + spec = importlib.util.spec_from_file_location("inbox_send", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class TestFilenameCollisions: + """Two sends in the same minute with the same leading phrase derived + identical filenames and the second silently overwrote the first + (a message was lost this way, 2026-07-02).""" + + def test_send_text_same_minute_same_phrase_keeps_both(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 2, 5, 42, 0) + prefix = "identical leading phrase long enough to fill the whole slug budget entirely" + first = mod.send_text(inbox, prefix + " tail one", "archsetup", None, now) + second = mod.send_text(inbox, prefix + " tail two", "archsetup", None, now) + assert first != second + assert first.exists() and second.exists() + assert first.name != second.name + assert "tail one" in first.read_text() + assert "tail two" in second.read_text() + + def test_send_text_collision_suffix_increments(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 2, 5, 42, 0) + paths = [mod.send_text(inbox, "same lead phrase differs later A", "src", "fixed-slug", now) + for _ in range(3)] + names = [p.name for p in paths] + assert names[0].endswith("fixed-slug.org") + assert names[1].endswith("fixed-slug-2.org") + assert names[2].endswith("fixed-slug-3.org") + + def test_send_file_collision_preserves_extension(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + src = tmp_path / "note.org" + src.write_text("body one") + now = datetime(2026, 7, 2, 5, 42, 0) + first = mod.send_file(inbox, src, "src", None, now) + src.write_text("body two") + second = mod.send_file(inbox, src, "src", None, now) + assert second.name.endswith("note-2.org") + assert first.read_text() == "body one" + assert second.read_text() == "body two" + + def test_cli_two_rapid_sends_lose_nothing(self, project_root, run_script, tmp_path): + project_root("sender") + target = project_root("receiver") + roots = [tmp_path / "projects"] + prefix = "identical leading phrase long enough to fill the whole slug budget entirely" + run_script(["receiver", "--text", prefix + " message one"], + cwd=tmp_path / "projects" / "sender", roots=roots) + run_script(["receiver", "--text", prefix + " message two"], + cwd=tmp_path / "projects" / "sender", roots=roots) + files = list((target / "inbox").iterdir()) + assert len(files) == 2 + bodies = "".join(f.read_text() for f in files) + assert "message one" in bodies and "message two" in bodies diff --git a/.ai/scripts/tests/test_route_recommend.py b/.ai/scripts/tests/test_route_recommend.py new file mode 100644 index 0000000..acc4755 --- /dev/null +++ b/.ai/scripts/tests/test_route_recommend.py @@ -0,0 +1,124 @@ +"""Tests for route_recommend.py — the wrap-up routing recommendation engine. + +The core is a pure function recommend(item, projects) -> (destination, confidence): +- strong: a project's name (or its dot-stripped form) appears literally in the item +- weak: a distinctive name token overlaps, but the full name doesn't +- none: no overlap; the item stays put (destination is None) + +A multi-way tie at the top tier downgrades to weak with a deterministic pick. +An empty project list yields none. + +The CLI wires this to inbox-send.py's discover_projects (sandboxed here via the +INBOX_SEND_ROOTS env var, the same hook inbox-send's own tests use). +""" + +import subprocess +import sys +from pathlib import Path + +SCRIPTS = Path(__file__).parent.parent +SCRIPT = SCRIPTS / "route_recommend.py" +sys.path.insert(0, str(SCRIPTS)) + +import route_recommend as rr # noqa: E402 + + +# --- pure function: the five spec'd cases ----------------------------------- + +def test_strong_match_named_literally(): + dest, conf = rr.recommend("fix the rulesets refactor command", ["rulesets", "home", "work"]) + assert (dest, conf) == ("rulesets", "strong") + + +def test_strong_match_via_dot_stripped_name(): + # ".emacs.d" addressed as "emacsd" in the item is still a literal hit. + dest, conf = rr.recommend("update the emacsd ai-term module", [".emacs.d", "rulesets"]) + assert (dest, conf) == (".emacs.d", "strong") + + +def test_strong_match_dotted_name_verbatim(): + dest, conf = rr.recommend("patch .emacs.d startup", [".emacs.d", "rulesets"]) + assert (dest, conf) == (".emacs.d", "strong") + + +def test_weak_match_topic_token_only(): + # "wttrin" is a token of "emacs-wttrin" but the full name isn't present. + dest, conf = rr.recommend("the wttrin weather bug", ["emacs-wttrin", "rulesets"]) + assert (dest, conf) == ("emacs-wttrin", "weak") + + +def test_no_match_stays_put(): + dest, conf = rr.recommend("calibrate the telescope mount", ["rulesets", "deepsat"]) + assert dest is None + assert conf == "none" + + +def test_two_project_strong_tie_downgrades_to_weak(): + # Both named literally → ambiguous → weak, deterministic tie-break (alphabetical). + dest, conf = rr.recommend("sync rulesets and home configs", ["rulesets", "home", "work"]) + assert conf == "weak" + assert dest == "home" # tie-break: most-overlap then alphabetical + + +def test_empty_project_list_is_none(): + assert rr.recommend("anything at all", []) == (None, "none") + + +# --- boundary / robustness -------------------------------------------------- + +def test_literal_name_requires_word_boundary(): + # "home" must not match inside "homeowner". + dest, conf = rr.recommend("the homeowner association meeting", ["home", "rulesets"]) + assert dest is None and conf == "none" + + +def test_path_mention_counts_as_literal(): + dest, conf = rr.recommend("edit ~/code/rulesets/Makefile", ["rulesets", "home"]) + assert (dest, conf) == ("rulesets", "strong") + + +def test_strong_beats_weak_when_both_present(): + # "rulesets" named literally (strong) outranks an emacs-wttrin token hit (weak). + dest, conf = rr.recommend("the wttrin fix belongs in rulesets", ["rulesets", "emacs-wttrin"]) + assert (dest, conf) == ("rulesets", "strong") + + +# --- CLI + discovery reuse (sandboxed roots) -------------------------------- + +def _run(args, roots, item): + import os + env = {"PATH": os.environ.get("PATH", ""), "HOME": os.environ.get("HOME", "/tmp"), + "INBOX_SEND_ROOTS": ":".join(str(r) for r in roots)} + return subprocess.run([sys.executable, str(SCRIPT), "--item", item, *args], + capture_output=True, text=True, env=env) + + +def _mk_project(tmp_path, name): + proj = tmp_path / "projects" / name + (proj / ".ai").mkdir(parents=True, exist_ok=True) + (proj / "inbox").mkdir(exist_ok=True) + return proj + + +def test_cli_discovers_and_recommends(tmp_path): + _mk_project(tmp_path, "foo") + _mk_project(tmp_path, "bar") + r = _run([], roots=[tmp_path / "projects"], item="fix the foo widget") + assert r.returncode == 0 + assert r.stdout.strip() == "foo\tstrong" + + +def test_cli_no_match_prints_none(tmp_path): + _mk_project(tmp_path, "foo") + r = _run([], roots=[tmp_path / "projects"], item="unrelated grocery list") + assert r.returncode == 0 + assert r.stdout.strip() == "none" + + +def test_cli_exclude_drops_current_project(tmp_path): + _mk_project(tmp_path, "foo") + _mk_project(tmp_path, "bar") + # Item names foo, but foo is excluded as the current project → no other match. + r = _run(["--exclude", "foo"], roots=[tmp_path / "projects"], item="fix the foo widget") + assert r.returncode == 0 + assert r.stdout.strip() == "none" diff --git a/.ai/scripts/todo-cleanup.el b/.ai/scripts/todo-cleanup.el index 6b3081a..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. @@ -25,14 +27,46 @@ ;; line isn't in canonical position. Reports these for manual fix; doesn't ;; auto-rewrite (preserving real state-log history is judgement work). ;; -;; * --archive-done (opt-in). Moves every level-2 subtree whose TODO state is -;; DONE or CANCELLED out of the "Open Work" section and into the "Resolved" -;; section of the same file, subtree intact. The sections are matched by a -;; unique level-1 heading containing "Open Work" (case-insensitive) and one -;; containing "Resolved"; if either is missing or ambiguous, the file is -;; skipped with a message. Only direct level-2 children move — a DONE entry -;; nested under an open parent stays put. Archiving is consequential, so it's -;; never run by default; it does *not* also run the hygiene passes. +;; * --archive-done (opt-in). Two steps, in order: +;; +;; 1. Moves every level-2 subtree whose TODO state is DONE or CANCELLED out of +;; the "Open Work" section and into the "Resolved" section of the same +;; file, subtree intact. The sections are matched by a unique level-1 +;; heading containing "Open Work" (case-insensitive) and one containing +;; "Resolved"; if either is missing or ambiguous, the file is skipped with +;; 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 +;; 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 +;; `tc--ensure-archive-gitignored'). +;; +;; 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 @@ -52,13 +86,19 @@ (require 'org) (require 'cl-lib) +(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.") @@ -70,11 +110,30 @@ 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) + +(defvar tc-archive-retain-days 7 + "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.") + +(defvar tc-archive-reference-date nil + "(YEAR MONTH DAY) treated as \"today\" when aging Resolved subtrees out to a +file; nil means the real current date. Set in tests for determinism.") + +(defvar tc-archive-file nil + "Destination file for aged-out Resolved subtrees; nil means +`archive/task-archive.org' beside the todo file being processed.") ;;; --------------------------------------------------------------------------- ;;; Hygiene mode @@ -224,7 +283,8 @@ are reported but not performed." :line (line-number-at-pos) :heading (org-get-heading t t t t)) tc-issues) - (cl-incf tc-archived)))) + (cl-incf tc-archived))) + (tc-archive-old-resolved-to-file)) (t (catch 'done (while t @@ -252,7 +312,171 @@ are reported but not performed." (cl-incf tc-archived) (push (list :kind 'archive-moved :file tc-current-file :line line :heading heading) - tc-issues))))))))) + tc-issues))))) + (tc-archive-old-resolved-to-file))))) + +;;; --------------------------------------------------------------------------- +;;; --archive-done: age old Resolved subtrees out to a file + +(defconst tc-archive-file-scaffold + "#+TITLE: Task Archive\n#+FILETAGS: :archive:\n\n* Resolved (archived)\n" + "Initial content written to a fresh `tc-archive-file'. Aged subtrees are +appended as level-2 children under the level-1 heading.") + +(defun tc--reference-absolute () + "Absolute (Gregorian serial) day number of the aging reference date — +`tc-archive-reference-date' when set, otherwise the real current date." + (if tc-archive-reference-date + (pcase-let ((`(,y ,m ,d) tc-archive-reference-date)) + (calendar-absolute-from-gregorian (list m d y))) + (pcase-let ((`(,m ,d ,y) (calendar-current-date))) + (calendar-absolute-from-gregorian (list m d y))))) + +(defun tc--closed-absolute-in-region (beg end) + "Absolute day number of the first CLOSED: [YYYY-MM-DD ...] line in BEG..END, +or nil when the region carries no parseable CLOSED date. The task's own CLOSED +line sits in canonical position directly under the heading, so the first match +in the subtree is the task's close." + (save-excursion + (goto-char beg) + (when (re-search-forward + "CLOSED:[ \t]*\\[\\([0-9][0-9][0-9][0-9]\\)-\\([0-9][0-9]\\)-\\([0-9][0-9]\\)" + end t) + (calendar-absolute-from-gregorian + (list (string-to-number (match-string 2)) + (string-to-number (match-string 3)) + (string-to-number (match-string 1))))))) + +(defun tc--archive-file-path () + "Resolve the destination file for aged-out subtrees: `tc-archive-file' if set, +else `archive/task-archive.org' beside the todo file being processed." + (or tc-archive-file + (and tc-current-dir + (expand-file-name "archive/task-archive.org" tc-current-dir)))) + +(defun tc--git-ignored-p (path) + "Non-nil when PATH is gitignored (git check-ignore exits 0). nil on any git +error or when git is unavailable." + (let ((default-directory (or tc-current-dir default-directory))) + (eq 0 (ignore-errors + (call-process "git" nil nil nil "check-ignore" "-q" + (expand-file-name path)))))) + +(defun tc--ensure-archive-gitignored (archive-path) + "Keep the aged-out archive as private as the todo file it derives from. When the +todo file being processed is gitignored but ARCHIVE-PATH is not, append a +root-relative ignore entry for ARCHIVE-PATH to the project's .gitignore. No-op +when the todo file is tracked, the archive is already ignored, or there is no git +work tree — so track-mode projects (todo file tracked) leave the archive tracked +too. This is what makes the aging step safe to ship to gitignore-mode projects, +where todo.org is private: the archive inherits that privacy instead of leaking +previously-ignored task history into a tracked path." + (when (and tc-current-file tc-current-dir) + (let* ((todo (expand-file-name tc-current-file tc-current-dir)) + (default-directory tc-current-dir) + (root (with-temp-buffer + (when (eq 0 (ignore-errors + (call-process "git" nil (current-buffer) nil + "rev-parse" "--show-toplevel"))) + (string-trim (buffer-string)))))) + (when (and root (> (length root) 0) (file-directory-p root) + (tc--git-ignored-p todo) + (not (tc--git-ignored-p archive-path))) + (let ((entry (concat "/" (file-relative-name + (expand-file-name archive-path) root))) + (gi (expand-file-name ".gitignore" root))) + (with-temp-buffer + (when (file-readable-p gi) (insert-file-contents gi)) + (unless (save-excursion + (goto-char (point-min)) + (re-search-forward (concat "^" (regexp-quote entry) "$") nil t)) + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (insert "\n# Claude Code: task archive (follows todo file privacy)\n" + entry "\n") + (write-region (point-min) (point-max) gi nil 'silent)))))))) + +(defun tc--append-subtrees-to-archive-file (path texts) + "Append TEXTS (subtree strings) under the level-1 heading in PATH, creating the +file with `tc-archive-file-scaffold' and the parent directory when absent. +Ensures the archive inherits the todo file's gitignore status first." + (when (and path texts) + (tc--ensure-archive-gitignored path) + (let ((dir (file-name-directory path))) + (when (and dir (not (file-directory-p dir))) + (make-directory dir t))) + (with-temp-buffer + (when (file-readable-p path) + (insert-file-contents path)) + (when (= (point-min) (point-max)) + (insert tc-archive-file-scaffold)) + ;; Guarantee a level-1 heading to append under (older files might lack one). + (goto-char (point-min)) + (unless (re-search-forward "^\\* " nil t) + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (insert "* Resolved (archived)\n")) + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (dolist (text texts) + (insert text) + (unless (bolp) (insert "\n"))) + (write-region (point-min) (point-max) path nil 'silent)))) + +(defun tc-archive-old-resolved-to-file () + "Move level-2 DONE/CANCELLED subtrees in the \"Resolved\" section whose CLOSED +date predates the `tc-archive-retain-days' window out to `tc--archive-file-path'. +Only subtrees closed within the window stay; older ones, and those with no +parseable CLOSED date, are moved out. A nil `tc-archive-retain-days' disables the +step. Honors `tc-check-only' (report only)." + (when tc-archive-retain-days + (let ((res (tc--find-section "resolved"))) + (when (integerp res) + (let* ((cutoff (- (tc--reference-absolute) tc-archive-retain-days)) + (moves nil)) + (dolist (pos (tc--done-level-2-children res)) + (save-excursion + (goto-char pos) + (let* ((region (tc--subtree-region)) + (beg (car region)) + (end (cdr region)) + (closed (tc--closed-absolute-in-region beg end))) + ;; Archive anything not provably within the window: closed + ;; before the cutoff, or with no parseable CLOSED date at all. + (when (or (null closed) (< closed cutoff)) + (push (list :beg beg :end end + :heading (org-get-heading t t t t) + :line (line-number-at-pos beg)) + moves))))) + (setq moves (nreverse moves)) ; document order + (cond + ((null moves) nil) + (tc-check-only + (dolist (m moves) + (cl-incf tc-archived-to-file) + (push (list :kind 'archive-file-would :file tc-current-file + :line (plist-get m :line) :heading (plist-get m :heading)) + tc-issues))) + (t + ;; Capture text before any deletion (positions are still valid), then + ;; delete bottom-up so earlier subtree positions stay correct. + (let ((texts (mapcar + (lambda (m) + (concat (string-trim-right + (buffer-substring-no-properties + (plist-get m :beg) (plist-get m :end)) + "[ \t\n]+") + "\n")) + moves))) + (dolist (m (sort (copy-sequence moves) + (lambda (a b) (> (plist-get a :beg) (plist-get b :beg))))) + (delete-region (plist-get m :beg) (plist-get m :end))) + (tc--append-subtrees-to-archive-file (tc--archive-file-path) texts) + (dolist (m moves) + (cl-incf tc-archived-to-file) + (push (list :kind 'archive-file-moved :file tc-current-file + :line (plist-get m :line) :heading (plist-get m :heading)) + tc-issues)))))))))) ;;; --------------------------------------------------------------------------- ;;; --sync-child-priority mode @@ -377,10 +601,143 @@ 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) (setq tc-current-file (file-name-nondirectory file)) + (setq tc-current-dir (file-name-directory (expand-file-name file))) (with-current-buffer (find-file-noselect file) (org-mode) (cond @@ -388,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) @@ -420,6 +779,21 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas (plist-get i :file) (plist-get i :line) (if tc-check-only "would move" "moved") + (plist-get i :heading))))))) + ;; Aged-out subtrees: only reported when some moved (or would). Additive to + ;; the in-file report above, and absent when the aging step is disabled. + (when (> tc-archived-to-file 0) + (princ (format "todo-cleanup --archive-done: %d aged subtree(s) %s task-archive.org%s\n" + tc-archived-to-file + (if tc-check-only "would move to" "moved to") + (if tc-check-only " — CHECK MODE (no writes)" ""))) + (dolist (i (reverse tc-issues)) + (pcase (plist-get i :kind) + ((or 'archive-file-moved 'archive-file-would) + (princ (format " %s:%d: %s %s\n" + (plist-get i :file) + (plist-get i :line) + (if tc-check-only "would archive" "archived") (plist-get i :heading))))))))) (defun tc--emit-hygiene-report () @@ -467,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 () @@ -484,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) @@ -491,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) @@ -510,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/sessions/2026-06-28-15-57-inbox-proposals-shipped-and-task-audit.org b/.ai/sessions/2026-06-28-15-57-inbox-proposals-shipped-and-task-audit.org new file mode 100644 index 0000000..bf67b8e --- /dev/null +++ b/.ai/sessions/2026-06-28-15-57-inbox-proposals-shipped-and-task-audit.org @@ -0,0 +1,278 @@ +#+TITLE: Session Context +#+DATE: 2026-06-28 + +* Summary + +** Active Goal + +Startup → processed all 11 inbox handoffs (7 shared-asset proposals, skeptical- +reviewed via parallel subagents, walked A–G with Craig), then two follow-ups +(code-quality umbrella workflow + a task review), then a full task audit, then +wrap. Everything shipped is committed + pushed to origin/main. + +** Decisions + +- A simplification mode added to /refactor, and Craig chose to include it in the + default full scan (not own-mode-only). +- locating-craig.md is a standalone rule (not folded into daily-drivers.md). +- suspend.org built standalone-but-lean (not folded into flush), always-commit + step dropped. +- Commit gate: prose tightening AND a hard PreToolUse deny on bundled + test+commit (Craig picked the hook backstop over prose-only). +- Bug-priority matrix is BINDING for any project with a codebase (not opt-in); + mapping P1→[#A], P2→[#B], P3→[#C], P4→[#D]; home + work notified and work has + adopted it. +- Dot-stripped project names: alias approach (exact match still wins). +- readability-audit kept separate from /refactor (it feeds /refactor by filing + :refactor: tasks). +- Wrap done as NORMAL wrap, not teardown — the teardown feature is unvalidated + (its manual test was deferred this session), so no teardown sentinel dropped. + +** Data Collected / Findings + +- Task audit verdict: contrary to "many shipped," NONE of the 21 open tasks are + fully-done-but-open. The two closest (wrap-teardown, memories-sync) are + code-complete, gated only on Craig's manual validation. +- The git-commit-confirm hook's deny path: `** TODO` is a substring of + `*** TODO`, so Edit old_strings on demoted headings need a leading-newline + boundary to disambiguate. +- route_recommend matching: word-boundary literal match avoids home/homeowner + false positives; weak matching on common-word names (home, work) can + over-route — accepted v1 risk (labeled weak, reject-flow recovers). + +** Files Modified (all committed + pushed) + +- b621914 .claude/commands/refactor.md — simplification mode (later folded into full scan, 96dfa63 era edits) +- d4e9d7d claude-rules/locating-craig.md +- 797c426 suspend.org + readability-audit.org (+ INDEX, protocols) +- 92dfc35 verification.md + commits.md + hooks/{git-commit-confirm,_common}.py + tests (bundled-test deny) +- 9753d03 triggers.md + inbox-send.py (+ mirror, tests) — dot-stripped names +- 798ef02 claude-rules/todo-format.md + docs/design bug-priority bundle — binding matrix +- 6fb6797 notes.org inbox marker +- 96dfa63 code-quality.org umbrella workflow (+ INDEX) +- 5263cd6 todo.org — task review (3 restamped, generic-runtime → [#D]) +- 6be62ae .ai/scripts/route_recommend.py (+ mirror, tests) — wrap-up routing recommendation engine (spec Phases 1+3) +- 749566c todo.org — task audit reconciliation + flashcard tooling cluster + +** Next Steps — PICK UP HERE + +Phase D of the task audit was started but only 1 of 5 items handled. Remaining, +in suggested order: + +1. *wrap-teardown (task 42) manual validation — DEFERRED today.* Feature shipped + + pushed both sides; only the 5-test checklist remains (in the task body). + HAZARDS when running: test 1 tears down whatever session runs it (use a + SCRATCH ai-term session, never the live one); test 4 powers off (stub + `sudo shutdown now` → echo first); each "wrap" test ends its session so they + don't chain. On all-pass close task 42 DONE + CLOSED:; a failure becomes a bug. +2. *memories-sync VERIFY (task 214).* Implementation fully shipped; can't close + until (a) its manual-validation child runs and (b) ratio gets the one-time + roam.git clone + roam-sync timer (velox confirmed; ratio still outstanding, + can't verify from velox). See daily-drivers.md. +3. *Spec storage location + lifecycle convention (task 362).* Stalled on one + decision: filename-suffix vs org-keyword for lifecycle status. Needs Craig's call. +4. *"fix speedrun" autonomous-batch (task 383, DOING).* Stalled at spec-review; + needs Craig to ratify (or re-park) the 6 open spec decisions before building + work-the-backlog.org. +5. *Tooling-path warn-hook (task 434, [#D]).* Craig chose docs-only before; + greenlight building the warn-only hook, or leave [#D]. + +Also queued (not Phase D): +- Wrap-up routing feature (task 133, DOING): engine landed (6be62ae). Next + sub-tasks under it: :ROUTE_CANDIDATE: marker in inbox process mode, the + wrap-it-up router sub-step, the test surface, then manual e2e validation. All + call route_recommend.py. Spec: docs/design/wrapup-routing-spec.org. +- Flashcard tooling cluster (task parent created this session): apkg converter, + refutation (generic header-exemption per cj), multi-tag reconcile — build + together, same scripts; re-derive against the post-#+TITLE-fix canonical. + +KB: promoted 0 / consulted no + +* Session Log + +** 2026-06-28 Sun — Startup + inbox triage + +Ran startup (Phase A.0/A/B). Clean prior wrap (no session-context.org). .ai/ +synced from templates fine. Findings: 11 pending inbox handoffs, 3 top-level +tasks unreviewed >7 days, roam inbox 2 items, KB 51 nodes (none relevant). + +The 11 inbox items resolve to ~7 substantive proposals (some carry a cover +note): +- A: Simplification lens for the refactor skill (.emacs.d) +- B: new rule locating-craig.md (home) + cover note +- C: new workflow suspend.org (.emacs.d) + cover note +- D: bug-priority severity×frequency matrix (wttrin) + cover note +- E: harden commit gate to require green full suite (wttrin) +- F: generalize readability-audit.org into a template workflow (.emacs.d) + cover +- G: strip dots from project names .emacs.d→emacsd (.emacs.d roam item) + +All are shared-asset/convention changes → skeptical review + Craig approval, none +self-applies. Dispatching parallel read-only skeptical-review subagents for A-F; +G is a lightweight file-as-task. Surfacing dispositions to Craig next. + +Six skeptical reviews returned (all "do it with named changes"): +- A: keep only new lenses 1/2/3/4/7; cross-ref 5(dead-code)/6(duplication)/8(dead-code) + instead of re-specifying; own mode, NOT in default full scan; state /refactor↔/simplify + boundary. Lands in .claude/commands/refactor.md (it's a slash command, no refactor/ dir). +- B: standalone claude-rules/locating-craig.md. Changes: add whereami-fails fallback + (never fabricate), positive gate (velox→run else ask), drop -lts secondary tell, + add public-artifact-scope line (geolocated address is personal). whereami confirmed + real at ~/.local/bin/whereami; host is velox. +- C: REAL DESIGN FORK — substantial overlap with the flush skill (suspend ≈ flush's + pre-clear checkpoint minus /clear, plus a commit, reframed for departure). Reviewer + prefers folding into flush as a "checkpoint-and-stop" variant over a standalone + workflow. Also: drop the always-commit-working-files step (.emacs.d-ism; blind commits + fight wrap-it-up's leftover discipline). Startup's resume path already exists. + Companion: INDEX + protocols triggers if kept standalone. +- D: fold into todo-format.md as an OPTIONAL opt-in scheme; drop the global + letter-mapping mandate (present as template); keep security/privacy carve-out; mark + release-vehicle column illustrative. Defer the non-coding companion matrix as a + separate task. +- E: only the new mechanic is load-bearing (run suite as own command, gate on + failures==0, never bundle `make test; git commit`). Two-line edits to verification.md + #1 and commits.md #5; exact wording provided. Asks #2/#4 already covered. +- F: generalize Elisp specifics; drop concrete ;;; markers (keep principle); add + graceful degradation; add cross-links to /refactor + /simplify + A. Coherent division + of labor confirmed (readability = prose/naming clarity; /refactor = structural metrics; + /simplify + A = behavior-preserving logic reduction). Lands as a template workflow. +- G: file-as-task (strip dots from project names; touches triggers.md + inbox-send). + +*** Item A — APPLIED (not yet committed) +Craig approved option 1, then changed his mind: simplification IS part of the +default full scan. Added Mode: +Simplification to .claude/commands/refactor.md (frontmatter Six→Seven modes, +argument-table row, new section after Mode: Dead Code) with the 5 new lenses, +cross-refs to Dead Code (twin branches, plain deletion) + Duplication (repeated +literals), verify-all-call-sites rule, and a /simplify boundary note. Harness +picked up the skill change live mid-session. Confirmed for Craig: Rename mode +executes renames but doesn't flag bad names; no organization scan exists — both +gaps are proposal F's territory. + +*** Item B — APPLIED (not yet committed) +Craig approved. Wrote claude-rules/locating-craig.md (standalone) with the four +review changes: whereami-fails fallback (never fabricate), positive host gate +(velox→run, any other host→ask), dropped the -lts secondary tell, added a +keep-out-of-shared-artifacts section. make install linked it into +~/.claude/rules/locating-craig.md. + +*** Item C — APPLIED (not yet committed) +Craig picked option 1 (standalone, lean). Wrote +claude-templates/.ai/workflows/suspend.org with the review changes: drop the +always-commit step (note uncommitted work, leave tree as-is; project-opt-in +always-commit set only), cross-refs to flush + wrap-it-up, states it's the +capture half (startup is the resume half), flags "I need to go" breadth. +Registered in INDEX.org (Session lifecycle, after wrap-it-up) + protocols.org +trigger section. sync-check --fix synced canonical→mirror; re-verified exit 0, +suspend.org mirror matches. + +*** Item D — REVERTED then RE-APPLIED (binding), not yet committed +First applied an opt-in version; Craig reverted it ("do it differently"). His +intent: the matrix is BINDING, not opt-in — any project with a codebase (incl. +home + work, which have one despite being non-code) must prioritize its codebase +bugs by the matrix. Re-applied to claude-rules/todo-format.md as a mandatory +subsection. Mapping per Craig (2a): P1→[#A], P2→[#B], P3→[#C], P4→[#D] (fixed, +not a per-project knob). Bands defined per codebase; matrix structure + mapping +fixed. Severity-alone carve-out kept. Sent adoption handoffs to home + work +(inbox-send, 2026-06-28-1212). Non-coding companion matrix dropped — scope is +codebase bugs (home/work codebases covered). + +*** Item E — APPLIED (not yet committed) +Craig picked option 1 (prose tightening + bundling-detection hard gate in the +PreToolUse hook); asked first whether a hard gate existed — it didn't (githooks +pre-commit only runs sync-check; git-commit-confirm.py only scanned attribution). +Applied: verification.md "Before Committing" #1 and commits.md #5 rewritten to +"run the full suite as its own command, gate on zero failures, never bundle the +run with the commit." Added detect_bundled_test_run() + respond_deny() to the +hook (hooks/git-commit-confirm.py + hooks/_common.py): denies a test runner +chained into git commit via any ungated connector (;, &, |, ||, newline, or a +pipe that masks exit), allows the gated && form, matches the runner only in the +prefix before git commit so a runner name in the message doesn't trip it. TDD: +13 new tests red→green; full make test exit 0; end-to-end smoke test confirms +deny on bundled / pass on gated+plain. + +*** Item F — APPLIED (not yet committed) +Craig asked "should this be part of refactoring?" — concluded separate-but-linked +(it's a multi-phase workflow that FILES structural work as :refactor: tasks, i.e. +feeds /refactor rather than being a mode of it; /refactor is structure-only +scan-and-apply). Craig approved option 1. Wrote +claude-templates/.ai/workflows/readability-audit.org (generalized from .emacs.d's +Elisp draft: header convention / public-private naming / doc-linters all +"the project's X if it has one"; dropped concrete ;;; markers, kept the +mechanical-applier principle; added graceful degradation for no-suite/no-header/ +no-linter; added the pipeline cross-links to /refactor + /simplify). INDEX entry +under new "Code quality" section. sync-check exit 0, mirror matches. +Told Craig the run sequence: /refactor (incl. simplification) + readability-audit += existing-code sweep; /simplify = in-flight-diff cleanup. Offered (not built) a +code-quality umbrella workflow to chain them. + +*** Item G — APPLIED (not yet committed) +Craig picked option 2 (do it now) + alias approach. Implemented dot-stripped +project-name resolution: inbox-send.py gained display_name() (basename with dots +stripped), find_target() falls back to a dot-stripped alias after exact match +(exact wins), print_project_list shows the stripped name. triggers.md launch +resolution gained the dot-stripped match rule. TDD: 3 new alias tests red→green +(incl. exact-wins-over-alias), 26 inbox-send tests pass; sync-check exit 0; full +make test exit 0. .emacs.d→emacsd, .dotfiles→dotfiles now resolve in both ai +launch and inbox-send. + +** Walk complete; inbox close-out done — commits pending +A,B,C,D,E,F,G all applied + verified, uncommitted. Inbox cleared (0 pending): +bug-priority proposal + cover preserved to docs/design/2026-06-27-*; 9 other +handoffs deleted (content in canonical files). :LAST_INBOX_PROCESS: stamped +2026-06-28. Replies sent: emacsd (4 items, via the new alias), home (locating-craig), +work (bug-matrix FYI); plus binding-adoption handoffs to home + work. +Commits DONE — 6 landed on main (publish flow: /review-code over staged diff = +Approve; /voice personal over all 6 messages; Craig approved all): + b621914 feat(refactor): add simplification scan mode (A) + d4e9d7d feat(rules): add locating-craig rule (B) + 797c426 feat(workflows): add suspend and readability-audit workflows (C+F) + 92dfc35 feat(hooks): block bundled test+commit, require full suite before commit (E) + 9753d03 feat(inbox-send): resolve dot-stripped project names (G) + 798ef02 feat(todo-format): make the bug-priority matrix binding for codebases (D) +PUSHED to origin/main (ecd33e0..798ef02); in sync (0/0). Pre-push reconcile +confirmed ahead-only. Working tree: only .ai/notes.org marker + +.ai/session-context.org (both for wrap-up). + +** Post-push follow-ups (Craig: "do both") +- Task review: 3 stale tasks (reviewed 2026-06-15) re-stamped 2026-06-28; generic + agent-runtime spec re-graded [#C]→[#D] (speculative large arc, not committed); + memories-sync VERIFY + token-rotation helper kept. Staleness now 0. todo.org + uncommitted. +- Umbrella workflow: created claude-templates/.ai/workflows/code-quality.org — one + trigger sequencing /refactor → readability-audit over a scope, surfaces the + filed :refactor: backlog, documents the /simplify boundary. INDEX entry under + Code quality; sync-check exit 0. Uncommitted. +Both committed + pushed (publish flow: /review-code Approve, /voice personal on +the workflow body, Craig approved): + 96dfa63 feat(workflows): add code-quality sweep workflow + 5263cd6 chore(todo): task review — restamp stale tasks, downgrade generic-runtime to [#D] +origin/main in sync (0/0). Staleness nudge cleared (0). +Then committed the notes.org inbox marker (6fb6797 chore) to clean the tree; +working tree now only .ai/session-context.org (live anchor). 1 ahead of origin +(the marker commit, unpushed). [Pushed 6fb6797.] + +** Next work: wrap-up routing feature (Craig: "1 then 2") +*** 1 — Recommendation engine (spec Phase 1+3) — BUILT, tested +Added .ai/scripts/route_recommend.py (canonical+mirror): pure recommend(item, +projects)→(destination, confidence) — strong/weak/none, word-boundary literal +match, dot-stripped alias aware, top-tier tie→weak deterministic, empty→none. +CLI (--item/--exclude) reuses inbox-send discover_projects via importlib. 13 +tests green, full make test exit 0, mirror synced. Sub-task in todo.org rewritten +to dated entry. UNCOMMITTED — committing via publish flow next. +*** 2 — wrap-teardown manual validation — DEFERRED (Craig redirected) +Engine committed + pushed (6be62ae). Then Craig redirected: "many tasks in +todo.org were shipped — let's do a full task audit." Pivoted to task-audit. + +** TASK AUDIT (Craig: many tasks shipped) +Phase A: 21 open tasks (lines 42-1134 in Open Work). Phase B: dispatched 4 +parallel read-only reconciliation subagents over batches, each checking tasks +vs git log + repo tree + sessions, returning CURRENT/DONE/STALE/NEEDS-USER. +Verdict: contrary to "many shipped," NONE are fully-done-but-open. Most CURRENT +(backlog). The 2 closest (wrap-teardown 42, memories-sync VERIFY 214) are +code-complete, gated only on Craig's manual validation. +Phase C autonomous updates applied: task 186 (folded cj generic-header redirect, +superseded 2-option fix), task 203 (folded cj "document as local-only"; :bug:→ +:chore:, reframed as docs task), task 428 (precondition-landed note + LAST_REVIEWED). +Phase E: :LAST_AUDIT: stamped 2026-06-28. Phase F: skip task-review chain (ran +today). NEEDS-USER + clusters surfaced to Craig next. todo.org + notes.org +uncommitted (audit edits). diff --git a/.ai/sessions/2026-06-29-03-56-spec-lifecycle-decision-and-speedrun-ratified.org b/.ai/sessions/2026-06-29-03-56-spec-lifecycle-decision-and-speedrun-ratified.org new file mode 100644 index 0000000..2a61f75 --- /dev/null +++ b/.ai/sessions/2026-06-29-03-56-spec-lifecycle-decision-and-speedrun-ratified.org @@ -0,0 +1,107 @@ +#+TITLE: Session Context +#+DATE: 2026-06-28 + +* Summary + +** Active Goal +Handle todo.org items 4 (spec storage location + lifecycle convention) and 5 +(speedrun / autonomous-batch) — both decision-gated — then wrap. + +** Decisions +- Item 4 status mechanism: org-keyword authoritative + Status field in Metadata, + drop the filename suffix (Craig chose option 1 over his earlier filename-suffix + lean, 2026-06-28). +- Item 4 scope addition: retrofit existing docs across ALL projects, not just + document the convention going forward (Craig, 2026-06-28). +- Speedrun naming: the workflow is "speedrun" / "no approvals speedrun" (not + "fix speedrun"); threaded through task heading, body, and the spec prose. +- Item 5 criteria recast (Craig found them too soft): removed the task-size gate + entirely (large tasks decompose into per-commit chunks; size gating defeated the + away-from-desk use case); replaced act-vs-file adjectives with a crisp 4-item + defer checklist keyed on test-writability; eligibility simplified to status TODO + AND :solo:. +- :solo: / :quick: get hard definitions in todo-format.md, applied at creation and + enforced as a mandatory step in task-review + task-audit. +- Added the speedrun pre-flight decision-gathering step: batch all quick decisions + up front, "skip this" drops a task, then run hands-off. Unattended loop has no + kickoff human, so it still defers decision-needing tasks. +- Craig ratified all 8 revised decisions; spec Status → ready. + +** Data Collected / Findings +- No abandoned work from any shutdown: clean wrap last session (no crash anchor, + clean tree, last commit was the wrap archive at 15:59). Craig's "machine shut + down" recollection didn't match the record; deferred work (wrap-teardown + validation) was the closest match. +- The autonomous-batch spec already existed and reconciled the old fix-speedrun + + inbox-zero Phase E proposals; it had 6 drafted decisions awaiting ratification. + The revision grew it to 8 (added tag-definitions/enforcement + pre-flight Q&A). + +** Files Modified +- docs/design/2026-06-16-autonomous-batch-execution-spec.org — major revision + (size gate removed, defer checklist, tag definitions, pre-flight Q&A, naming), + then ratified: Status ready, cookie [8/8], all 8 decisions DONE, history entries. +- todo.org — item 4 (:373) decision + retrofit requirement recorded; item 5 (:394) + heading/body renamed to "No-approvals speedrun"; the spec-review VERIFY rewritten + to a dated event-log entry. + +** Next Steps +- Item 5 build (when prioritized): Phase 0 (todo-format.md :solo:/:quick: definitions + + task-review/task-audit enforcement) through Phase 6 (synthesis). Parent task + stays DOING. +- Item 4 build (when prioritized): spec-create via the recorded decisions; ship the + retrofit helper + startup nudge; pilot on rulesets' own docs/design first. +- Naming cleanup: the proposal-doc filenames still carry "fix-speedrun"; a rename + pass with link updates is deferred. +- Other open carryover from startup: wrap-teardown manual validation (task 42), + memories-sync VERIFY (needs ratio), plus the remaining what's-next candidates. + +KB: promoted 0 / consulted no + +* Session Log + +** 2026-06-28 — Startup + what's-next triage +Ran full startup: clean wrap last session (no crash anchor), repos current, +inbox empty, no reminders/pending decisions. Roam inbox had 4 items, all for +other projects (.emacs.d, emacs-wttrin) — none for rulesets. Surfaced 5 +what's-next candidates; Craig picked items 4 and 5 to handle, then wrap. + +** 2026-06-28 — Item 4 decision recorded +Craig chose option 1 for the spec lifecycle status mechanism (org-keyword +authoritative + Status field, drop filename suffix; adopt location split + +org-id links). He added a requirement: existing spec/design files in ALL +projects must be sorted into docs/specs/ vs docs/design/ — a one-time per-project +migration template sync can't do, so the spec must design the reach mechanism +(proposed: synced classify-and-move helper under .ai/scripts/ + startup nudge +gated on a :LAST_SPEC_SORT: marker). Recorded both into todo.org:373. + +** 2026-06-28 — Item 5 (speedrun) spec revised per Craig's direction +Craig found the eligibility criteria too soft. Revised the autonomous-batch spec +(docs/design/2026-06-16-autonomous-batch-execution-spec.org) substantially: +- Removed the task-size gate entirely (Craig: size shouldn't matter; large tasks + decompose into per-commit chunks; speedrun is the away-from-desk mode and size + gating forced him to stay at the desk). I agreed; only caveat is the unattended + loop's cost ceiling, handled by the vNext token budget. +- Recast act-vs-file as a crisp 4-item defer checklist keyed on test-writability + ("can I write the failing test from the task text without inventing a + requirement"), an enumerated data-loss operation list, already-satisfied, and + design-deliberation. Replaces the old adjectives. +- Eligibility simplified to status TODO AND :solo: (size gone, so :quick: drops to + an effort hint, not a gate). :solo:/:quick: get hard definitions in + todo-format.md, applied at creation + enforced as a mandatory step in + task-review and task-audit (Craig's ask). +- Added the speedrun pre-flight decision-gathering step: gather → classify → order + → intro → batch-ask the quick decisions → "skip this" drops a task → run + hands-off. Makes "no approvals" = all approvals front-loaded. The unattended + loop has no kickoff human, so it still defers decision-needing tasks. +- Naming: "fix speedrun" → "no-approvals speedrun" in spec prose + todo.org:394 + heading/body. Proposal-doc filenames keep their on-disk names (rename pass is + separate). Spec Status stays draft pending ratification of the revised decisions. +Spec opened in emacs for Craig's review. Companion build edits still pending: +todo-format.md definitions + task-review/task-audit enforcement (Phase 0). + +** 2026-06-29 — Item 5 ratified +Craig ratified all 8 decisions. Spec Status → ready, cookie → [8/8], all 8 +decision headings DONE, ratification entry added to iteration history. The +*** VERIFY "Review the autonomous-batch execution spec" (todo.org) rewritten to a +dated event-log entry. Parent task stays DOING (build pending: Phase 0–6). +Items 4 and 5 both handled. Ready to wrap. diff --git a/.ai/sessions/2026-06-30-13-55-pager-mcp-ssh-alias-and-emacsd-proposals.org b/.ai/sessions/2026-06-30-13-55-pager-mcp-ssh-alias-and-emacsd-proposals.org new file mode 100644 index 0000000..3cc8501 --- /dev/null +++ b/.ai/sessions/2026-06-30-13-55-pager-mcp-ssh-alias-and-emacsd-proposals.org @@ -0,0 +1,101 @@ +#+TITLE: Session Context +#+DATE: 2026-06-30 + +* Summary + +** Active Goal +Startup, then process the .emacs.d inbox: chase how the 2026-06-29 Signal page was sent, clean up the dead page-signal path, and review + ship the three shared-asset proposals. Verify ratio's roam-sync, then wrap + tear down. + +** Decisions +- Pager: no CLI wrapper. The signal-mcp tool is one call for in-session agents (the only paging caller); a signal-cli wrapper only if a non-session caller ever needs Signal paging. Pager guidance = notify --persist at the machine, signal-mcp send_message_to_user (to Craig's UUID) when away. +- SSH alias drift: restore the bare =cjennings= alias in ~/.ssh/config (root fix, both daily drivers) rather than repointing one repo's remote. +- green-baseline: accept with 3 changes (no-suite guard, cross-ref When You Cannot Verify, placement as start-work 0.3). +- todo-cleanup aging: accept retain 7, default ON; ADD script self-protect so the archive inherits the todo file's gitignore status (Craig's rule). +- lint-org checkers: accept with indented-heading tightened to 2+ stars (1+ false-positives on valid `*` list bullets). + +** Data Collected / Findings +- The 2026-06-29 page went out via the signal-mcp tool (transcript 924ea200), not a revived account. The pager account (+15045173983) was never deregistered — the 2026-06-12 "deregistered" premise conflated the page-signal *script* removal with account loss. signal-cli registered, signal-mcp connected, live page reached Craig's phone. +- "Network down" was a misread: internet was up, DNS fine, cjennings.net resolves. Real cause = git@cjennings remote alias missing from ~/.ssh/config (only cjennings.net defined). Fixed; both repos' remotes work again. +- gitignore-mode code projects (chime, pearl, archangel, .emacs.d) all gitignore todo.org → the aging archive must follow or it leaks private task history to a tracked path on public repos. +- indented-heading 1+-star regex flagged 3 valid `*` list bullets (reproduced); 2+ stars is the correct, collision-free target. +- ratio confirmed over tailscale: roam clone + roam-sync.timer live and syncing; both daily drivers now set up. + +** Files Modified (all committed + pushed) +- a266250 — Makefile prune step for dangling bin symlinks + protocols.org pager section. +- dotfiles 3119bbb — ~/.ssh/config: restore bare =cjennings= alias (propagated to ratio over tailscale). +- d0ab047 — verification.md green-baseline section + start-work Phase 0.3. +- 324a52b — daily-drivers.md reframed around direct tailscale reach + mechanics section. +- f67e724 — todo-cleanup.el Resolved-section aging + tc--ensure-archive-gitignored self-protect + 2 tests. +- d9d8ce7 — lint-org.el four structural checkers, indented-heading at 2+ stars + negative-case test. +- a1f87b1 — inbox-process marker. 08772c5 — ratio roam-sync confirmed (todo log + cleared daily-drivers open instance). +- Three proposal source notes preserved under docs/design/. + +** Next Steps +- Memory-sync VERIFY (todo.org:225) cross-machine half done; only its manual-validation child (work/unknown-project refusal checks, needs Craig's eyes) remains before DONE. +- This wrap runs --archive-done with the new aging step: rulesets is track-mode, so ~most of its 65 Resolved entries move to a tracked archive/task-archive.org (intended). +- Pager: still-open caveat is signal-cli's 27-day receive gap (send unaffected; long gaps can desync eventually). + +KB: promoted 0 / consulted no + +* Session Log + +** 2026-06-30 ~08:50 EDT — Startup +Ran startup workflow. Network is down for git remotes (host =cjennings= unresolvable) — both the rulesets pull and the project-repo fetch failed; session continues on local state, no remote sync this session. No crash anchor (clean prior wrap). notes.org: no active reminders, no pending decisions. =make install= nothing new to link. =.ai/= synced from templates (no tracked change). + +Roam inbox: 5 items, all =emacs:= / =wttrin:= prefixed — none owned by rulesets; nothing to claim. + +Local inbox: 9 unprocessed handoffs, all from .emacs.d: +- green-baseline-proposal.org — shared-asset change (verification.md + start-work skill): add a "green baseline before starting work" gate. +- todo-cleanup.el + test-todo-cleanup.el + rulesets-note-archive-aging.org — shared-asset change to synced =.ai/scripts/todo-cleanup.el=: add Resolved-section file-aging to =--archive-done=. +- lint-org.el + test-lint-org.el + rulesets-note-lint-checkers.org — shared-asset change to synced =.ai/scripts/lint-org.el=: four structural heading checkers. +- dangling-page-signal note (2026-06-30 0112) — FYI + ask: =~/.local/bin/page-signal= is a dead symlink (canonical removed 2026-06-12); ask make install/uninstall to clean it on both daily drivers + confirm pager = =notify --persist=. +- follow-up page-signal note (0115) — FYI: GV pager account (+15045173983) may be live again as of 2026-06-29; capture the revival method, update pager guidance. + +Three of these are shared-asset proposals → skeptical review + Craig approval required (no silent apply). The two page-signal notes are one actionable cleanup + FYIs. Confirmed =~/.local/bin/page-signal= target is dead (exit 2). Working tree clean except the 9 untracked inbox files. + +** 2026-06-30 ~09:05 EDT — Signal pager investigation (Craig's option 2) +Craig asked how the 2026-06-29 Signal page was done. Traced it: the .emacs.d session (transcript 924ea200) called the signal-mcp tool =send_message_to_user= at 19:19 to Craig's UUID b1b5601e-…, after desktop notify couldn't reach his session bus. No re-registration / captcha — the pager account was never down. + +Finding: the "GV account deregistered 2026-06-12" premise in the follow-up note (and the PROCESSED-2026-06-12 note) is WRONG. Only the =page-signal= shell script was removed from canonical on 6-12; its =~/.local/bin/page-signal= symlink dangles. The account stayed registered the whole time. Verified live: signal-cli listAccounts shows +15045173983 registered; =claude mcp list= shows signal-mcp ✔ Connected. Two independent paths, opposite status: page-signal script = dead; signal-mcp tool = alive (the working pager). Caveat: signal-cli warns last *receive* 27 days ago — send unaffected, but long receive gaps can desync eventually. + +Updated the =project_signal_pager_account= memory to correct the page-signal-vs-signal-mcp distinction and kill the deregistration premise. + +** 2026-06-30 ~09:25 EDT — page-signal cleanup + pager guidance (Craig directed: proceed) +Craig confirmed the live signal-mcp page reached his phone. Directed: point pager guidance at signal-mcp for when away from laptop/desktop, clean up the dead script, commit+push when remote is reachable. + +Found page-signal already fully removed from the repo on 2026-06-12 (13256aa) — Makefile, workflow, mcp/README all clean. Only the on-disk =~/.local/bin/page-signal= symlink dangled. Root gap: the install bin loop links =claude-templates/bin/*= but never prunes orphans, so any removed script leaks a dangling symlink. Fixed durably: added a prune step to the install bin section (Makefile) that removes symlinks in =~/.local/bin= pointing into =claude-templates/bin/= whose target is gone. Ran =make install= — pruned page-signal on velox; ratio self-cleans on its next session's make install. + +Pager guidance: added a "Paging Craig — desktop vs. away" section to protocols.org (canonical), distinguishing =notify --persist= (at-machine) from signal-mcp =send_message_to_user= to the UUID (away), and explicitly retiring the page-signal script. sync-check --fix synced the mirror. + +Files modified: Makefile (prune step), claude-templates/.ai/protocols.org + .ai/protocols.org (pager section). Reviewed (/review-code --staged → Approve), /voice personal on the message. Committed a266250 (authored as Craig). + +** 2026-06-30 ~09:55 EDT — Decision: no CLI wrapper; push diagnosis (SSH alias drift) +Craig's design call: don't rebuild a page-signal-style script. The signal-mcp tool is one call for in-session agents (the only paging caller we have); a thin signal-cli wrapper gets added only if a non-session caller (hook/cron) ever needs Signal paging. protocols wording already reflects this. + +Push diagnosis: the "network down" read this session was WRONG. Internet is up, DNS works, cjennings.net resolves (IPv6). The real cause: =origin= is =git@cjennings:rulesets.git= (bare host alias =cjennings=), but =~/.ssh/config= only defines =Host cjennings.net= — no bare =cjennings= alias — so every git remote op failed to resolve the literal hostname. =git@cjennings.net:rulesets.git= works (ls-remote returned HEAD). Pushed a266250 via the explicit .net URL (clean fast-forward e4aeea6..a266250); remote main now at a266250. + +OPEN: the =cjennings= SSH alias drift is a root issue affecting every repo whose remote uses =git@cjennings:=. Durable fix is a Craig decision — restore =Host cjennings= in =~/.ssh/config= (covers all repos, dotfile-synced to both daily drivers) vs. repoint this repo's remote to =cjennings.net=. Daily-driver concern: ratio may differ. Surfaced to Craig. + +** 2026-06-30 ~10:05 EDT — SSH alias fix (Craig: option 1) + both pushes landed +Craig chose the root fix. =~/.ssh/config= is a stow symlink → canonical =~/.dotfiles/common/.ssh/config= (the ~/.dotfiles git repo). Edited the cjennings block from =Host cjennings.net / LogLevel ERROR= to =Host cjennings cjennings.net / HostName cjennings.net / LogLevel ERROR= so the bare alias maps to the real host. Verified: =ssh -G cjennings= → hostname cjennings.net, identity id_ed25519; =git ls-remote origin= (the original git@cjennings URL) returns a266250 — unbroken. + +Committed in dotfiles as 3119bbb (fix(ssh): restore the bare cjennings host alias, authored as Craig) and pushed via git@cjennings (alias works). rulesets a266250 also confirmed on remote. + +DAILY-DRIVER (ratio): ratio likely has the same alias drift. Once ratio runs =git -C ~/.dotfiles pull=, the stow symlink picks up the fix immediately (no re-stow needed if the symlink exists). Until then ratio's pushes to cjennings.net keep failing. Flagged to Craig per daily-drivers.md. + +NOTE: dotfiles repo has its own .ai/ scope + 5 pending inbox handoffs — left untouched (cross-project boundary; they belong to a dotfiles session). + +** 2026-06-30 ~10:15 EDT — ratio synced over tailscale; alias drift fully closed +Craig: do the ratio pull now, it's on tailscale (ratio = 100.71.182.1). Confirmed ratio had the same alias drift (=ssh -G cjennings= → hostname cjennings). ratio's dotfiles remote already used the .net form (=git@cjennings.net:dotfiles.git=), so a plain ff-only pull worked there — no bootstrap problem. Pulled 995f7d7..3119bbb (clean ff; ratio's untracked cross-agent-comms WIP left untouched). ratio's =~/.ssh/config= is a stow symlink, so the fix went live immediately: =ssh -G cjennings= now → cjennings.net, and =git@cjennings:rulesets.git= ls-remote returns a266250. Both daily drivers fixed. + +Page-signal arc fully closed. Replied to .emacs.d (inbox file 2026-06-30-1310-from-rulesets) and deleted both page-signal handoffs from rulesets inbox. 7 inbox handoffs remain = the 3 shared-asset proposals. + +** Reviewing shared-asset proposals in turn (Craig's direction) +1. green-baseline — DONE. Accepted with 3 changes (no-suite guard, cross-ref When You Cannot Verify, placement as start-work 0.3). Implemented in verification.md + .claude/commands/start-work.md, proposal preserved to docs/design/2026-06-29-green-baseline-proposal.org. Committed d0ab047, pushed. +4. daily-drivers tailscale correction (arrived mid-session 13:20) — DONE. Reframed daily-drivers.md from "can't reach, flag it" to "CAN reach over tailscale, sync/verify/repair directly" + a tailscale-mechanics section; my tweak: bare hostname resolves only with MagicDNS (ssh ratio worked from velox), so IP/MagicDNS is the reliable path. Note preserved to docs/design/2026-06-30-daily-drivers-tailscale-correction.org. Committed 324a52b, pushed. Replied to .emacs.d on both. +2. todo-cleanup.el Resolved-section aging — DONE. Accepted (retain 7, default ON, Craig confirmed). Applied proposed .el + tests to canonical. ADDED self-protect (Craig's gitignore rule): confirmed gitignore-mode code projects (chime/pearl/archangel/.emacs.d) all gitignore todo.org, so the archive must too or it leaks private task history to a tracked path on public repos. tc--ensure-archive-gitignored appends the archive path to .gitignore when the todo file is ignored but the archive isn't; track-mode leaves both tracked. +2 ERT tests (temp git repo, per branch). 36 todo-cleanup tests green, full make test green. Committed f67e724, pushed. Note preserved to docs/design/2026-06-29-todo-cleanup-aging-proposal.org. Replied to .emacs.d (incl. heads-up: its next --archive-done sheds backlog + auto-adds the .gitignore line). NOTE: rulesets is track-mode → its next wrap sheds ~most of 65 Resolved entries to a tracked archive/task-archive.org. +3. lint-org.el four structural heading checkers — DONE. Accepted with a fix: tightened indented-heading from one-or-more stars to TWO-or-more. The 1+ regex false-positives on valid org plain-list bullets (indented single `*` is a list bullet, not a demoted heading — reproduced: a normal `*` list flagged 3 valid bullets). `**`+ is never a bullet, so an indented one is unambiguously a demoted invisible heading. Added a negative-case test. 45 lint-org ERT green, full make test green. Committed d9d8ce7, pushed. Note preserved to docs/design/2026-06-29-lint-org-structural-checkers-proposal.org. Replied to .emacs.d. + +ALL inbox handoffs processed (inbox empty). Commits this session: a266250 (page-signal/paging), d0ab047 (green-baseline), 324a52b (daily-drivers tailscale), f67e724 (todo-cleanup aging + self-protect), d9d8ce7 (lint-org checkers); dotfiles 3119bbb (ssh alias). All pushed. + +OPPORTUNITY (now actionable): daily-drivers.md "Current open instance" wants ratio's roam clone + roam-sync timer verified — now doable directly over tailscale rather than waiting on Craig. Flagged, not yet done. diff --git a/.ai/sessions/2026-07-02-09-29-docs-lifecycle-speedrun-autonomous-loop.org b/.ai/sessions/2026-07-02-09-29-docs-lifecycle-speedrun-autonomous-loop.org new file mode 100644 index 0000000..8fc23e9 --- /dev/null +++ b/.ai/sessions/2026-07-02-09-29-docs-lifecycle-speedrun-autonomous-loop.org @@ -0,0 +1,110 @@ +#+TITLE: Session Context — Docs-lifecycle build, wrap-up router, speedrun build +#+DATE: 2026-07-02 + +* Summary + +** Active Goal +(Session closed 2026-07-02 ~09:30 — the standing loop directive below ran through the night and morning and ended with this wrap; cron job 752624d1 deleted at wrap.) +Standing directive (Craig, 2026-07-02 ~01:30): run auto-inbox-zero every 30 minutes with a STANDING YES — execute on all items found each cycle. Per-item disposition: feature-level task → write a spec (spec-create); decisions I can't confidently guess → file a VERIFY; well-defined → implement with the full quality bar. Autonomous commit + push under rulesets' :COMMIT_AUTONOMY: waiver. Auto-flush (/flush auto, self-inject) at clean boundaries when context grows heavy. Earlier directive items all DONE tonight: wrap-up routing, speedrun Phases 1-6 (work-the-backlog.org), roam inbox zero, auto-flush implementation + speedrun incorporation + disposition rule. + +** Decisions +- Inbox: Craig approved all five dispositions (convert-subtasks bundle + planning-line fix, task-audit C.6, sweep security fix + public-reachability convention + broadcast, spec-review UI-traps promotion, KB orphan report → [#C] task). +- Wrap-teardown: Craig ran all five manual tests, all passed — feature closed DONE, armed as the default (a bare "wrap it up" tears the session down; "with summary" keeps the buffer). +- Speedrun Phase 0: hard :solo:/:quick: definitions are fixed cross-project in todo-format.md; review/audit tag assessment is mandatory; task-review gate 3 realigned to no-deliberation (1-2 upfront-answerable quick decisions allowed). +- Docs-lifecycle spec: ratified through two independent review rounds (Codex + fresh-context Claude agent; 14 findings, all fixed, verify passes held); Codex flipped READY; spec-response decomposition flipped DOING. Key forks: two-sequence keyword header; :SPEC_ID: parent-keyword binding; fail-safe --apply; file: links through the pilot with id: conversion gated on the .emacs.d id-index mechanism; evidence-based status confirmation. +- Lesson: never flip a lifecycle state in the same pass that authored the fixes — the reviewer owns the flip. + +** Data Collected / Findings +- Spec: docs/specs/2026-07-01-docs-lifecycle-spec.org, status DOING, :ID: 80b0787b-4a60-4c82-8a16-b383d3e3c8f2; build parent in todo.org carries :SPEC_ID: (task "Spec storage location + lifecycle-status convention", ~line 361). +- Pilot surface: docs/design has 41 files (3 spec-spine candidates: Decisions AND Implementation phases); 2 stray root specs (agent-knowledge-base-spec.org, inbox-workflow-consolidation-spec.org); docs/design/task-review.org is the note counter-case (Metadata only). +- Validations: KB check 1 verified (55 rg = 55 org-roam DB); check 4 velox half verified (probe node agents/20260701214910-kb-sync-validation-probe.org pushed f0252bb, 0 conflicts) — ratio half blocked (ssh times out; probe left in place; confirm command logged in todo.org). KB checks 2+3 need work/unknown-project sessions. Roam inbox holds 2 rulesets items (ai-term colors → .emacs.d territory; "wrap it up closes window" → already delivered by wrap-teardown). +- Bare [N/N] tokens in org prose get mangled by cookie updates — spell counts in words. + +** Files Modified +All committed and pushed through 9ad415d. Tonight: 80ca5d0 spec-sort helper + 33-test bats; f4b64d6 pilot (5 specs sorted to docs/specs/, board live, :LAST_SPEC_SORT: stamped); 21639cb startup nudge (find-based probe, compgen was bash-only) + .emacs.d convention-live/id-index note; 7c12007 wrap-up router (route-batch + 13-test bats, inbox.org :ROUTE_CANDIDATE: stamp, wrap-it-up router step, cross-project.md); 9ad415d speedrun decomposition + spec DOING flip. Earlier (2026-07-01): d0c92d0 docs-lifecycle Phase 1 and everything before it. + +** Next Steps +SESSION CLOSED — the auto-inbox-zero loop ended with the wrap (job deleted; a future session re-arms it only on a fresh directive from Craig). What carries forward: Craig's parked [#C] wrap-summary keep-or-cut think-through; ratio probe confirm (ssh was timing out — verify agents/20260701214910-kb-sync-validation-probe.org landed on ratio); KB refusal checks 2+3 (need work/unknown-project sessions); docs-lifecycle leftovers (Craig's manual tests: nudge visibility + Emacs id-link click-through; 4 anomaly renames; id-conversion gated on .emacs.d id-index). All build work from this session is DONE and pushed. + +Original loop contract (historical): continue the hourly auto-inbox-zero loop (cron job 752624d1, fires at :37, session-only). Each cycle: inbox-status + roam scan (capture-guard before roam writes); quiet → one acknowledgement line; finds → file, then execute ALL under Craig's standing yes with autonomous-commit + push (:COMMIT_AUTONOMY: + :LOOP_MAY_COMMIT: both stamped in notes.org Workflow State), disposition feature→spec / unguessable→VERIFY / well-defined→implement, full quality bar, metrics JSONL per task, session-log update per state-mutating cycle, /flush auto at heavy-context clean boundaries. Parked for Craig at his choosing: wrap-summary keep-or-cut think-through ([#C] in todo.org). Carryovers: ratio probe confirm (ssh), KB refusal checks (work/unknown sessions), docs-lifecycle leftovers (Craig's manual tests: nudge visibility + Emacs id-link click-through; 4 anomaly renames). Everything else from tonight is DONE and pushed (speedrun spec IMPLEMENTED after live trial; auto-flush + self-inject shipped; inbox-send collision fix; page info-styling; host-identity rule; template-sync policy; id-link conversion). + +KB: promoted 1 / consulted no +(Node: agents/20260702093025-reviewer-owns-the-lifecycle-flip.org — the don't-flip-your-own-fixes lesson from the docs-lifecycle spec review.) + +OLD (completed): build the speedrun / autonomous-batch phases per the spec docs/specs/2026-06-16-autonomous-batch-execution-spec.org (DOING, :ID: 90f623cd-fdbe-4f5c-b63d-b2f84d9151cf; build parent "No-approvals speedrun" in todo.org carries :SPEC_ID:, children Phases 1-6 + live-trial + flip). Read the spec's Design section (lines ~66-177: loop at both altitudes, eligibility gate, defer checklist, pre-flight Q&A, session modes/preset, run cap + kill switch, paging) before writing. Phase 1: write claude-templates/.ai/workflows/work-the-backlog.org (eligibility gate, defer checklist, per-task quality bar, run-cap; inputs task set + session mode + cap) AND revert inbox.org's "auto inbox zero" per-cycle item 3 yes-path to routing-only in the same commit (one home for execution). Phase 2: wire both callers (auto-inbox-zero yes-path → work-the-backlog tag-query/file-only/cap-1; speedrun preset → explicit list/autonomous-commit/always-push/paging after pre-flight Q&A). Phases 3-6 per the child task bodies. Canonical-side edits, sync-check --fix, make test, commit per phase. THEN: roam inbox zero (inbox.org roam mode; 2 known rulesets-related items: ai-term colors → .emacs.d territory, wrap-it-up-closes-window → already delivered) and react. Docs-lifecycle leftovers for Craig: flip decision pending his manual tests (nudge visibility + Emacs link click-through), 4 anomaly renames, id-conversion gated on .emacs.d. Carryovers: ratio probe confirm (ssh still timing out), KB refusal checks. + +* Session Log + +** 2026-07-02 Thu @ 09:30 -0400 — Session wrapped (teardown mode) +Morning loop cycles after the 07:51 auto-flush were all quiet (0 pending handoffs, roam inbox empty; last manual cycle ~09:15). Craig called the wrap. Cron job 752624d1 deleted; one KB node promoted (reviewer-owns-the-lifecycle-flip); todo cleanup + lint ran; wrap commit pushed. Teardown sentinel dropped per the validated default. + +** 2026-07-02 Thu @ 07:51:13 -0400 — flushed (auto-flush, self-injected) +Clean boundary: 07:50 loop cycle came back empty, tree clean, everything pushed through the metrics commit after a6b534f, suites green. Nothing in flight. First live use of the auto-flush mechanism shipped tonight (self-inject via tmux run-shell -b). Post-clear resume: read this Summary and continue the hourly loop per Next Steps — the cron job survives the clear (session-only, not conversation-only). Craig's standing yes remains in force (Active Goal). + +** 2026-07-02 Thu @ 06:00 -0400 — Loop cycle executed 3 finds (commits through a6b534f + metrics, pushed) +First executing loop cycle under the marker-granted autonomy. Found 3 archsetup handoffs + 2 rulesets roam entries (duplicates of the routed ones). Shipped: inbox-send collision fix (uniquify -2/-3 suffix, 4 red-first deterministic tests, 30/30 — a wild data-loss find, graded [#B] P2), page styling alarm → info --persist (page-me.org + work-the-backlog page; status-check untouched), dupre-blue ai-term refinement forwarded to .emacs.d (#67809c). Roam inbox: swept the 2 rulesets entries (archsetup's 3 left, roam-sync owns the git). Local inbox 0 pending; archsetup replied. Suites green, sync clean (one drift caught by the pre-commit check and re-synced). Loop continues hourly at :37 (job 752624d1). + +** 2026-07-02 Thu @ 05:30 -0400 — Live trial validated, spec IMPLEMENTED, loop now hourly (5eae9e0, pushed) +Craig's "1" granted :LOOP_MAY_COMMIT: (stamped in notes.org Workflow State) and validated the run. Closed the live-trial + flip children dated, parent "No-approvals speedrun" DONE + CLOSED, spec flipped DOING → IMPLEMENTED (board: 2 IMPLEMENTED / 2 READY / 2 DOING). Wrap-summary keep-or-cut think-through PARKED (stays filed [#C] in todo.org). Auto-inbox-zero rescheduled: old 30-min job deleted, new hourly job 752624d1 (at :37, off-minute per fleet guidance), same standing-yes contract, now with loop commits authorized by the marker rather than only the directive. + +** 2026-07-02 Thu @ 05:25 -0400 — First no-approvals speedrun complete: 3/3 (78bbaae, b6a977c, ed75d3c + metrics commit, all pushed) +Live-trial run c726f526 over Craig's ordered set. Pre-flight Q&A fired once (2 questions, Craig took both recommendations, answers stamped into task bodies as dated lines). Task 1 id-link conversion: 13 links → id: form, Review-findings heading got its own :ID: for the 2 search-target links, residue zero, all ids verified. Task 2 host-identity: claude-rules/host-identity.md (linked machine-wide, verified) + startup probe 13 (fixture-verified bash+zsh) + Phase C flag line. Task 3 template-sync: freshness policy in startup Phase A.0 (dirty = tracked-only; WIP-guard named as deliberate exception), monitor-inbox precondition fixed to --untracked-files=no + close-out symmetrized. Every task: /review-code, /voice, make test green, sync clean, one JSONL record (3 records in .ai/metrics/work-the-backlog.jsonl). End-of-set page fired via notify --persist. AWAITING: Craig's read on the run → then close the live-trial child dated + flip the autonomous-batch spec DOING → IMPLEMENTED. Then his two parked decisions: :LOOP_MAY_COMMIT: grant, wrap-summary keep-or-cut think-through. + +** 2026-07-02 Thu @ 01:40 -0400 — Inbox pass done; auto-flush shipped (d4f132b, 794b248, pushed); 30-min executing loop armed +Local inbox zero + roam inbox zero (roam was already empty — archsetup routed it). Processed: .emacs.d org-id delivery → id-conversion task ungated (:solo: now); archsetup's three roam items → template-sync-gitignored filed [#C], ai-term colors forwarded to .emacs.d, wrap-summary keep-or-cut filed [#C]; auto-flush bundle → self-inject.sh canonicalized + 6-test bats, flush skill auto mode (gate order preserved: verify anchor write BEFORE arming), work-the-backlog auto-flush section + preset step 6 + per-item disposition rule (feature→spec, unguessable→VERIFY, well-defined→implement). Design note preserved at docs/design/2026-07-02-auto-flush-mechanism-note.org. Replies sent to .emacs.d (x2) + archsetup (x2). All suites green, sync clean. NOW: /loop 30m auto-inbox-zero with Craig's standing yes (see Active Goal). + +** 2026-07-02 Thu @ 01:30 -0400 — Speedrun Phases 2-6 shipped (263138a, 8d790c0, 04561b2, eea93f1, 44c8cc2 — all pushed) +Phase 2: both callers wired (auto-mode chain ask scoped to the queued batch — a deliberate judgment, logged in the commit; speedrun preset section + trigger routing, "speedrun" always beats "no approvals" with disambiguation in no-approvals.org + INDEX). Phase 3: waiver pinned as :COMMIT_AUTONOMY:/:LOOP_MAY_COMMIT: markers in notes.org Workflow State; rulesets stamped :COMMIT_AUTONOMY: yes, :LOOP_MAY_COMMIT: deliberately left for Craig; .emacs.d told to stamp its own (inbox-send 0118). Phase 4: VERIFY-filing dedup (existing-sibling check), quick-question discriminator, batch-ask contract, page finalized. Phase 5: JSONL field table (+failed outcome, +manual caller, +comma-separated commit_sha — three traceable spec gaps closed). Phase 6: synthesis section + "synthesize backlog metrics" trigger. Every phase: /review-code, /voice, make test green, sync clean, todo.org child flipped dated. Parent stays DOING pending Craig's live trial + the flip task. NEXT: local inbox (2 handoffs: .emacs.d org-id delivery unblocks the docs-lifecycle id-conversion task; archsetup roam-routed item unread), then roam inbox zero + react. + +** 2026-07-02 Thu @ 01:15 -0400 — Speedrun Phase 1 shipped (d379a23, pushed) +work-the-backlog.org created (canonical + mirror + INDEX entry): caller contract, five-outcome vocabulary, mechanical eligibility gate (TODO + :solo:, no-scheme-header → don't run), four-item defer checklist, quality bar, cap semantics, Phase 3-5 stubs. inbox.org auto-mode item 3 reverted to routing-only. Review fixed a cap-default contradiction (explicit set defaults to list length, not 1) pre-commit. make test green twice, sync clean, todo.org Phase 1 child rewritten dated. NOTE: new inbox handoff from .emacs.d (2026-07-02-0056) — org-id resolution delivered, the gated id-conversion task is unblocked; process during the inbox pass after the speedrun build. Next: Phase 2 (wire the two callers). + +** 2026-07-02 Thu @ 00:47:00 -0400 — flushed +Clean boundary: wrap-up router shipped (7c12007 — route-batch helper + 13-test bats, inbox.org marker stamp, wrap-it-up router step, cross-project.md note; review fixed a reproduced nested-candidate data-loss bug pre-commit) and the speedrun spec decomposed + flipped DOING (9ad415d). Nothing half-edited; tree clean, pushed through 9ad415d, suite green. Post-clear resume goes straight to speedrun Phase 1 (contract pointers in Next Steps). Remaining under wrapup-routing: manual e2e (Craig's) + vNext transcript task. + +** 2026-07-02 Thu @ 00:25 -0400 — Phases 3 + 4 shipped: pilot ran, nudge live, .emacs.d notified +Craig confirmed all five pilot keywords as-is (option 1) plus the IMPLEMENTED reason for agent-knowledge-base-spec. Applied with --allow-dirty (only the untracked session anchor was dirty): 5 specs moved to docs/specs/, 12 todo.org links + the moved specs' outbound links rewritten, :LAST_SPEC_SORT: 2026-07-02 stamped, residue zero, board live (6 specs: 1 IMPLEMENTED, 3 READY, 2 DOING). Phase 4: spec-sort probe added to startup.org Phase A + Phase C nudge line; replaced the spec's compgen sketch with a find-based check (compgen is bash-only, zsh false-negatived on stray root specs) — fixture-verified both shells, four project shapes; fixed startup.org's stale path to the moved encourage-kb spec; sent .emacs.d the convention-live note + id-index ask (2026-07-02-0022 handoff). f4b64d6 + 21639cb pushed, make test green, sync clean. + +** 2026-07-02 Wed @ 00:15 -0400 — Phase 2 shipped: spec-sort + 33-test bats suite (80ca5d0, pushed) +TDD'd the retrofit helper (bats red-first, then the Python implementation). A fresh-context review agent found 4 real issues, all fixed pre-commit: acknowledged bare mentions weren't mapped through moves (a self-mention turned a successful apply into a false FAILURE with a destructive recovery recipe — regression-tested), real OSError mid-apply lost the applied-ops list (both failure paths now share ApplyFailure), "incomplete" status proposed terminal IMPLEMENTED (word-boundary matching now), and file-relative vs root-anchored link ambiguity now blocks validation as AMBIGUOUS. Real-data dry run matches predictions (5 candidates / 4 anomalies / 30 notes / 1 self bare mention / 10 report-only incl. the Codex-flagged startup.org case). make test green, sync clean. Next: Phase 3 pilot — candidate keywords + anomaly dispositions need Craig (see Next Steps). + +** 2026-07-01 Wed @ 23:41:36 -0400 — flushed +Clean boundary after docs-lifecycle Phase 1 (d0c92d0, pushed, tree clean, suite green). In flight: nothing half-edited. Post-clear resume goes straight to Phase 2 — the spec-sort build (contract pointers in Next Steps above). + +** 2026-07-01 21:05 EDT — Session resumed after interruption; inbox first, then validations/specs +The 2026-06-30 session died right after the validation pre-flight (nothing lost beyond the plan itself — no edits had landed). New session startup found this anchor plus 15 pending inbox handoffs. Craig's call: process the inbox first, then return to the validations + spec writing goal above. Also committed the one-line .claude/settings.json change from his /model command (c976f5b, "chore: set fable as project default model"). Green baseline confirmed before the commit: make test exit 0 — pytest 370+67+12 passed, all ERT suites 0 unexpected, 0 bats failures. + +Inbox inventory (15): the .emacs.d convert-subtasks bundle (10 files — todo-cleanup/lint-org + tests + 5 workflow/rule wirings), the .emacs.d task-audit Phase C.6 follow-on (2 files), the .emacs.d sweep-gitignore anchored-pattern security fix ask, the archsetup spec-review UI-traps promotion proposal, and a KB hygiene report (42 orphan agent nodes). Plan: skeptical review via diff-against-canonical for the bundles, then surface dispositions for approval. + +** 2026-07-01 ~21:45 EDT — Inbox items A, B, C shipped +Craig approved all five recommendations (A1 B1 C1 D1 E1). Shipped so far: +- A (19ba7cb): convert-subtasks bundle applied to canonicals + mirror — todo-cleanup --convert-subtasks, lint-org subtask-done-not-dated checker, wiring in wrap-it-up/clean-todo/open-tasks/task-review/todo-format.md. TDD'd the planning-line edge on top (CLOSED removal now preserves a DEADLINE/SCHEDULED sharing the line; red then green). Suites 45/45, 49/49, full make test green. +- B (356b905): task-audit Phase C.6 (retire completed parents / promote stragglers) applied as sent. +- C (909b21b + bac3fe4): sweep-gitignore-tooling.sh now recognizes anchored /.ai/ (mode detection + per-pattern presence + style-matched append) and WARNs on tracked tooling reachable via a non-cjennings.net remote; bare cjennings ssh-alias counts as private (false positive on rulesets itself caught by the real-data dry run, fixed with a 13th bats test). protocols.org gained the public-reachability convention. Real sweep run: 6 projects backfilled (archsetup, chime, emacs-wttrin in anchored style), archsetup's tracked CLAUDE.md flagged. Broadcast sent to 14 projects via inbox-send.py (the inbox-send wrapper isn't on PATH here — used the .py directly); archsetup got an extra tailored note about its tracked CLAUDE.md. +Still open in this pass: D (spec-review UI-traps), E (KB orphan task), replies to .emacs.d + archsetup, inbox file cleanup, LAST_INBOX_PROCESS stamp, push. + +** 2026-07-01 ~21:55 EDT — Inbox pass complete: D, E, replies, cleanup +D (9814b94): archsetup's six UI-traps checks promoted into spec-review.org Phase 4 as the conditional "Operational-panel UI traps" dimension; accept reply sent to archsetup. E: KB orphan review filed as [#C] :chore: in todo.org (orphan-ness isn't a defect; periodic prune/merge/link pass, regenerate the list before running); report deleted, no reply (script source). Full reply to .emacs.d sent covering all three of its handoffs, including the planning-line divergence from what it sent. All 15 inbox files deleted; inbox-status 0 pending; :LAST_INBOX_PROCESS: stamped 2026-07-01. Next: commit todo/notes, push all five commits, then return to the interrupted goal — manual validations (wrap-teardown task 42, Agent-KB refusal checks task 309) and the queued specs. + +** 2026-07-01 ~22:00 EDT — Validations: agent-runnable half done +Inbox pass pushed (7 commits, e36e932..6ec05bb). Resumed the validations goal. Done tonight: wrap-teardown plumbing re-verified fresh (Stop hook + symlink + no stale sentinel + companion fns (t t t); 3 live aiv-* sessions available for the gate test); Agent-KB check 1 verified (55 :agent: nodes in rg inventory AND 55 in the live org-roam DB — match); check 4 velox half verified (probe node agents/20260701214910-kb-sync-validation-probe.org committed + pushed by roam-sync in seconds, f0252bb, 0 conflicts). BLOCKED: ratio ssh times out (tailscale ping pongs via DERP, TCP:22 unreachable — likely suspended); probe left in place for later confirmation. Still needing Craig: wrap-teardown 5-test checklist (scratch session), KB checks 2+3 (work/unknown-project refusal sessions), work-machine-no-clone check. Evidence logged in todo.org under both tasks. Next: surface status + spec-writing choice to Craig. + +** 2026-07-01 ~22:00 EDT — Wrap-teardown feature validated and closed +Craig ran all five manual tests live — teardown-after-valediction, both summary qualifiers, the multi-session shutdown refusal, the cancellable countdown + stubbed shutdown, and the push-failure guard. All passed ("works great"). Rewrote the checklist sub-task to a dated entry and closed the parent DONE + CLOSED [2026-07-01 Wed] (archives on the next --archive-done). Sent .emacs.d a one-line FYI since its companion functions are half the feature. NOTE for this session's own wrap: teardown is now the validated default — a bare "wrap it up" here will tear this session down; use "with summary" to keep the buffer. Remaining validation carryover: KB refusal checks (work/unknown project sessions), ratio probe confirmation, work-machine-no-clone check. + +** 2026-07-01 ~22:15 EDT — Speedrun Phase 0 + docs-lifecycle spec drafted +Craig picked "2 then 1". Phase 0 shipped (2a45f07): hard :solo:/:quick: definitions in todo-format.md (fixed cross-project; :solo: = buildable + agent-verifiable + no deliberation with 1-2 upfront-answerable quick decisions allowed; :quick: = ≤30-min effort hint, never a gate), mandatory-assessment language in task-review + task-audit, and task-review gate 3 realigned from "no upfront decision" to the ratified no-deliberation form. Then the docs-lifecycle spec drafted at docs/specs/2026-07-01-docs-lifecycle-spec.org from the five 2026-06-28 decisions, dogfooding itself (first resident of docs/specs/, status heading DRAFT, :ID: link). Design call made in the draft: the authoritative keyword lives on a prepended top-level status heading (vocabulary DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED) — additive, retrofittable, grep- and agenda-scannable. lint-org --check on the spec: 0 mechanical, 6 judgment items that are todo-shape checkers misreading spec conventions (spec DONE decisions carry no CLOSED; review-history dated headers are template shape) — noted, no action. Task flipped DOING with a dated entry. Awaiting Craig's spec review to flip DRAFT → READY. + +** 2026-07-01 ~22:30 EDT — Dual review of the docs-lifecycle spec; all nine findings fixed +Craig had two independent agents review the spec: Codex (4 blocking findings, recorded in the spec's Review findings section) and my dispatched fresh-context reviewer (9 findings: same top blocker as Codex plus 5 unique, incl. the unowned DOING→IMPLEMENTED flip). Both rated Not ready; both independently caught that my #+TODO replacement destroyed the decision-task keyword machinery — the spec's own cookie was hand-faked. Craig approved fixing all nine. Responder pass done: merged ledger [9/9] with per-finding responses, two-sequence keyword header (verified: org computes [5/5] and [9/9]), transition-ownership table + spec-response flip task + task-audit safety net, single classification predicate, -spec.org rename, full relink contract, marker/nudge contract, compatibility rule, org-id prerequisite, three-line transition. Lint judgments on the spec are known todo-shape false positives (spec DONE entries and dated history headers). Status stays DRAFT; Craig decides the READY flip (option: send the fixed spec back to the reviewer agent, a082bea09c72a4e15, for a verify pass). + +** 2026-07-01 ~22:55 EDT — Second review round: five more findings, fixed and verified +After the first nine fixes, my premature READY flip raced Codex's re-review — no data lost (commit 642be35 carries both my flip and Codex's demotion + five new blocking implementation-readiness findings; twelve seconds of READY). Craig approved a second responder pass, including the fork on the org-id finding (keep file: links through the pilot; id: conversion gated on a concrete .emacs.d id-index mechanism). Fixed all five (b163637): canonical-placement contract, :SPEC_ID: parent-keyword binding for task-audit (dissolves the flip-task chicken-and-egg, survives --convert-subtasks), fail-safe --apply (preflight/plan/recovery), staged id conversion, evidence-based status confirmation. Also de-cookified bracket [N/N] prose tokens org's cookie updater would mangle. My reviewer's second verify pass: ready, all held, nothing regressed, three minor nits — folded in (43cecd4): scoped id-link criterion, untracked-copy cleanup in recovery, two stale prose spots. Spec parked at DRAFT [14/14]; the authoritative READY flip is left to Codex's rerun or Craig. Lesson recorded: don't flip a lifecycle state the same pass that authored the fixes. + +** 2026-07-01 ~23:40 EDT — Spec READY (Codex flip), decomposed, Phase 1 built +Codex's rerun flipped the spec READY at 23:22 (all fourteen findings closed). Craig: run with it, then flush and do Phase 2. Committed the reviewer flip, then ran spec-response Phase 6 as the first live exercise of the convention: :SPEC_ID: stamped on the build parent in todo.org, six child tasks (Phases 1-4, the gated id-conversion pass, the flip-to-IMPLEMENTED task) plus a manual-testing child (nudge visibility, link click-through), spec flipped READY→DOING (328ca18). Phase 1 then built and committed: claude-rules/docs-lifecycle.md (new rule, linked machine-wide), spec-create location + template updates (two-sequence header, DRAFT status heading with :ID:), spec-review location expectation + compatibility rule + READY flip ownership (incl. the demote path), spec-response DOING flip + :SPEC_ID: + mandatory flip task, task-audit :SPEC_ID: reconcile query. Mirror synced, make test green. NEXT AFTER FLUSH: Phase 2 — build claude-templates/.ai/scripts/spec-sort + bats per the spec's retrofit contract (classify predicate, evidence panel, plan/validate/apply, preflight, recovery, relink, marker stamp); the spec section "The retrofit" and the Phase 2 task body carry the full contract. + +** 2026-06-30 ~14:25 EDT — Startup + validation plan (interrupted session's log) +Fresh session, clean startup (no crash anchor, clean tree, repos current). Craig: "do all the validations now, then write all the specs." Two validation items pending: wrap-teardown (task 42, 5-test checklist) and Agent-KB refusal checks (task 309, work/unknown project refusal — the cross-machine half is already confirmed on velox+ratio). + +Pre-flight on wrap-teardown plumbing: Stop hook wired in settings.json, hook script present+executable, all three companion functions (cj/ai-term-quit, -live-count, -shutdown-countdown) live in the daemon (t t t). Four live aiv-* sessions right now (aiv-_emacs_d, aiv-archsetup, aiv-rulesets [this], aiv-work). Read the hook + wrap-it-up Teardown mode + the three functions: cj/ai-term-quit is a safe idempotent no-op on a nonexistent project; shutdown-countdown aborts when >1 session live. So the gate + hook-wiring pieces are safely verifiable from this session without endangering it; only the buffer-teardown/geometry-restore and the countdown render/C-g need Craig's scratch session + eyes. diff --git a/.ai/workflows/INDEX.org b/.ai/workflows/INDEX.org index a474b29..88721ed 100644 --- a/.ai/workflows/INDEX.org +++ b/.ai/workflows/INDEX.org @@ -54,6 +54,11 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e - Roam-mode triggers: "inbox zero", "empty the inbox", "process the roam inbox", "triage my roam inbox" - Auto-mode trigger: "auto inbox zero" (match before "inbox zero") +- =work-the-backlog.org= — the autonomous task-execution loop, the single home for working a batch of marked tasks unattended: takes an ordered task set (explicit list or tag query) + session mode (=file-only= default / =autonomous-commit= + paging) + a hard run cap; each candidate passes the mechanical eligibility gate (status =TODO= + =:solo:= per the project's scheme header) and the four-item defer checklist, then is implemented to the full quality bar (TDD, =/review-code=, =/voice=) as its own logical commits. Fed by the inbox auto-loop's chain step (yes-gated, file-only, cap 1) and the no-approvals speedrun preset (pre-flight Q&A → autonomous-commit + always-push + end-of-set page over an explicit ordered list). + - Speedrun triggers: "speedrun", "no approvals speedrun", "speedrun these: <task set>" — any phrase containing "speedrun" routes here (the preset), never to =no-approvals.org= + - Manual triggers: "work the backlog", "work the backlog with <task set>" (file-only defaults) + - Synthesis trigger: "synthesize backlog metrics" — read the per-project metrics logs, compute trends + the corrections signal, write one =:agent:metrics:= KB node (personal projects only) + ** Calendar - =add-calendar-event.org= — create a calendar event. @@ -117,6 +122,7 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e - Triggers: "session harvest", "harvest the sessions", "let's run the session-harvest workflow", "monthly harvest", "mine the sessions" - =no-approvals.org= — drop the interaction-level approval gates for a pre-agreed batch while keeping engineering-discipline gates (=/review-code=, =/voice personal=, tests, session-log updates, subagent reviews, destructive-action consent). Mode stays on until Craig turns it off, a real question arises, the queue empties, or the conversation switches topics. - Triggers: "no-approvals mode", "no approvals", "no-approval", "no need for approval gates", "stop asking, just keep going", "I'll check back in when you're done or stuck", "do all =<selector>= with no-approval" + - Exception: any phrase containing "speedrun" routes to =work-the-backlog.org='s no-approvals speedrun preset instead * Living Document 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/inbox.org b/.ai/workflows/inbox.org index 5fc855f..acfd11d 100644 --- a/.ai/workflows/inbox.org +++ b/.ai/workflows/inbox.org @@ -114,6 +114,14 @@ The item extends a task already filed. Update the parent TODO's body with a date ** File as TODO Substantive but waits, or needs design/triage before implementation. Add the TODO under =* <Project> Open Work= with priority + tags per the priority-scheme check (core §6). Body summarizes the proposal and links the inbox content if it's been moved to =docs/design/=. Delete the inbox file (or move it to =docs/design/= first if the content survives). +*Route-candidate marking (feeds the wrap-up router).* After filing, check whether the keeper's inferred home is a different project: + +#+begin_src bash +python3 .ai/scripts/route_recommend.py --item "<the keeper's heading + body text>" --exclude "$(basename "$PWD")" +#+end_src + +On a =<destination>\tstrong= or =<destination>\tweak= result, stamp the new TODO's property drawer with =:ROUTE_CANDIDATE: <destination>= (create the drawer if the task has none). A =none= result stamps nothing, and a local keeper stays unstamped. The marker is the wrap-up router's entire candidate set — =wrap-it-up.org= Step 3 surfaces exactly the =:ROUTE_CANDIDATE:=-tagged tasks and offers to deliver each to its destination's inbox, never scanning the standing backlog. Stamping is cheap and reversible (the router's skip leaves the task in place; a wrong marker is one property line to delete), so prefer stamping on any plausible match — the human reviews the batch at wrap time. + *Blocking-dependency handoff.* A special shape: another project sends a note that *this* project's work is blocking one of theirs ("your task X is blocked on us — we need Y"). File or link the owning task, tag it =:blocker:=, and name the requesting project in the body (see the cross-project dependency convention in =todo-format.md=). The =:blocker:= tag makes =open-tasks.org= surface that task *first*, since clearing it unblocks the other project. Dedup against an existing task rather than filing a duplicate. When the work later lands, drop =:blocker:= and notify the waiting project (=inbox-send <their-project> --text "Delivered: <what> — you're unblocked."=) so it can lift its own =:blocked:=. ** Defer @@ -253,7 +261,7 @@ The gap it closes: handoffs that arrive mid-session used to sit unseen until the Never begin monitoring on a dirty worktree or a failing test suite. A dirty tree means the auto-commit at the end of an executed item sweeps up unrelated changes; a red suite means you can't tell whether the monitor broke something. At the start: -1. =git status --porcelain= is empty (clean worktree). +1. =git status --porcelain --untracked-files=no= is empty (no tracked modifications). Untracked and gitignored files never block — an inbox drop is exactly what this mode processes, and a scratch file is none of its business (the template-freshness policy in =startup.org= Phase A.0). The tracked-only gate is safe because the per-item commit stages its files explicitly (=commits.md=: only intended changes staged) — never =git add -A=, which would sweep untracked files and is the failure this gate guards against. 2. A full test run is all green (=make test= here, or the project's full-suite command). If *dirty*: offer to commit the pending changes in discrete, logical batches before starting. If *red*: offer to investigate the failures first. Surface the blocker with inline numbered options per =interaction.md= and wait — monitoring does not start until the tree is clean and the suite is green. @@ -338,7 +346,7 @@ Close the loop per the reply-to-sender discipline (core §4): confirm what lande End the way it started: clean worktree, green suite. Before stopping the loop or reporting the pass done: -1. Commit or revert everything left in the worktree — nothing uncommitted remains. +1. Commit or revert every tracked modification left in the worktree — no tracked change remains uncommitted. Untracked files (unprocessed inbox drops, scratch) are not the monitor's to sweep. 2. Run the full test suite once more and confirm all green. If either can't be satisfied — a half-done item, a failure introduced during the pass — surface it rather than leaving it. The next monitor run assumes a clean, green starting state (the Preconditions gate). @@ -453,18 +461,19 @@ Take these up when the single-destination version is in use and the multi-projec * Mode: auto inbox zero -A recurring, *interactive* roam check. Trigger phrase: "auto inbox zero" (match before "inbox zero" — the longer phrase wins). On invocation, *ask Craig for the interval* (e.g. 30 min, 2 hours), then drive the loop with =/loop <interval>= running roam mode. It is in-session and interactive by design — each cycle reports, and a find waits for Craig's go before any work happens. +A recurring, *interactive* roam check. Trigger phrase: "auto inbox zero" (match before "inbox zero" — the longer phrase wins). On invocation, *ask Craig for the interval* (e.g. 30 min, 2 hours), then drive the loop with =/loop <interval>= running roam mode. It is in-session and interactive by design — each cycle reports what it found and filed. ** Per cycle 1. Run roam mode's scan (Phase A local check + Phase B roam scan), read-only — no =git pull=. The capture-guard still gates any write: use =capture-guard --wait= (core §5) so a transient capture clears itself; if it's still open after the wait, *defer this cycle's roam reconcile to the next cycle* rather than surfacing — the loop cadence is the retry, and the filed items get swept next time. The rare write hands its git to =roam-sync= (roam Phase D). 2. *Nothing found* → no inbox summary. One acknowledgement line: =ran at HH:MM, nothing found=. Nothing else. The acknowledge-only-on-empty rule keeps a quiet inbox quiet. 3. *Items found* → summarize the found items, file them as tasks (roam Phase C), and *append them to a displayed queue* — the harness task list, via =TaskCreate= — so the queue accumulates across cycles. Then ask: "run this batch next?" - - *Yes* → launch into implementing the found items, each through the normal disposition ladder (core §3) + verify flow. + - *Yes* → chain into =work-the-backlog.org= as an explicit second step after routing completes: pass it the eligibility query over the queued items (status =TODO= + =:solo:= per the scheme header, priority-ordered), =file-only= mode, paging off, cap 1. The highest-priority eligible candidate runs; the rest wait for the next tick or a later yes. - *No* → they stay queued for a later go. + This mode never implements anything itself — routing ends here, and the execution loop lives in =work-the-backlog.org=, its one home. 4. *Cross-cycle dedup.* Subsequent cycles add only *newly-found* items to the same displayed queue, never re-surfacing what's already there. Dedup against the queue (the =TaskCreate= list), not against what's already been implemented — a find that was queued-but-not-yet-run must not reappear, and one already filed into =todo.org= is dropped by roam Phase C's status check. -A find is always surfaced and gated on Craig's yes; a quiet inbox produces only the timestamped acknowledgement. =auto inbox zero= is inherently in-session because its execute step waits for a yes. +A find is always surfaced and filed; execution happens only through the =work-the-backlog.org= chain and waits for Craig's yes. A quiet inbox produces only the timestamped acknowledgement. =auto inbox zero= is inherently in-session because its chain step waits for that yes. ** Fully-unattended pass (=/schedule=) — vNext, not v1 diff --git a/.ai/workflows/no-approvals.org b/.ai/workflows/no-approvals.org index 1efce82..9e1c894 100644 --- a/.ai/workflows/no-approvals.org +++ b/.ai/workflows/no-approvals.org @@ -22,6 +22,8 @@ Craig activates the mode with any of: - Queuing several tasks in =todo.org= followed by any phrase above - Any equivalent phrasing that signals he doesn't want to be re-asked between items +*Not this mode:* any phrase containing "speedrun" ("speedrun", "no approvals speedrun") routes to =work-the-backlog.org='s no-approvals speedrun preset — an autonomous batch over an explicit ordered task set, with a pre-flight Q&A, autonomous commits, always-push, and an end-of-set page. This mode is the general interaction-gate suspension for whatever work is already underway; the speedrun is the dedicated backlog-batch workflow. + Mode resets when: - Craig says approvals are back on 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/page-me.org b/.ai/workflows/page-me.org index 607ed51..8069830 100644 --- a/.ai/workflows/page-me.org +++ b/.ai/workflows/page-me.org @@ -5,9 +5,9 @@ * Overview -This workflow enables Claude to set timers and alarms that reliably notify Craig, even if the terminal session ends or is accidentally closed. Notifications are distinctive (audible + visual with alarm icon) and persist until manually dismissed. +This workflow enables Claude to set timers and alarms that reliably notify Craig, even if the terminal session ends or is accidentally closed. Notifications are distinctive (audible + visual with the blue info icon) and persist until manually dismissed. -Uses the =notify= command (alarm type) for consistent notifications across all AI workflows. +Uses the =notify= command (info type) for consistent notifications across all AI workflows. Info-level on purpose: the earlier alarm styling read as all-red urgency, and Craig's verdict was that a page "should be a persistent info notification" — noticeable, never crash-scary (2026-07-02). * Trigger Phrase @@ -63,8 +63,8 @@ Craig tells Claude when and why: Claude schedules the alarm using the =at= daemon with =notify=: #+begin_src bash -echo "notify alarm 'Page' 'Time to call the dentist' --persist" | at 3:30pm -echo "notify alarm 'Page' 'Meeting starts' --persist" | at now + 45 minutes +echo "notify info 'Page' 'Time to call the dentist' --persist" | at 3:30pm +echo "notify info 'Page' 'Meeting starts' --persist" | at now + 45 minutes #+end_src The =at= daemon: @@ -89,26 +89,26 @@ Craig dismisses the notification and acts on it. ** Setting Alarms -Use the =at= daemon to schedule a =notify alarm= command: +Use the =at= daemon to schedule a =notify info= command: #+begin_src bash # Schedule for specific time -echo "notify alarm 'Page' 'Meeting starts' --persist" | at 3:30pm +echo "notify info 'Page' 'Meeting starts' --persist" | at 3:30pm # Schedule for relative time -echo "notify alarm 'Page' 'Check the build' --persist" | at now + 30 minutes +echo "notify info 'Page' 'Check the build' --persist" | at now + 30 minutes # Schedule for tomorrow -echo "notify alarm 'Page' 'Call the dentist' --persist" | at 3:30pm tomorrow +echo "notify info 'Page' 'Call the dentist' --persist" | at 3:30pm tomorrow #+end_src ** Notification System -Uses the =notify= command with the =alarm= type. The =notify= command provides 8 notification types with matching icons and sounds. +Uses the =notify= command with the =info= type. The =notify= command provides 8 notification types with matching icons and sounds. #+begin_src bash -# Immediate alarm notification (for testing) -notify alarm "Page" "Your message here" --persist +# Immediate page notification (for testing) +notify info "Page" "Your message here" --persist #+end_src The =--persist= flag keeps the notification on screen until manually dismissed. All page-me notifications should use =--persist= by default. @@ -139,10 +139,10 @@ The alarm must fire. Use the =at= daemon which is designed for exactly this purp Simple invocation - Claude runs one command. No complex setup required per alarm. ** Fail Audibly -If the alarm fails to schedule, report the error clearly. Don't fail silently. +If the page fails to schedule, report the error clearly. Don't fail silently. ** Testable -The =notify alarm= command can be called directly to verify notifications work without waiting for a timer. +The =notify info= command can be called directly to verify notifications work without waiting for a timer. ** Non-Alarming Use normal urgency, not critical. The notification should be noticeable but not imply something has gone horribly wrong. diff --git a/.ai/workflows/spec-create.org b/.ai/workflows/spec-create.org index 508b969..1249181 100644 --- a/.ai/workflows/spec-create.org +++ b/.ai/workflows/spec-create.org @@ -82,8 +82,9 @@ This is where the spec earns a "Ready" from review: an engineer must be able to ** Phase 5 — Wire it up (conventions) -- *Filename + location:* =docs/<problem-slug>-spec.org=. Org-mode. The slug names the *problem/feature*, not a date. Must end in =-spec.org=. -- *Metadata header:* a small table at the top — Status, Owner, Reviewer(s), Date, Related (link to the task/ticket). +- *Filename + location:* =docs/specs/YYYY-MM-DD-<problem-slug>-spec.org= — formal specs live in =docs/specs/=, never =docs/design/= (that's for notes, brainstorms, inventories; see =claude-rules/docs-lifecycle.md=). Org-mode. The slug names the *problem/feature*; no status suffixes ever — status lives in the file. Must end in =-spec.org=. +- *Status heading (first element after the file header):* a top-level heading carrying the lifecycle keyword, stamped =DRAFT= at authoring — spec-create owns this flip. It holds an =:ID:= UUID (generate with =uuidgen=) and dated history lines, newest first. The keyword is authoritative; the Metadata =Status= field mirrors it in lowercase. Transitions are three lines in one file (keyword + history line + mirror): spec-review flips =READY=, spec-response flips =DOING= at decomposition, the final build task flips =IMPLEMENTED=. Terminal states always record a reason. +- *Metadata header:* a small table at the top — Status (the lowercase mirror), Owner, Reviewer(s), Date, Related (link to the task/ticket). - *Review-and-iteration-history stub:* add a =Review and iteration history= section at the bottom and seed it with the author's first entry. =spec-review= and =spec-response= append provenance entries here, so the heading shape is a contract: =YYYY-MM-DD Day @ HH:MM:SS -ZZZZ — Contributor — Role=, body fields What / Why / Artifacts. - *Cross-link both ways:* the spec links its task; the task links the spec (replace the task's inline plan with a terse description + a =file:= link to the spec). @@ -103,7 +104,14 @@ Then it's ready for =spec-review.org=. Snapshot-vs-living rule: keep the spec li ,#+TITLE: <Feature> — Spec ,#+AUTHOR: <author> ,#+DATE: <YYYY-MM-DD> -,#+TODO: TODO | DONE SUPERSEDED CANCELLED +,#+TODO: TODO | DONE +,#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +,* DRAFT <spec short name> +:PROPERTIES: +:ID: <uuid — generate with uuidgen> +:END: +- <YYYY-MM-DD Day @ HH:MM:SS -ZZZZ> — drafted. ,* Metadata | Status | draft | diff --git a/.ai/workflows/spec-response.org b/.ai/workflows/spec-response.org index de5b1c8..7628e49 100644 --- a/.ai/workflows/spec-response.org +++ b/.ai/workflows/spec-response.org @@ -130,9 +130,11 @@ When related specs were reviewed together, two reviews can recommend opposite th This is the *last* step of the workflow, and it runs *only after the author confirms the spec is Ready* — never during review iterations. A Ready spec nobody can act on is unfinished; this phase turns it into tracked work. It applies to every project type (library, application, service, docs set). -1. *Decide where the tasks live.* If the work is spinning off into its own project/repo, move the parent task into that project's =todo.org= (and relocate the spec with it); otherwise use the current project's =todo.org=. One parent task owns the effort; the phase tasks hang under it. +*This phase owns the =READY= → =DOING= lifecycle flip* (docs-lifecycle convention): when the decomposition below lands, update the spec's top-level status heading keyword to =DOING=, add a dated history line, and set the Metadata =Status= mirror to =doing= — three lines, one file. -2. *Create one task per implementation phase* from the spec's =Implementation phases=, in dependency order, so the task set as a whole describes the *full* milestone (e.g. v1) with no gaps. Each task body names the deliverable, its tests, and how it is verified. Carry over deferred/vNext work and any publish/release steps as their own tasks. +1. *Decide where the tasks live.* If the work is spinning off into its own project/repo, move the parent task into that project's =todo.org= (and relocate the spec with it); otherwise use the current project's =todo.org=. One parent task owns the effort; the phase tasks hang under it. *Stamp the binding:* the parent task's =:PROPERTIES:= drawer gets a =:SPEC_ID:= line holding the spec's status-heading UUID. That property is the durable join task-audit uses to police =DOING= specs (a =DOING= spec whose bound parent is closed, archived, or missing gets flagged). + +2. *Create one task per implementation phase* from the spec's =Implementation phases=, in dependency order, so the task set as a whole describes the *full* milestone (e.g. v1) with no gaps. Each task body names the deliverable, its tests, and how it is verified. Carry over deferred/vNext work and any publish/release steps as their own tasks. *Always end the set with the flip task:* a final "flip the spec to IMPLEMENTED (+ dated history line + mirror)" task under the same parent — the tracked obligation that closes the lifecycle loop when the build finishes. Never skip it; "a human remembers" is the failure mode this exists to prevent. 3. *Turn a critical eye on completeness.* Re-read the spec — every phase, every acceptance criterion, every named deliverable, every data-safety/principle rule — and confirm each has a home in a task. The work is not done when the tasks merely exist; it is done when nothing in the spec is left untracked. This completeness pass is mandatory regardless of project type. diff --git a/.ai/workflows/spec-review.org b/.ai/workflows/spec-review.org index 833dfc9..d4998eb 100644 --- a/.ai/workflows/spec-review.org +++ b/.ai/workflows/spec-review.org @@ -50,6 +50,11 @@ Run it *early* — design review exists to catch viability problems and costly m Before Phase 1, verify the file under review ends with =-spec.org=. Every design, decision, or planning document under a project's =docs/= directory carries that suffix as its identifier. The =.org= extension alone is not enough because =docs/= holds non-spec org files too (tutorials, frozen inventories, reference material). +*Location expectation (docs-lifecycle convention).* Formal specs live in =docs/specs/=. Whether that's enforced depends on whether the project has run its one-time =spec-sort= retrofit: + +- =:LAST_SPEC_SORT:= present in =.ai/notes.org= Workflow State → the project has sorted; a =-spec.org= file outside =docs/specs/= fails this precondition. Surface it: "this spec sits outside docs/specs/ — move it (and update inbound links) before review." +- Marker absent → legacy locations (=docs/= root, =docs/design/=) stay reviewable; add one nudge line to the review output ("this project's docs pile has never been spec-sorted — say 'run spec-sort' to sort it") and proceed. No legacy spec is ever unreviewable during the transition. + If the file does not end with =-spec.org=, stop immediately and surface the mismatch: #+begin_example @@ -128,6 +133,7 @@ Work the spec against these. Each is a source of concrete findings, not a box to - *Performance & scale.* Expected counts (issues/comments/labels/teams/projects/views)? Server-side filtering where possible? Bounded, visible pagination? Cached name→ID lookups? Sync calls in the command path acceptable? Could a save hook or whole-file scan make N network calls? Rendering linear? Full-file rewrites avoided? Long-running operations async/cancellable/observable? Is concurrency/queueing/backpressure defined? Are high-output process filters throttled and cheap? Is progress/ETA exposed only when defensible, and are hung/stalled operations detectable and killable? Identify UI freezes, repeated network calls, unbounded pagination — without premature optimization. - *Security & privacy.* API keys safe? Debug logs leaking secrets or private issue text? Confirmations before mutating shared workspace objects? Personal vs shared distinguished? Local files holding sensitive descriptions/comments? Anything to redact from messages/logs? Any work-tracker integration may handle private company data. - *UX & accessibility.* Discoverable commands? Recoverable mistakes? Prompts ordered to the task? Safe, useful defaults? Informative-not-noisy status messages? Does the UI avoid implying unsupported actions are supported? Match the upstream product's permissions/concepts? Are customizations named in user language, with clear defaults and docstrings? For Emacs packages, command names, completion candidates, buffer layout, defcustom names, and message wording *are* the UX. +- *Operational-panel UI traps.* Applies when the spec covers a user-facing panel, dialog, or control surface; skip otherwise. Lists that mix saved, current, and generated items must name each item's source. Refresh or scan actions must not gate data that could be shown immediately. Add-forms must not ask the user to retype values the system already discovered. Destructive confirmations read in future tense before the action and verified-result tense after it. Diagnostics, performance, logging, and repair affordances are reviewed as one coherent flow before extra pages or buttons are added. A popup launched from a bar, tray, or tool surface should visually belong to that launcher. (Promoted from archsetup's Waybar network-panel review, 2026-06-30.) - *Test strategy and coverage.* Characterization tests before behavior changes? Pure functions to unit-test? API responses needing fixtures? Command flows needing stubs? Regression tests for prior bugs? Boundary/error cases? What's covered elsewhere and shouldn't be re-tested? Which existing tests must change? How is coverage generated, summarized, and used to find untested/refactor-worthy code? Prefer tests that lock contracts: representation shape, query compilation, sync no-op, conflict refusal, pagination, dirty-buffer protection, log redaction, and long-running/slow-operation behavior via fakes rather than flaky live dependencies. - *Observability & operations.* How does a user see what the package is doing? Progress messages for long ops? Useful, safe debug logging? Are logs structured enough to isolate issues from a bug report? Are commands provided to inspect/clear caches, test connectivity, diagnose backends/tools, copy redacted debug info, or reproduce command invocations? How are terminal states discovered: completion, failure, partial success, stalled/hung, cancelled, cleanup-unverified, and "needs user action"? Does the product notify only when useful, avoid noisy success spam, and keep non-success states visible until acknowledged? For generated org files, headers should often carry source, filter/view name, refresh time, count, truncation state. - *Comparable-product sentiment.* When there are obvious adjacent products, research what users love and hate about them from official docs plus current community reports. Do not cargo-cult their feature set; translate findings into the spec's scope. For each loved behavior, say whether the spec provides it, intentionally omits it, or defers it. For each hated behavior, say whether the spec avoids, resolves, inherits, or accepts it. @@ -166,6 +172,8 @@ Assign one label consistently: The most useful reviews move a spec from =Not ready= to =Ready with caveats= or =Ready= once decisions are captured. +*The =Ready= verdict flips the spec's lifecycle status.* spec-review owns the =DRAFT= → =READY= transition (docs-lifecycle convention): on assigning =Ready= (or =Ready with caveats= the author accepts), update the spec's top-level status heading keyword to =READY=, add a dated history line under it naming the review that passed, and set the Metadata =Status= mirror to =ready= — three lines, one file. Any other rubric label leaves the keyword where it stands (a re-review that finds new blockers on a =READY= spec demotes it back to =DRAFT= the same three-line way, with the reason in the history line). + Finding severity maps to blocking power: *high-priority findings block =Ready=* — they hold the rubric at =Not ready= (or =Ready with caveats= if the author accepts and tracks them) until dispositioned; *medium-priority findings are the author's discretion* and don't block. State the blocking status on each finding so the author running spec-response knows which ones gate the rubric. Then update the spec's review history. Specs should carry a bottom section named =Review and iteration history= (or the nearest existing equivalent) that tracks each material author/reviewer pass. Add a concise entry for this review even when the spec is ready and no findings are recorded. diff --git a/.ai/workflows/startup.org b/.ai/workflows/startup.org index 5e8f61e..47a77c8 100644 --- a/.ai/workflows/startup.org +++ b/.ai/workflows/startup.org @@ -44,6 +44,8 @@ Behavior: - *Dirty working tree* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start). - *Non-fast-forward history* → =--ff-only= aborts with an error. Surface that to the user; the rsync still proceeds against the working tree as-is. +*Template-freshness policy (applies to every dirty-check in the synced workflows).* "Dirty" means *tracked modifications only*. Untracked and gitignored files — an inbox drop, a file left in the tree to read, scratch output — never block a template pull, a fast-forward, or a monitoring gate. Projects were falling behind on templates because somebody sent them a task; that's the failure this policy closes. The checks here already comply (=git diff --quiet HEAD= sees only tracked changes; the ff gate uses =--untracked-files=no=), and any dirty-check added to a synced workflow follows the same rule. One deliberate exception: the rsync WIP-guard below counts untracked files *within rulesets' own synced source paths*, because an untracked half-written template is exactly the WIP it exists to hold back — that guard is about rulesets' outbound content, not the consuming project's local state. + *** Install rulesets symlinks into ~/.claude (idempotent) A skill, rule, or bin script added to rulesets and pushed reaches each machine's *files* on the next pull, but not its =~/.claude= *symlink* — =make install= only links what isn't already linked, and =git pull= doesn't run it. So a newly-added skill stays silently uninstalled until someone re-runs =make install= by hand. The flush skill sat in that gap from 2026-06-02 until a manual install on 2026-06-05. Running =make install= here, right after the rulesets pull, closes it: "add a skill, commit, push" becomes enough for it to reach every machine on the next session. @@ -151,7 +153,7 @@ These calls have no dependencies on each other. Issue them all together in one m 8. =[ -f todo.org ] && .ai/scripts/task-review-staleness.sh todo.org 7 || true= — count top-level tasks overdue for review (the daily task-review habit's startup nudge). The =[ -f todo.org ]= guard skips projects without a root todo.org; =|| true= keeps Phase A from failing if the script isn't synced yet. Threshold 7 days is one review cycle of slack — softer than the wrap-up health check's 30-day alarm. 9. =bash ~/code/rulesets/scripts/sync-language-bundle.sh "$PWD" 2>/dev/null || true= — language-bundle freshness for the current project. Fingerprint-detects which bundle (if any) the project has, auto-fixes drifted rulesets-owned files (=.claude/rules/*.md=, =.claude/hooks/*=, =githooks/*=), and surfaces drift in =settings.json= without writing it (a project may have customized it). =CLAUDE.md= is deliberately left untracked — it's seed-only in =install-lang= and project-owned afterward, mirroring how =diff-lang= skips it. Quiet when there's no bundle or everything's clean. Hardcodes the rulesets path because =languages/= is the canonical source and lives only there — the same absolute-path dependency the rsyncs already carry. =|| true= keeps Phase A from failing on older checkouts where the script isn't present yet. The =.ai/= rsyncs and this call write to disjoint paths (=.ai/= vs =.claude/=/=githooks/=), so the batch stays parallel-safe. 10. =[ -f "$HOME/org/roam/inbox.org" ] && grep -cE '^\*\* ' "$HOME/org/roam/inbox.org" || true= — count items in the roam global inbox (=~/org/roam/inbox.org=), the roam-mode startup nudge. Silent if the roam clone isn't on this machine. Phase C reads the file when the count is non-zero, splits total vs items related to this project, and surfaces the offer (see =inbox.org= roam mode). Read-only; never files at startup. -11. KB surface prep (the read + contribute startup nudges; see =docs/design/2026-06-16-encourage-kb-contribution-spec.org=). Gated on the agent KB clone. Counts =:agent:= nodes, lists up to 5 whose content matches the current project basename (titles only; a few most-recent nodes as a fallback when nothing matches), and resolves the best-practices node path. Read-only; silent when the clone is absent. Phase C surfaces the relevant titles (consult) and the best-practices link (contribute). +11. KB surface prep (the read + contribute startup nudges; see =docs/specs/2026-06-16-encourage-kb-contribution-spec.org=). Gated on the agent KB clone. Counts =:agent:= nodes, lists up to 5 whose content matches the current project basename (titles only; a few most-recent nodes as a fallback when nothing matches), and resolves the best-practices node path. Read-only; silent when the clone is absent. Phase C surfaces the relevant titles (consult) and the best-practices link (contribute). #+begin_src bash ra="$HOME/org/roam/agents" @@ -166,6 +168,25 @@ These calls have no dependencies on each other. Issue them all together in one m fi #+end_src +12. Spec-sort probe (the docs-lifecycle retrofit nudge; see the docs-lifecycle spec in =docs/specs/=). Read-only; prints one line when the project has an unsorted docs pile — a =docs/design/= directory or stray =docs/*-spec.org= root files — and no =:LAST_SPEC_SORT:= marker in =.ai/notes.org=. Silent for projects with nothing to sort or an already-stamped marker (the marker permanently clears it). + + #+begin_src bash + { [ -d docs/design ] || [ -n "$(find docs -maxdepth 1 -name '*-spec.org' -print -quit 2>/dev/null)" ]; } \ + && ! grep -qs ':LAST_SPEC_SORT:' .ai/notes.org \ + && echo "spec-sort: unsorted docs present" || true + #+end_src + + The stray-root check uses =find= rather than a glob so the probe behaves identically under bash and zsh (=compgen= is bash-only, and zsh aborts on an unmatched glob). + +13. Host-identity probe (see the host-identity rule in =claude-rules/=). Read-only; flags fixed machine-identity claims in the project's tracked/synced docs — the "This machine is ratio" trap, false on every machine but the one that wrote it. Silent when nothing matches. + + #+begin_src bash + grep -inE '\b(this|the current) (machine|host|box|laptop|workstation) is ' \ + CLAUDE.md .ai/notes.org 2>/dev/null | head -3 || true + #+end_src + + Fleet descriptions ("the fleet is ratio and velox") and runtime derivations ("run =uname -n= to find the hostname") don't match — only current-identity assertions do. Fixture-verified under bash and zsh. + Notes on the rsync commands: - Trailing slashes on both source and destination matter — they tell rsync to sync /contents/ rather than nest a directory inside. - =--delete= on the directory syncs lets retired template files actually disappear from each project on next startup. @@ -199,6 +220,8 @@ This phase touches the user and runs sequentially: - *Roam inbox nudge.* If the Phase A roam-inbox count is greater than zero, read =~/org/roam/inbox.org=, split total vs items related to this project (claimed by the =<project>:= prefix, plus any unprefixed item whose topic plainly concerns this project), and surface one line: "Roam inbox: =<N>= total, =<M>= appear related to this project — say 'inbox zero' to file them." Offer it as a priority option; never auto-file. If the count is zero or the file is absent, say nothing. See =inbox.org= roam mode. - *KB consult nudge (read side).* If the Phase A KB-surface prep returned any =kb-relevant-titles=, surface one line listing them (capped 5): "KB lessons that may be relevant: =<title>=; =<title>=… — open the node before related work." The titles are declarative, so the list alone tells you whether to open one. Gated on the roam clone; silent when the clone is absent or nothing relevant surfaced. See the best-practices node and =knowledge-base.md=. - *KB contribute nudge (write side).* Once per session, surface one line pointing at the best-practices node (the =kb-bestpractices= path from Phase A): "Learned something durable? See =<path>= for how to write a KB node — contributing cross-project facts is welcome (personal projects only; work/unknown projects never write per =knowledge-base.md=)." Light encouragement, never a gate. Gated on the roam clone; silent when absent. + - *Spec-sort nudge.* If the Phase A spec-sort probe printed =spec-sort: unsorted docs present=, surface one line: "this project's docs pile has never been spec-sorted — say 'run spec-sort' to sort it." If the probe was silent, say nothing. A project with nothing to sort never sees the line; a stamped =:LAST_SPEC_SORT:= marker permanently clears it. See the docs-lifecycle rule and the spec in =docs/specs/=. + - *Host-identity flag.* If the Phase A host-identity probe printed any match, surface it with the file:line and the fix: "this doc asserts a fixed machine identity — false on every other machine; replace with a runtime derivation (run =uname -n=), per the host-identity rule." The probe flags for judgment, never blocks. Silent when the probe is silent. - *Language-bundle sync.* If the Phase A step-12 call (=sync-language-bundle.sh=) printed anything, surface it. =fixed= lines are informational — the drift was already repaired (note that =.claude/= is now dirty if the project commits it). A =drift= line on =settings.json= is surface-only and needs the printed =make install-<lang> PROJECT=.= to reconcile; flag it so the user can decide. If the call was silent, say nothing. - *Newly-installed symlinks.* If the Phase A.0 =make install= step printed any =link= / =relink= / =WARN= line, surface it. A =link= line means a skill, rule, hook, or script added to rulesets is now linked into =~/.claude= for the first time on this machine. For a newly-linked *skill*, check the agent's available-skills list: if the harness already registered it mid-session, note it's available and move on; if it's absent, stop and tell Craig to restart the agent so it loads (whether a mid-session reload works is harness-version-dependent). For a newly-linked *hook*, note that the harness reads hooks at session start — it fires from the next session (or after Craig opens =/hooks= once); its settings.json wiring travels with the tracked file, so the link is usually the only missing piece. A =WARN ... not a symlink= line is a real collision at the target path — surface it; it needs a human. If the step printed only "nothing new to link", say nothing. - *Template-sync churn (safety net).* Check whether Phase A's rsync left uncommitted churn in the synced =.ai/= paths — accumulated from a prior session that crashed before wrap-up, or freshly added this session when rulesets advanced. Without surfacing, it builds up silently until it blocks Phase A.0's auto-ff (git won't ff a dirty tree). Skip in the rulesets repo itself (there =.ai/= is a committed mirror, kept honest by the pre-commit hook). The check is sequential here, after the rsync has finished — not a Phase A step, to keep that batch race-free. diff --git a/.ai/workflows/task-audit.org b/.ai/workflows/task-audit.org index 94b99da..7d2b758 100644 --- a/.ai/workflows/task-audit.org +++ b/.ai/workflows/task-audit.org @@ -61,6 +61,8 @@ For each open task, read its body and cross-check its claims against the actual - *Calendar* — did a scheduled event happen; is a SCHEDULED/DEADLINE date now past. - *Meeting recordings* — if a task hinges on "did this conversation happen / what was said," check the recording queue (e.g. =~/sync/recordings/=) and transcribe via =process-meeting-transcript.org= if the answer lives in an un-transcribed recording. (This is exactly how a "did the interview happen?" task gets resolved instead of guessed.) +*Spec lifecycle reconcile (docs-lifecycle convention).* If the project has a =docs/specs/=, run the =:SPEC_ID:= query as part of this phase: for each spec whose top-level status heading reads =DOING=, find the =todo.org= task whose =:SPEC_ID:= property matches the spec's =:ID:=. Flag the spec NEEDS-USER when that bound parent is =DONE=/=CANCELLED=, archived, or missing — the build finished (or evaporated) without the =IMPLEMENTED= flip, exactly the drift this check exists to catch. Check the parent's own keyword, not its children (completed children become dated entries and the final flip task is a child, so child-counting misleads). + Assign each task a bucket (CURRENT / STALE / NEEDS-USER) and, for STALE, the specific factual update. *Scale tactic.* For a large open-task set, dispatch read-only investigation sub-agents over batches of tasks (parallel-safe per =subagents.md= — independent read-only domains). Each returns a per-task bucket + suggested update. *Never* let sub-agents write to =todo.org= concurrently — apply all edits serially in the main thread (concurrent writes to one file race and lose work). @@ -79,7 +81,7 @@ For every STALE task, edit it in the main thread: - *Ensure priority is set per the project scheme.* The top of the project's =todo.org= should carry the priority legend (=[#A]= through =[#D]=). Every task should carry an explicit priority cookie. If a cookie is missing, or no longer matches the reconciled facts, assign the right level per the legend. If the level is unambiguous from the body, do it autonomously; if it's a judgment call (especially the [#A] / [#B] line for important-but-not-urgent work), flag NEEDS-USER. Also enforce the [#A]-discipline rule from the legend — an [#A] task without a =SCHEDULED:= or =DEADLINE:= line is mis-graded and is either down-graded to [#B] (when reconciled facts say "important but not urgent") or surfaced as NEEDS-USER for the user to date. - *Ensure a type tag is set.* Every task carries one type tag from the project's tag legend (typically =:feature:= / =:chore:= / =:spec:= / =:bug:=). If missing or wrong, assign or correct it from the body when the type is unambiguous. If two tags fit (a refactor that also fixes a bug; a spec that's also a chore), flag NEEDS-USER rather than picking one silently. - *Enforce the project's declared tag vocabulary.* If the project's tag legend declares an *exhaustive* set of allowed tags, strip from each task any tag outside that set — the heading and parent section already carry topic/scope context, so ad-hoc tags only fragment the vocabulary and defeat tag-based filtering. Normalize near-duplicate spellings to the canonical tag (a plural to its singular, say). Where the legend does not declare the set closed, leave existing tags alone; this step applies only where the allowed set is exhaustive by design. -- *Re-assess the =:quick:= and =:solo:= tags* — reconciliation can change a task's effort or autonomy: a resolved dependency may make a stuck task =:solo:=, a scope cut may make it =:quick:=, and new complexity surfaced by the sources can invalidate either. Add or remove the tags per the definitions in the project's tag legend (and [[file:task-review.org][task-review.org]]) when the reconciled facts make the call clear. When they don't — an effort estimate you can't pin down, a =:solo:= gate you can't confirm — it's a NEEDS-USER flag, not a guess. +- *Re-assess the =:quick:= and =:solo:= tags (mandatory — an audit that skips this is incomplete).* Reconciliation can change a task's effort or autonomy: a resolved dependency may make a stuck task =:solo:=, a scope cut may make it =:quick:=, and new complexity surfaced by the sources can invalidate either. Add or remove the tags per the hard definitions in [[file:../../claude-rules/todo-format.md][todo-format.md]] ("Hard definitions: :solo: and :quick:"; task-review carries the same three-gate walk). Autonomous execution reads =:solo:= as its eligibility gate and trusts the tag, so a stale one is a run-time hazard, not cosmetic drift. When the call isn't clear — an effort estimate you can't pin down, a =:solo:= gate you can't confirm — it's a NEEDS-USER flag, not a guess. - Bump =:LAST_REVIEWED:= on each edited task. Follow =todo-format.md= for completion mechanics (depth-based DONE vs dated-rewrite) and the working-files / link-hygiene rules when moving artifacts. @@ -99,6 +101,21 @@ Never merge or re-parent autonomously — which tasks belong together, and wheth When no clear cluster exists, say so in one line and move on — most audits won't find one, and forcing a merge fragments worse than it consolidates. +** Phase C.6 — Retire completed parents and promote stragglers (interactive) + +Phase C.5 consolidates related *open* tasks. This step retires parent tasks whose work is *finished*, so completed containers don't linger in Open Work as scaffolding. + +Run =todo-cleanup.el --convert-subtasks= first (it's part of the =clean-todo= / wrap-up cleanup, and =open-tasks.org= runs it too) so every completed sub-task is a dated event-log entry rather than a lingering =DONE= keyword. The closure logic below reads "open child" as a child heading still carrying a task keyword (=TODO=/=DOING=/=WAITING=/=VERIFY=/=NEXT=/=PROJECT=/=STALLED=/=DELEGATED=); a dated entry is correctly not open. + +Two shapes, both proposed to Craig (inline numbered options per =interaction.md=, no popup) before applying: + +- *Zero open children → close the parent.* A parent whose child *tasks* are all resolved (now dated) and that carries no open child task is finished: close it per =todo-format.md= (=**= parent → =DONE=/=CANCELLED= + =CLOSED:=), and it moves to Resolved on the next =--archive-done=. If the work resurfaces later, a fresh task is created then; a completed container shouldn't sit open as a placeholder. +- *One or two open children → promote, then close.* When a parent has only one or two open children, pull them out and rewrite them as standalone =**= level-2 tasks — give each a priority per the project scheme, and make the heading stand alone without the parent's context — then close the now-childless parent and let it move to Resolved. The former children become first-class Open Work tasks; the retired parent stops being scaffolding for one or two stragglers. + +*The leaf-with-notes carve-out (important).* "Zero open children" is not the same as "done." A =**= leaf task whose only descendants are dated *notes* — a captured "Ideas", "Goals", or "Current State" entry, not a real completed sub-task — is unstarted work with a note attached, not a finished container. Do not close it. Tell the two apart by intent: a container reads as a grouping (a =PROJECT= keyword, an explicit "parent grouping ..." line, or several dated entries that were genuinely separate sub-tasks that shipped); a leaf-with-notes is a single feature/bug task whose title names unstarted work and whose lone dated child is a design note. When the call is ambiguous, flag it NEEDS-USER rather than closing. + +Never close or promote autonomously past the ambiguous line — surface the candidates with a recommendation and let Craig ratify, the same interactive stance as Phase C.5. Clear container completions (a =PROJECT= whose every child is dated) can be proposed as a batch; leaf-with-notes ambiguities are flagged individually. Verify open-vs-done counts against the actual headings (a real scan of the subtree), not a fragile regex that a shell's =\b= support can silently break — a miscount here closes live work. + ** Phase D — Flag the judgment calls (interactive) Present the NEEDS-USER bucket as a short, scannable list — one line per task, naming the decision or the fact required. Adjudicate with the user one item at a time (inline numbered options per =interaction.md=, no popup). Apply the user's calls as they come (which may itself produce more autonomous updates, or new tasks). @@ -147,3 +164,7 @@ Two Phase C behaviors added, both surfaced by an Emacs-config =todo.org= audit: - *Tag-vocabulary enforcement.* That project declares a closed tag set (=bug=, =feature=, =refactor=, =test=, =quick=, =solo=); the audit had to strip ~44 ad-hoc tags that had accumulated across the file. The prior workflow only checked that a type tag was *present* — it had no concept of an exhaustive allowed set. The new bullet enforces a declared closed vocabulary and leaves open-vocabulary projects untouched. - *Code-complete-but-unverified closing.* Many tasks had shipped (tests green, live in the daemon) but stayed open awaiting a manual or visual verification, so they accumulated as half-open. Leaving them open is noise; auto-closing them would violate "never claim a fix verified before the user confirms." The fix routes the pending human check into the project's =Manual testing and validation= parent (dedup-checked) per =verification.md='s manual-verification hand-off, then closes the implementation task. The work is done and the check is tracked; a failed check promotes to a bug. + +** 2026-07-01 — Retire completed parents (Phase C.6) + +Added Phase C.6: retire a parent task once its child *tasks* are all done. Zero open children → close the parent; one or two open children → promote them to standalone level-2 tasks, then close. Surfaced by an Emacs-config =todo.org= audit where several PROJECT containers had all children complete. Depends on =todo-cleanup.el --convert-subtasks= running first so completed sub-tasks are dated (not lingering =DONE= keywords) and the open-child count is accurate. Carries a leaf-with-notes carve-out: a =**= leaf task whose only descendant is a dated design note ("Ideas"/"Goals") is unstarted work, not a finished container, and must not be closed — the ambiguous case is flagged NEEDS-USER. The step also warns against counting open-vs-done with a fragile regex (a =\b= that a given shell/awk silently drops miscounts and closes live work). diff --git a/.ai/workflows/task-review.org b/.ai/workflows/task-review.org index 69e172d..ba1571a 100644 --- a/.ai/workflows/task-review.org +++ b/.ai/workflows/task-review.org @@ -57,7 +57,9 @@ Keep is the common case — most tasks are still right and just need re-stamping *** Tagging =:quick:= — small tasks -While reviewing each task, estimate its effort. If you judge it *30 minutes or less* and it doesn't already carry =:quick:=, add the tag to the heading line. If the heading and body don't tell you how long it'll take, *ask Craig* — don't guess. A wrong =:quick:= is worse than none: the tag exists so Craig can grab a genuinely small task in a spare moment, and a mislabeled one wastes that moment. +The =:quick:= and =:solo:= assessments (this section and the next) are *mandatory* for every reviewed task except a Kill — a review that skips them is incomplete. The hard definitions live in [[file:../../claude-rules/todo-format.md][todo-format.md]] ("Hard definitions: :solo: and :quick:"); autonomous execution (work-the-backlog / the no-approvals speedrun) reads =:solo:= as its eligibility gate and trusts the author's tag, so the run-time gate is only as trustworthy as this pass. + +While reviewing each task, estimate its effort. If you judge it *30 minutes or less* and it doesn't already carry =:quick:=, add the tag to the heading line. If the heading and body don't tell you how long it'll take, *ask Craig* — don't guess. A wrong =:quick:= is worse than none: the tag exists so Craig can grab a genuinely small task in a spare moment, and a mislabeled one wastes that moment. =:quick:= is an effort hint only, never an eligibility gate — size does not decide what runs autonomously. This is orthogonal to the action chosen — a task can be kept (or re-graded, or marked DOING) *and* tagged =:quick:= in the same pass. Skip the assessment on a Kill, since it's leaving the pool. Tags go on the heading line per [[file:../../claude-rules/todo-format.md][todo-format.md]], sharing one =:tag1:tag2:= cluster. @@ -67,7 +69,7 @@ While reviewing each task, judge whether Claude could build *and* verify it with 1. *Buildable* — Claude has the capability and access to do the work. 2. *Verifiable by Claude* — an objective or local check exists that Claude can run itself. Craig's routine spot-checking does not count against this, and neither does handing off a residual human-in-the-loop confirmation as a structured manual-testing reminder (the =verification.md= "Handing Off Manual Verification" pattern). The disqualifier is having no verification path of Claude's own at all — when the success criterion is only judgeable by Craig's eyes or subjective taste. -3. *No upfront decision* — no design or preference call Craig must make before Claude can begin. +3. *No deliberation* — no open design question and no "weigh these approaches" with real tradeoffs. At most one or two *quick, upfront-answerable* factual decisions are allowed — the speedrun preset batches those into its pre-flight Q&A, so they don't break the hands-off run. A genuine design or preference call disqualifies. If any gate is shaky, leave the tag off. Like =:quick:=, a wrong =:solo:= is worse than none — it tells Craig he can hand the task off and walk away, so a mislabeled one wastes that trust. When the heading and body don't make all three gates clear, ask Craig instead of guessing. @@ -96,6 +98,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/work-the-backlog.org b/.ai/workflows/work-the-backlog.org new file mode 100644 index 0000000..b0666e7 --- /dev/null +++ b/.ai/workflows/work-the-backlog.org @@ -0,0 +1,263 @@ +#+TITLE: Work the Backlog +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-07-02 + +* Overview + +The single home for the autonomous task-execution loop: take a set of marked, solo-doable tasks from the project's =todo.org= and work them unattended, each held to the full quality bar, under a fixed safety contract. Spec: =rulesets/docs/specs/2026-06-16-autonomous-batch-execution-spec.org=. + +Two callers feed it, differing only in how they build the task set and which session mode they pass: + +- The *inbox auto-loop* (=inbox.org= auto mode) chains here after its routing completes, with a tag/priority query, file-only mode, cap 1. +- The *no-approvals speedrun* preset feeds an explicit ordered list with autonomous-commit + always-push + paging-on, after a pre-flight Q&A that front-loads every decision. + +This workflow owns the execution logic — eligibility gate, defer checklist, quality bar, run cap. Callers own input assembly and mode selection. Capture-routing (inbox surfaces) stays entirely in =inbox.org=; this file never reads an inbox. + +* When to Use This Workflow + +Invoked by its two callers, or directly by phrase: + +- *Speedrun triggers:* "speedrun", "no approvals speedrun", "speedrun these: <task set>" — run the no-approvals speedrun preset (below). The word "speedrun" always routes here, even when the phrase also says "no approvals": plain =no-approvals.org= is the general session mode; the speedrun is this workflow's preset over an explicit task set. +- *Loop caller:* =inbox.org= auto mode chains here after its routing (below). Not phrase-triggered. + +Manual fallback: "work the backlog" / "work the backlog with <task set>" — gather the three inputs below (ask for whichever are missing, defaulting to file-only mode; default cap is the list length for an explicit set, 1 for a query) and run the loop. + +* Inputs — the caller contract + +A caller hands this workflow three things: + +1. *A task set* — an ordered list of candidate task headings from the project's =todo.org=. Either an explicit ordered list (speedrun) or the result of a tag/priority query (the loop). The loop does not care how the set was assembled; it receives an ordered list of candidates. +2. *A session mode* — two orthogonal flags: + - *Commit autonomy:* =file-only= (default) or =autonomous-commit=. See "Commit autonomy" below. + - *Paging:* on or off. End-of-set only. +3. *A run cap* — the hard maximum number of tasks to complete this run. + +It returns a per-task outcome and a run summary. + +* Outcomes — the per-task vocabulary + +Every task in the set ends in exactly one of: + +- =implemented-committed= — implemented, committed (and pushed per the project's flow) under =autonomous-commit=. +- =implemented-diff-surfaced= — implemented, diff surfaced, *not* committed (=file-only=). +- =deferred-VERIFY= — a defer-checklist hit; a =VERIFY= filed naming what's missing or risky. +- =dropped-by-craig= — removed from the run at the speedrun pre-flight Q&A ("skip this"). +- =skipped-ineligible= — failed the mechanical eligibility gate. +- =failed= — implementation was attempted and abandoned: the tree is left working (never commit a broken state), the failure is surfaced in the run summary, and the run continues to the next task. + +The run summary lists each task with its outcome, plus the remaining set when the cap stopped the run. + +* The loop + +For the task set, in order, until the run cap is hit: + +1. *Eligibility gate* (below). Ineligible → record =skipped-ineligible=, next task. +2. *Scope read* of the relevant code. Cheap; just enough to run the defer checklist. +3. *Defer checklist* (below). Any hit → defer: file the =VERIFY= naming the gap and record =deferred-VERIFY= (or, under the speedrun preset, route a quick-question gap to the pre-flight Q&A), next task. +4. *Implement* under the project's commit discipline: TDD red→green→refactor, then =/review-code --staged=, fix all Critical/Important findings, then close the task per =todo-format.md='s completion rules. Decompose into as many logical commits as the change needs — size is not capped. If implementation fails partway, leave the tree working, record =failed=, surface it, and continue to the next task. +5. *Commit autonomy branch:* + - =file-only= → surface the diff, do *not* commit. Record =implemented-diff-surfaced=. + - =autonomous-commit= → =/voice personal= on the message, commit individually, push per the project's flow. Record =implemented-committed=. +6. *Record metrics* for the task (the JSONL append — see Metrics below). +7. Decrement the cap. At zero, stop. + +After the set: if the paging flag is set, fire the end-of-set page (below). Surface the run summary either way. + +* Eligibility gate — mechanical, no judgment + +A task is autonomous-safe when *both* hold. This layer is a lookup, not a judgment; all the judgment lives in the defer checklist. + +1. *Status is =TODO=* — never =VERIFY=, =DOING=, =DONE=, or =CANCELLED=. =VERIFY= marks "awaiting Craig's input"; auto-implementing one defeats the check it represents. The do-not-implement set is safe-by-omission: anything not plainly =TODO= (plus any project-declared "hold" marker) is out. +2. *Tagged =:solo:=* — the autonomy tag, resolved against the project's priority/tag scheme header in =todo.org= (never hardcoded). =:solo:= carries the hard definition in =todo-format.md=: completable and verifiable without Craig beyond at most one or two quick decisions answerable up front, no design deliberation. A project whose scheme declares a different autonomous-safe tag set overrides the default. + +Priority and =:next:= drive *ordering* within the eligible set, not eligibility ([#A] before [#B] before [#C], then the author's ordering). =:quick:= is an effort hint for batching and duration estimates — never a gate. + +Task *size* is deliberately absent from this gate. A large but well-specified, decision-free task is in scope and gets decomposed into per-logical-commit chunks during implementation. Size never sends a task away; only *deliberation* or *risk* does (the checklist below). + +*No scheme header → don't run.* The gate reads =:solo:= semantics from the project's scheme header; a =todo.org= without one leaves the tag undefined (=todo-format.md= makes the header mandatory). Surface that the header is missing and stop rather than guessing eligibility. + +* The defer checklist — act vs file + +After the scope read, run each eligible candidate through the checklist. Each item is a concrete, answerable question, not an adjective. *Any* hit — or any "unsure" — defers the task. Only a task that clears every item is implemented. + +1. *Test-writability (the keystone).* Can I write the failing test from the task text — plus any decisions gathered up front — without inventing a requirement? *No / unsure* → underspecified. Under the speedrun preset, if the gap is one or two quick answerable questions, route it to the pre-flight Q&A; otherwise file a =VERIFY= noting what's missing. Under the unattended loop, file the =VERIFY= (no one to ask). +2. *Data-loss / irreversible / external operation.* Does implementing it require any of: =rm= of non-scratch data, =git reset --hard= / force-push, =DROP= / =DELETE= / =TRUNCATE=, file truncate/overwrite of persisted content, a schema or data migration, any external or shared-state mutation, any credential touch? *Yes* → do NOT implement; file a =VERIFY= naming the risk. This is the hard safety gate; an upfront answer never overrides it without an explicit checkpoint. +3. *Already-satisfied.* Does the scope read show the desired end-state already holds? *Yes* → file a =VERIFY= noting it and move on. Don't make a no-op change. +4. *Design deliberation.* Does the task carry an unresolved design question, a "weigh these approaches" with real tradeoffs, or a TBD that isn't a quick factual answer? *Yes* → under the speedrun preset, if it collapses to one or two quick questions, route to the pre-flight Q&A; otherwise file and surface as a =/start-work= candidate. Under the loop, file. The discriminator is *quick-answerable question* vs *deliberation* — never task size. + +When genuinely unsure which side a task falls on, defer — a wrong auto-implement costs a revert *and* the next-session correction. + +** Filing the deferral =VERIFY= + +Every checklist hit files a =VERIFY= in the project's =todo.org=, per =todo-format.md='s VERIFY rules: + +- *Dedup first.* If a =VERIFY= sibling for this deferral already exists (a prior run filed it), don't file another — record the outcome as =deferred-VERIFY= with a "previously filed" note and move on. The deferred task keeps its =TODO= status and tags, so without this check every subsequent run would re-defer and re-file. +- *Placement:* sibling of the deferred task (the deferred task is the trigger) — a =**= task gets its =VERIFY= at =**=, a =***= sub-task gets it at =***= under the same parent, never deeper. +- *Heading:* carries the question or risk on its own ("VERIFY <topic> — migration touches persisted rows"). +- *Body:* which checklist item hit, what's missing or risky, and what answer or action would make the task runnable. For an already-satisfied hit, the evidence that the end-state already holds. + +** Routing a quick-question gap (speedrun only) + +Under the speedrun preset, a checklist-1 or checklist-4 hit that collapses to one or two quick answerable questions routes to the pre-flight Q&A instead of deferring (see the preset section below). The discriminator: a *quick question* is a factual or preference pick answerable in one line without weighing tradeoffs ("cap at 5 or 8?", "which config key name?"); *deliberation* is anything that needs tradeoffs weighed, options explored, or code read by Craig. A task needing three or more questions isn't quick-question-gapped — it's underspecified; file the =VERIFY=. Checklist item 2 (data-loss / irreversible) never routes to the Q&A: an upfront answer doesn't override the hard safety gate. + +The unattended loop has no one to ask — every hit defers there. + +* Per-task quality bar + +Autonomy changes who approves, not what quality means. Per task, non-negotiable: + +- *TDD* per =testing.md=: red first, green, refactor. The keystone checklist item already proved the failing test is writable. +- *Verification* per =verification.md=: fresh evidence, full suite green before any commit. +- *=/review-code --staged=* before every commit; Critical and Important findings block until fixed. +- *=/voice personal=* on every commit message on the =autonomous-commit= path (or the patterns walked inline if the skill is unavailable), message printed inline so the log shows what landed. +- *Task closure* per =todo-format.md=: depth-based completion (keyword + =CLOSED:= at level 2, dated rewrite at level 3+). +- *One logical change per commit.* A large task becomes several commits, not one omnibus. + +* Commit autonomy + +=file-only= is the default: surface the diff, never commit. =autonomous-commit= is honored only when the project carries the commit-autonomy waiver, read fresh each run — never from memory of past runs or "this project usually allows it." + +The waiver lives in the project's =.ai/notes.org= *Workflow State* section as marker lines, the same shape as the workflow markers already there: + +#+begin_example +:COMMIT_AUTONOMY: yes +:LOOP_MAY_COMMIT: yes +#+end_example + +- =:COMMIT_AUTONOMY: yes= — the project has the waiver. An =autonomous-commit= request (the speedrun preset, or a manual run asking for it) is honored. +- =:LOOP_MAY_COMMIT: yes= — the *unattended loop caller* may also commit. It requires =:COMMIT_AUTONOMY:= alongside it; the split exists because "Craig-initiated speedrun may commit" and "the recurring loop may commit unattended" are different levels of trust. Without this flag the loop stays =file-only= even when the project holds the waiver. + +An absent marker means no. Anything other than a plain =yes= value also means no. The read is one grep of the Workflow State section — a lookup, not a judgment. + +*The degrade contract.* When a caller requests =autonomous-commit= and the required marker is missing, degrade to =file-only= and surface it in both the run intro and the run summary: "autonomous-commit requested, no :COMMIT_AUTONOMY: waiver in notes.org — running file-only." Never honor the request without the marker, and never drop to file-only silently — the first commits into a project that didn't opt in, the second hides why nothing got committed. + +* Bounding the run + +The cap is a hard per-run task ceiling passed by the caller — the kill switch a runaway can't exceed: + +- *Loop caller default: 1.* Implement the highest-priority eligible candidate, record, stop; the next tick continues. +- *Speedrun: the length of the explicit list*, capped at a ceiling — the human bounded the set by naming it. + +Even the speedrun stops at the cap and surfaces (and, with paging on, pages) the remainder. The cap bounds task *count*, not cost; a token budget is logged as vNext. + +* Context hygiene — auto-flush between tasks + +Task boundaries are clean boundaries by construction: the previous task is closed and committed (or filed), nothing is half-edited. When the context window grows heavy mid-run, run the flush skill's *auto mode* between tasks: checkpoint the session anchor with the remaining task set, session mode, and cap in Next Steps (so the resumed context continues the run blind), arm the self-injection (=.ai/scripts/self-inject.sh= via =tmux run-shell -b=), and end the turn. The fresh context resumes from the anchor and works on. Unattended runs only — the keystroke-collision hazard and the full mechanism live in the flush skill. + +* End-of-set page + +With paging on, fire one page when the set is done or the cap is hit — end-of-set only, never per-task: + +#+begin_src sh +notify info "Page" "<project>: <N> done, <M> remaining — <one-line summary>" --persist +#+end_src + +=--persist= keeps it on screen until dismissed, and =info= is the page-me urgency convention (persistent but never crash-scary). The page fires when the set completes *or* the cap stops the run — either way exactly once. The message carries the project name, the completed count, and the remaining count (with skipped tasks noted in the run summary) so Craig can confirm ready and name the next project in one reply. There is no separate page-signal call — =notify= is the paging surface. + +* Metrics + +Each task outcome appends one JSON line to the project's =.ai/metrics/work-the-backlog.jsonl= — git-tracked, append-only, =jq=-queryable. Create the directory and file on the first append. Logging is a side effect only: a failed append surfaces a warning in the run summary but never blocks, reorders, or aborts execution. + +One record per task, written at the moment its outcome is decided: + +| Field | Meaning | +|--------------------+-------------------------------------------------------------------------------------------------| +| =ts= | ISO-8601 timestamp of the task outcome | +|--------------------+-------------------------------------------------------------------------------------------------| +| =run_id= | UUID shared by every record in one run (=uuidgen= at run start) | +|--------------------+-------------------------------------------------------------------------------------------------| +| =project= | project basename | +|--------------------+-------------------------------------------------------------------------------------------------| +| =caller= | =loop= / =speedrun= / =manual= | +|--------------------+-------------------------------------------------------------------------------------------------| +| =task= | the task heading (slug) | +|--------------------+-------------------------------------------------------------------------------------------------| +| =outcome= | =implemented-committed= / =implemented-diff= / =deferred-verify= / =skipped-ineligible= / | +| | =dropped-by-craig= / =failed= | +|--------------------+-------------------------------------------------------------------------------------------------| +| =defer_reason= | =underspecified= / =data-loss= / =already-satisfied= / =needs-deliberation= — set on | +| | =deferred-verify= records only | +|--------------------+-------------------------------------------------------------------------------------------------| +| =upfront_decision= | =true= when a pre-flight answer was recorded and used for this task | +|--------------------+-------------------------------------------------------------------------------------------------| +| =wall_clock_s= | seconds from task start to outcome | +|--------------------+-------------------------------------------------------------------------------------------------| +| =commit_sha= | committed tasks: the commit SHA (comma-separated when the task decomposed into several); empty | +| | otherwise | +|--------------------+-------------------------------------------------------------------------------------------------| +| =review_findings= | count of =/review-code= Critical + Important findings on this task | +|--------------------+-------------------------------------------------------------------------------------------------| + +The =outcome= slugs map one-to-one onto the outcome vocabulary above (=implemented-diff= is =implemented-diff-surfaced=; =deferred-verify= is =deferred-VERIFY=). Per-run rollups (attempted / completed / deferred / dropped, wall-clock total, findings per commit) are computed at synthesis, not stored per record. The =commit_sha= field is what the synthesis step's corrections signal keys on — whether a later commit reverted or hand-fixed an autonomous one — so never omit it on a committed task. + +* Caller: the inbox auto-loop + +=inbox.org= auto mode chains here as an explicit second step *after* its routing completes — never as a phase inside inbox processing. When a cycle files new items and Craig answers "run this batch next?" with yes, auto mode invokes this workflow with: + +- *Task set:* the eligibility query over the queued/filed items — status =TODO= + =:solo:= per the scheme header, priority-ordered. +- *Session mode:* =file-only=, paging off. (A project carrying both =:COMMIT_AUTONOMY:= and =:LOOP_MAY_COMMIT:= markers opts the loop into commits — see Commit autonomy above.) +- *Cap: 1.* The highest-priority eligible candidate runs, gets recorded, and the loop's next tick (or the next yes) continues from there. + +The loop has no human at kickoff of each task, so a needs-quick-decisions task defers with a =VERIFY= — the pre-flight Q&A is a speedrun capability, not a loop one. Startup and wrap-up never invoke this workflow. + +* Preset: the no-approvals speedrun + +The named preset is a label for one flag combination, not a second code path: *explicit ordered list + =autonomous-commit= + always-push + paging-on*, with every approval front-loaded into a single pre-flight step. "No approvals" means all input first, then hands-off — not no input ever. =autonomous-commit= still requires the =:COMMIT_AUTONOMY:= waiver (Commit autonomy above); without it the preset degrades to =file-only= and says so in the pre-flight intro. + +When Craig names a task set and says "speedrun": + +1. *Gather* the named task set. +2. *Scope-read and classify* each task against the eligibility gate + defer checklist: *ready* (clears everything), *needs-quick-decisions* (one or two upfront-answerable questions — checklist item 1 or 4), or *drop* (data-loss/irreversible, or deliberation that isn't a quick question). +3. *Order* the list — priority, then the author's ordering / =:next:=. +4. *Intro the work* — present the ordered plan: what will run, what was dropped and why, and the batched questions for the needs-quick-decisions tasks. +5. *Craig answers each question or says "skip this"* — a skip removes the task (recorded =dropped-by-craig=; the task itself stays =TODO=); an answer is recorded so implementation works from the decision, not a guess. +6. *Run the finalized list autonomously* — no further approvals until done. Cap = the list length (the human bounded the set by naming it), still one commit per logical change, always-push per the project's flow, auto-flushing between tasks when the context grows heavy (see Context hygiene above). +7. *End-of-set page* with completed + remaining + skipped. + +The batch-ask (step 4-5) is one message: each question names its task, puts the recommended answer at item 1 when there is one (per =interaction.md= — inline numbered, no popup), and offers "skip this" as the last option. Before the run starts, write each answer into its task's body in =todo.org= as a dated line — the implementation works from the recorded decision, and the record survives the session. The Q&A fires only under this preset; the loop caller never asks (its decision-needing tasks defer). + +*** Per-item disposition rule + +For every item the run picks up (this holds for any executing caller, including an auto-inbox-zero run given a standing yes): + +- *Feature-level task* → write a spec first (=spec-create=), don't implement directly. The spec is the run's deliverable for that item. +- *Needs decisions you can't confidently guess* → file it as a =VERIFY= carrying the question (under this preset, one or two quick questions route to the pre-flight Q&A instead). +- *Well-defined* → implement it, taking the time it needs. + +This extends the defer checklist: the checklist decides *act vs file*; this rule decides the *shape* of the act. + +* Synthesis: metrics → org-roam KB + +Trigger: "synthesize backlog metrics" (optionally a weekly scheduled run). This is the read side of the metrics log — Craig's ask was "gather data and create org-roam articles we can look at later," and this step is the second half. It is read-only over the logs plus exactly one KB write. + +1. *Gather the JSONL union.* Discover =.ai/metrics/work-the-backlog.jsonl= across the project roots (dirs carrying =.ai/protocols.org= under =~/code=, =~/projects=, =~/.emacs.d=). Classify each project per =knowledge-base.md= (work-root denylist, never inference) before reading it into the union. +2. *Enforce personal-only.* A work-classified or unknown project's metrics never enter the KB write — they stay in that project's own log. Report the exclusion per the KB refusal contract: the classification, a one-line redacted summary, and where the data stayed. +3. *Compute the rollups and trends.* Per run: attempted / completed / deferred (by reason) / dropped / failed, wall-clock total, commits landed, review findings per commit. Trends across runs: completion rate over time, defer-reason distribution, findings-per-commit trend. +4. *Compute the corrections signal* — the key metric. For each =commit_sha= in the window, check that project's history for a later commit (within ~14 days) that reverts it or carries a fix touching the same files. A clean run is one whose autonomous commits survive untouched; a flagged run is what Craig reviews by hand. This is a cheap proxy, not proof — it flags candidates, it doesn't convict. +5. *Write one KB node* at =~/org/roam/agents/YYYYMMDDHHMMSS-backlog-metrics-<window>.org= per =knowledge-base.md=: =:agent:metrics:= filetags, a concise title, the rollup table, the trend narrative, and =[[id:...]]= links to prior synthesis nodes so the series is traceable. Pull before writing, commit and push after — the normal KB session discipline. + +The KB node is the artifact Craig reads later: "are the runs completing more and getting corrected less?" should read off the trend table without touching raw logs. Synthesis never mutates the JSONL, todo.org, or any project tree. + +* Common Mistakes + +1. *Implementing a =VERIFY= or =DOING= task.* The gate is status =TODO= only — a =VERIFY= exists precisely because Craig's input is pending. +2. *Treating =:quick:= as eligibility.* It's an effort hint. =:solo:= is the gate. +3. *Deferring on size.* A large, well-specified, decision-free task runs — decomposed into logical commits. Size is not a checklist item. +4. *Guessing past the keystone.* If the failing test isn't writable from the task text, the task isn't ready. Inventing the requirement is the failure the checklist exists to stop. +5. *Rationalizing through the data-loss list.* "The migration is small" doesn't clear checklist item 2. Enumerated operations defer, full stop. +6. *Committing in =file-only= mode.* The diff is the deliverable; the commit is Craig's. +7. *One omnibus commit for the whole run.* Every logical change is its own reviewed commit. +8. *Skipping =/review-code= or =/voice= because nobody's watching.* Autonomy removes interaction gates, never engineering-discipline gates (same contract as =no-approvals.org=). +9. *Running past the cap.* The cap is the kill switch; hitting it means stop and surface, even mid-set. +10. *Paging per-task.* One page, end of set. +11. *Honoring =autonomous-commit= from memory.* The waiver is the marker line in =notes.org=, read fresh each run. "This project usually allows it" isn't a read. +12. *Re-filing the same deferral =VERIFY= every run.* The deferred task stays =TODO=, so a run that skips the existing-sibling check spams =todo.org= with duplicates. +13. *Routing a data-loss hit to the pre-flight Q&A.* Checklist item 2 is the hard gate — an upfront answer never clears it without an explicit checkpoint. + +* Living Document + +Refine as the dogfooding signal arrives — the metrics log and the corrections-in-next-session signal are the feedback loop. Fold recurring adjustments in rather than accumulating caller-side workarounds. + +* History + +Created 2026-07-02 as Phase 1 of the autonomous-batch execution spec, reconciling the inbox-zero "Phase E" proposal and the =.emacs.d= speedrun proposal into one execution loop. The auto-inbox-zero execute step in =inbox.org= reverted to routing-only in the same change so this file is the loop's only home. Phases 2-6 (same day) wired both callers, pinned the commit-autonomy waiver markers, fleshed the defer/Q&A/page mechanics, and added the metrics record + KB synthesis step. diff --git a/.ai/workflows/wrap-it-up.org b/.ai/workflows/wrap-it-up.org index 5d2cdd2..d0c4e75 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 @@ -244,6 +260,32 @@ The check exempts =lint-followups.org= explicitly because lint-org runs earlier This integrates with =inbox.org= process mode, which stamps =:LAST_INBOX_PROCESS:= in =notes.org='s *Workflow State* section on completion. Wrap-up doesn't double-stamp. It only ensures the inbox carries nothing but the expected pipeline artifacts at session end. +*** Cross-project router (optional — route filed keepers to their home projects) + +Runs directly after the inbox sanity check. The split between the two: the sanity check *gates* the wrap (a dirty inbox blocks until resolved); the router is *optional* (skipping it never blocks anything — the candidates just stay local until a future wrap). Spec: =docs/specs/wrapup-routing-spec.org= (D7/D8/D9). + +The candidate set is exactly the local tasks carrying a =:ROUTE_CANDIDATE:= property — keepers that inbox process mode filed this session whose inferred home is another project. Never scan the standing backlog. + +#+begin_src bash +.ai/scripts/route-batch --list +#+end_src + +*Empty set = zero interaction.* =--list= prints nothing when there are no candidates; continue the wrap silently — no prompt, no "0 items" line. + +When candidates exist, surface the batch as one line per task — the task heading, the destination project, the delivery mode (=inbox-send= file handoff), and the engine's confidence — then offer exactly two options: *go* (route the whole batch) or *skip* (leave everything local). Derive each confidence label by running the engine on the task's heading + body (=python3 .ai/scripts/route_recommend.py --item "..." --exclude "$(basename "$PWD")"=); label weak matches visibly ("weak — verify the destination") so a low-confidence route gets a human glance before the keystroke. + +On *go*: + +#+begin_src bash +.ai/scripts/route-batch --go +#+end_src + +Per candidate, the helper writes the task's subtree (children ride along; =:ROUTE_CANDIDATE:= stripped, headings promoted to top level) to a one-task handoff, delivers it via =inbox-send <destination> --file= (so the =from-<this-project>= provenance is stamped and the destination's inbox process mode dispositions it as a single item), and only after a successful send removes the subtree from the local =todo.org= — a single-file local edit the wrap is already committing. A failed send leaves that task in place and exits non-zero; report it and continue the wrap. Never write the destination's =todo.org= directly; its own inbox processing files the task per its conventions. + +On *skip*, leave every candidate in place, marker included — they resurface next wrap. + +Mis-routes are recoverable: the receiving project rejects via inbox process mode's reject-from-another-project flow, which returns the item to this project's inbox with the rationale. That reject path is why removing the local source on send is safe. + *** Review-habit health check (surface a slipped daily task-review) The daily task-review habit walks the open top-level tasks on a rotating cycle, stamping =:LAST_REVIEWED:= as it goes (see =task-review.org=). This check is the watchdog for that habit. When tasks have gone too long unreviewed, the habit has slipped, and the wrap-up says so in one line — it does not re-list the tasks. @@ -536,7 +578,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 |
