aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/.ai
diff options
context:
space:
mode:
Diffstat (limited to 'claude-templates/.ai')
-rw-r--r--claude-templates/.ai/notes.org12
-rw-r--r--claude-templates/.ai/protocols.org25
-rwxr-xr-xclaude-templates/.ai/scripts/cj-remove-block.py81
-rwxr-xr-xclaude-templates/.ai/scripts/inbox-send.py61
-rwxr-xr-xclaude-templates/.ai/scripts/inbox-status1
-rw-r--r--claude-templates/.ai/scripts/lint-org.el121
-rw-r--r--claude-templates/.ai/scripts/route_recommend.py9
-rw-r--r--claude-templates/.ai/scripts/tests/inbox-status.bats12
-rw-r--r--claude-templates/.ai/scripts/tests/test-lint-org.el239
-rw-r--r--claude-templates/.ai/scripts/tests/test-todo-cleanup.el103
-rw-r--r--claude-templates/.ai/scripts/tests/test_cj_remove_block.py167
-rw-r--r--claude-templates/.ai/scripts/tests/test_inbox_send.py114
-rw-r--r--claude-templates/.ai/scripts/tests/test_route_recommend.py28
-rw-r--r--claude-templates/.ai/scripts/todo-cleanup.el34
-rw-r--r--claude-templates/.ai/workflows/INDEX.org2
-rw-r--r--claude-templates/.ai/workflows/page-me.org24
-rw-r--r--claude-templates/.ai/workflows/sentry.org26
-rw-r--r--claude-templates/.ai/workflows/startup.org27
-rw-r--r--claude-templates/.ai/workflows/triage-intake.org21
-rw-r--r--claude-templates/.ai/workflows/work-the-backlog.org4
-rw-r--r--claude-templates/.ai/workflows/wrap-it-up.org68
21 files changed, 1103 insertions, 76 deletions
diff --git a/claude-templates/.ai/notes.org b/claude-templates/.ai/notes.org
index c16863b..311a86f 100644
--- a/claude-templates/.ai/notes.org
+++ b/claude-templates/.ai/notes.org
@@ -6,19 +6,19 @@
This file contains project-specific information for this project.
-**When to read this:**
+- When to read this:
- At the start of EVERY session (after reading protocols.org)
- When needing project context or history
- When checking reminders or pending decisions
-**What's in this file:**
+- What's in this file:
- Project-specific context and goals
- Pending decisions
- Active reminders
-**Session history is NOT in this file.** Each session's record lives in =.ai/sessions/YYYY-MM-DD-HH-MM-description.org= — one file per session. Catch-up reads the Summary sections of the most recent 5.
+- Session history is NOT in this file. Each session's record lives in =.ai/sessions/YYYY-MM-DD-HH-MM-description.org= — one file per session. Catch-up reads the Summary sections of the most recent 5.
-**For protocols and conventions, see:** [[file:protocols.org][protocols.org]]
+- For protocols and conventions, see [[file:protocols.org][protocols.org]].
* Project-Specific Context
@@ -48,9 +48,9 @@ This section tracks decisions that need Craig's input before work can proceed.
- Include: What needs to be decided, options available, why it matters
- Remove decisions once resolved (the resolution is captured in the Session Log of the session where it was resolved)
-**Example format:**
+- Example format:
#+begin_example
-** Feature Name or Topic
+,** Feature Name or Topic
Craig needs to decide on [specific question].
diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org
index d89e345..2ea041b 100644
--- a/claude-templates/.ai/protocols.org
+++ b/claude-templates/.ai/protocols.org
@@ -84,7 +84,7 @@ Do NOT estimate, guess, or rely on memory. Just run the command. It takes one se
Every session pulls rulesets first, then the local project repo. Rulesets carries the canonical behavioral rules and =.ai/= templates (the old =claude-templates= repo is folded in as a subtree at =rulesets/claude-templates/=); the project pull lands commits pushed from other machines or teammates since the last session.
-Resolve any dirty-tree or merge issue at each step before moving on. Both pulls run as =git pull --ff-only= (or =git merge --ff-only= against the fetched upstream), so anything non-trivial — non-fast-forward history, dirty working tree, diverged branches — aborts. Surface the state and stop. Never auto-stash, auto-merge, or auto-rebase; the user resolves the conflict before further work.
+Resolve any sync-blocking tree or merge issue at each step before moving on. The shared =git-worktree-gate sync-safe= policy permits untracked deliveries beneath =inbox/= so receiving a handoff never prevents another project from refreshing rulesets; every staged or tracked change, dirty submodule, Git operation in progress, or untracked path outside =inbox/= blocks. Both pulls run as =git pull --ff-only= (or =git merge --ff-only= against the fetched upstream), so non-fast-forward history and diverged branches also abort. Surface the state and stop. Never auto-stash, auto-merge, or auto-rebase; the user resolves the conflict before further work.
Mechanics live in =startup.org= Phase A.0. The rule lives here because it governs the very first action of every session: load the freshest behavioral rules and templates before anything else runs.
@@ -430,27 +430,29 @@ 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 — the agent pager
+** Reaching Craig — the notification vocabulary
-"Page me" has two channels; pick by where Craig is. Both work from any agent runtime — nothing here is Claude-specific.
+Two channels, two trigger words. "page me" is the desktop, "text me" is the phone, "text and page me" is both. Pick by where Craig is, and default to both when a run can't tell. Both work from any agent runtime (nothing here is Claude-specific). The words are what Craig says; a run deciding on its own maps the same way (away run texts, at-desk run pages, unsure does both).
-- *At his laptop/desktop* — desktop =notify ... --persist= (above). It reaches him on the machine and stays up until dismissed.
+- *"page me" — at his laptop/desktop.* A desktop =notify ... --persist= that reaches him on the machine and stays up until dismissed.
#+begin_src bash
notify info "Title" "Message" --persist
#+end_src
-- *Away from his laptop/desktop* — page his phone over Signal with the *agent pager*:
+- *"text me" — away from his machine.* A Signal push to his phone via =agent-text=:
#+begin_src bash
- agent-page "Message for Craig's phone"
+ agent-text "Message for Craig's phone"
#+end_src
- =agent-page= (in =~/.local/bin= via the rulesets install) sends from the dedicated pager identity (+15045173983, registered in velox's signal-cli) to Craig's Signal account UUID, firing a normal mobile push. On velox it sends directly; on any other tailnet machine it ssh-relays the send to velox. Verified end to end 2026-07-13. Never page Craig's phone *number* — it reads as unregistered in Signal's directory; the script already targets the UUID.
+ =agent-text= (in =~/.local/bin= via the rulesets install) sends from the dedicated Signal identity (+15045173983) to Craig's Signal account UUID, firing a normal mobile push. The account is registered on velox (primary) and ratio (linked device), so either sends directly; a machine without it ssh-relays to velox. Verified end to end 2026-07-13 (velox) and 2026-07-20 (ratio). Never target Craig's phone *number* (it reads as unregistered in Signal's directory); the script targets the UUID.
- Caveats: velox must be up and on the tailnet (the script says so and names the desktop fallback when the relay fails), and the signal-cli account wants a periodic =receive= — both tracked on the rulesets Signal-pager task, which owns the full runbook.
+ Caveats: a relay from a non-linked machine needs velox up on the tailnet, and each device holding the account wants a periodic =receive= (the signal-receive timer handles that). The full runbook lives in rulesets =docs/design/=.
-On velox, Claude sessions may also have the *signal-mcp* tool (=send_message_to_user=, same pager identity) — fine to use there, but it exists only in velox's local MCP config, so =agent-page= is the portable habit. Do *not* use the old =page-signal= shell script (removed 2026-06-12).
+- *"text and page me" — both.* Fire =agent-text= and =notify= together. The phone reaches him now, the desktop note waits for his return. This is the default when a run can't tell whether he's away.
+
+On velox, Claude sessions may also have the *signal-mcp* tool (=send_message_to_user=, same identity), fine to use there, but it exists only in velox's local MCP config, so =agent-text= is the portable habit. The tool was named =agent-page= before 2026-07-20; a deprecated =agent-page= shim still delegates to =agent-text=. Do *not* use the old =page-signal= shell script (removed 2026-06-12).
* Session Protocols
@@ -565,12 +567,13 @@ When monitoring a long-running process (rsync, large downloads, builds, VM tests
** "Wrap it up" / "That's a wrap" / "Let's call it a wrap"
-When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Four steps:
+When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Five load-bearing steps:
1. *Finalize the Summary* in =.ai/session-context.org= (populate the 5 subsections from the Session Log)
2. *Rename* =.ai/session-context.org= → =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=
3. *Git commit + push* to all remotes (see Git Commit Requirements)
-4. *Valediction* — brief, warm, specific closing
+4. *Certify the clean tree* with =git-worktree-gate certify=. Any remaining staged, unstaged, untracked, submodule, or in-progress-operation state blocks wrap entirely; report each path and the exact decision needed. There is no dirty-file deferral.
+5. *Valediction* — brief, warm, specific closing, reachable only after certification
The absence of =.ai/session-context.org= after wrap-up is the signal that the session ended cleanly. If the file is still there at the next session start, the previous session was interrupted.
diff --git a/claude-templates/.ai/scripts/cj-remove-block.py b/claude-templates/.ai/scripts/cj-remove-block.py
index 71c7b3d..d5137a3 100755
--- a/claude-templates/.ai/scripts/cj-remove-block.py
+++ b/claude-templates/.ai/scripts/cj-remove-block.py
@@ -16,8 +16,12 @@ Companion to the /respond-to-cj-comments skill and to cj-scan.py.
from __future__ import annotations
import argparse
+import os
import re
+import shutil
import sys
+import tempfile
+from datetime import datetime
from pathlib import Path
SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:", re.IGNORECASE)
@@ -57,12 +61,83 @@ def looks_like_cj_range(lines: list[str], start: int, end: int) -> tuple[bool, s
f"Line {end} does not look like a #+end_src closing fence "
f"(got: {last[:60]!r})"
)
+
+ # The range must hold exactly ONE block. Checking only the first and last
+ # lines let a drifted range run from one block's opener to a *later* block's
+ # closer: validation passed and the removal silently deleted everything
+ # between, prose and headings included. Drift is the case this check exists
+ # for, so it has to look inside the range, not just at its ends.
+ for offset, line in enumerate(lines[start:end - 1], start=start + 1):
+ if SRC_CLOSE_RE.match(line):
+ return False, (
+ f"Range {start}..{end} covers more than one cj block — "
+ f"a #+end_src appears at line {offset}, before the range ends. "
+ f"Re-scan for current line numbers; removing this range would "
+ f"delete everything between the two blocks."
+ )
+ if SRC_OPEN_RE.match(line):
+ return False, (
+ f"Range {start}..{end} covers more than one cj block — "
+ f"a second #+begin_src cj: appears at line {offset}. "
+ f"Re-scan for current line numbers."
+ )
return True, ""
+def _backup(path: Path) -> Path:
+ """Copy path to /tmp before mutating it, mirroring lint-org.el's convention.
+
+ These are Craig's org files. lint-org.el, the other tool that rewrites them,
+ leaves a /tmp copy before touching anything; this matches it so a bad edit is
+ always recoverable without reaching for git (which only reaches the last
+ commit, losing intra-session work).
+ """
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+ base = Path(tempfile.gettempdir()) / f"{path.name}.before-cj-remove.{stamp}"
+ # Never overwrite an earlier backup. The skill removes several annotations
+ # in quick succession, so a second-resolution stamp collides and the later
+ # copy would replace the earlier one with already-mutated content — losing
+ # the pre-session original the backup exists to preserve.
+ dest = base
+ n = 2
+ while dest.exists():
+ dest = base.with_name(f"{base.name}-{n}")
+ n += 1
+ shutil.copy2(path, dest)
+ return dest
+
+
+def _atomic_write(path: Path, text: str) -> None:
+ """Write text to path via a temp sibling and os.replace.
+
+ A bare write_text truncates the target on open, so a mid-write failure left
+ the org file truncated with no complete copy on disk. Writing a temp sibling
+ and renaming means the file is either its old content or its new content,
+ never a partial.
+ """
+ # Follow a symlink to the file it names. os.replace would otherwise swap the
+ # symlink itself for a regular file, leaving the real target holding the old
+ # content — the edit silently goes nowhere. Resolving also puts the temp
+ # sibling on the same filesystem as the real file, which os.replace needs.
+ path = path.resolve()
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
+ os.close(fd)
+ tmp_path = Path(tmp)
+ # Carry the original's permissions across. mkstemp creates 0600, and
+ # defaulting to the umask instead widened a deliberately-restricted file
+ # (a 0600 org file came back 0644).
+ shutil.copymode(path, tmp_path)
+ try:
+ tmp_path.write_text(text, encoding="utf-8")
+ os.replace(tmp_path, path)
+ except BaseException:
+ tmp_path.unlink(missing_ok=True)
+ raise
+
+
def remove_range(path: Path, start: int, end: int) -> None:
"""Read path, validate range looks like cj content, remove the range, write back."""
- text = path.read_text()
+ text = path.read_text(encoding="utf-8")
had_trailing_newline = text.endswith("\n")
lines = text.splitlines(keepends=False)
@@ -77,7 +152,9 @@ def remove_range(path: Path, start: int, end: int) -> None:
new_text += "\n"
elif not new_lines and had_trailing_newline:
new_text = ""
- path.write_text(new_text)
+
+ _backup(path)
+ _atomic_write(path, new_text)
def main() -> int:
diff --git a/claude-templates/.ai/scripts/inbox-send.py b/claude-templates/.ai/scripts/inbox-send.py
index 1ebb636..663efcb 100755
--- a/claude-templates/.ai/scripts/inbox-send.py
+++ b/claude-templates/.ai/scripts/inbox-send.py
@@ -31,6 +31,7 @@ import os
import re
import shutil
import sys
+import tempfile
from datetime import datetime
from pathlib import Path
@@ -48,7 +49,7 @@ def resolve_roots() -> list[Path]:
config = Path.home() / ".claude" / "inbox-roots.txt"
if config.is_file():
paths: list[Path] = []
- for line in config.read_text().splitlines():
+ for line in config.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
paths.append(Path(line).expanduser())
@@ -69,17 +70,28 @@ def discover_projects(roots: list[Path]) -> list[Path]:
a specific project root (included directly if it qualifies).
"""
projects: list[Path] = []
+ seen: set[Path] = set()
+
+ def _add(p: Path) -> None:
+ # Dedupe on the resolved path: a roots config naming both a parent and
+ # one of its children would otherwise list the child project twice, at
+ # two different indices.
+ key = p.resolve()
+ if key not in seen:
+ seen.add(key)
+ projects.append(p)
+
for root in roots:
if not root.is_dir():
continue
if _is_project(root):
- projects.append(root)
+ _add(root)
continue
for child in sorted(root.iterdir()):
if not child.is_dir():
continue
if _is_project(child):
- projects.append(child)
+ _add(child)
return projects
@@ -194,6 +206,39 @@ def uniquify(dest: Path) -> Path:
n += 1
+def _atomic_write(dest: Path, writer) -> None:
+ """Write to a temp file in dest's directory, then rename it into place.
+
+ dest is another project's inbox/, and a direct write truncates the target
+ on open, so any mid-write failure (a full disk, an encoding error, an
+ interrupted process) leaves a zero-byte .org there. inbox-status counts
+ that phantom as a pending handoff and blocks a turn in the receiving
+ project over a file with no content and no sender (2026-07-23). Writing to
+ a temp sibling and os.replace-ing means the inbox only ever sees a complete
+ file. os.replace is atomic within one filesystem, and the temp sits in the
+ same directory as dest, so it is.
+
+ `writer` receives the open temp path and fills it. On any failure the temp
+ is removed and the error re-raised, so a caught error never leaves debris.
+ """
+ fd, tmp = tempfile.mkstemp(
+ dir=dest.parent, prefix=".inbox-send-", suffix=dest.suffix
+ )
+ os.close(fd)
+ tmp_path = Path(tmp)
+ # mkstemp creates the temp 0600; give the delivered file the umask-default
+ # mode the old direct write produced, so inbox files stay readable as before.
+ umask = os.umask(0)
+ os.umask(umask)
+ os.chmod(tmp_path, 0o666 & ~umask)
+ try:
+ writer(tmp_path)
+ os.replace(tmp_path, dest)
+ except BaseException:
+ tmp_path.unlink(missing_ok=True)
+ raise
+
+
def send_text(
target_inbox: Path,
message: str,
@@ -209,7 +254,8 @@ def send_text(
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 = uniquify(target_inbox / filename)
- dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT)))
+ body = build_text_org(message, source_name, now.strftime(TS_DOC_FMT))
+ _atomic_write(dest, lambda p: p.write_text(body, encoding="utf-8"))
return dest
@@ -229,7 +275,7 @@ def send_file(
ext = src_path.suffix
filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}"
dest = uniquify(target_inbox / filename)
- shutil.copy2(src_path, dest)
+ _atomic_write(dest, lambda p: shutil.copyfile(src_path, p))
return dest
@@ -310,7 +356,10 @@ def main() -> int:
else:
assert args.file is not None
dest = send_file(target_inbox, args.file, source_name, args.name, now)
- except (ValueError, FileNotFoundError) as exc:
+ except (ValueError, OSError) as exc:
+ # OSError covers FileNotFoundError (missing source), PermissionError
+ # (unreadable source), and any atomic-write failure — all should
+ # surface as the clean "inbox-send: <message>" error, never a traceback.
print(f"inbox-send: {exc}", file=sys.stderr)
return 1
diff --git a/claude-templates/.ai/scripts/inbox-status b/claude-templates/.ai/scripts/inbox-status
index b917144..17031af 100755
--- a/claude-templates/.ai/scripts/inbox-status
+++ b/claude-templates/.ai/scripts/inbox-status
@@ -35,6 +35,7 @@ mapfile -t pending < <(find inbox -maxdepth 1 -type f \
! -name '.gitkeep' \
! -name 'lint-followups.org' \
! -name 'PROCESSED-*' \
+ ! -name '.inbox-send-*' \
-printf '%f\n' 2>/dev/null | sort)
n=${#pending[@]}
diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el
index 47e8bf1..33dc52f 100644
--- a/claude-templates/.ai/scripts/lint-org.el
+++ b/claude-templates/.ai/scripts/lint-org.el
@@ -38,6 +38,7 @@
;; empty-heading bare stars with no title
;; malformed-priority-cookie [#x]-shaped token org rejected
;; level2-done-without-closed completed level-2 task with no CLOSED
+;; task-missing-last-reviewed open level-2 task with no :LAST_REVIEWED:
;; subtask-done-not-dated level-3+ done sub-task still a DONE keyword
;; dated-log-heading-active-timestamp dated-log heading with a live SCHEDULED/DEADLINE
;; (anything else) surfaced as judgment with checker name
@@ -75,6 +76,18 @@ The CLI defaults this to t (a linter reports, it doesn't write);
`--fix' is what enables writes on a command-line run.")
(defvar lo-current-file nil
"Path of the file currently being processed.")
+
+(defun lo--spec-file-p ()
+ "Non-nil when the current file lives under a docs/specs/ directory.
+The four todo-format-family checkers encode todo.org completion conventions
+and misfire on a spec: a spec's Decisions section legitimately carries a
+level-2 DONE with no CLOSED cookie, and its review-history section carries
+level-2 dated headings. docs/specs/ is the canonical spec home per the
+docs-lifecycle rule, so a path segment match is the scope test. Link,
+table, and structural checks still run on specs — only the todo-format
+family is scoped out."
+ (and lo-current-file
+ (string-match-p "/docs/specs/" (expand-file-name lo-current-file))))
(defvar lo-followups-file nil
"When non-nil, after a non-check run any judgment items are appended to this
path as an org section dated today. The file is created if missing.")
@@ -293,6 +306,52 @@ Craig-specific annotation marker rather than Babel src-block syntax."
(lo--goto-line line)
(looking-at-p "^[ \t]*#\\+begin_src[ \t]+cj:")))
+(defvar-local lo--matched-blocks-cache nil
+ "Cons of (TICK . REGIONS) memoizing `lo--matched-block-regions'.
+TICK is the `buffer-chars-modified-tick' the regions were computed at, so a
+fix applied mid-pass invalidates them.")
+
+(defun lo--matched-block-regions ()
+ "Return ((BEGIN-LINE . END-LINE) ...) for every correctly paired block.
+Scans lines directly rather than asking org, because org's own parser is what
+mis-reads these blocks: a heading-shaped line inside a verbatim body reads as a
+structural break and loses the open block. The scan applies org's real rule —
+once a block is open, only its own `#+end_TYPE' closes it, so a nested
+`#+begin_' or a foreign `#+end_' in the body is just text."
+ (let ((tick (buffer-chars-modified-tick)))
+ (if (eql (car lo--matched-blocks-cache) tick)
+ (cdr lo--matched-blocks-cache)
+ (let ((case-fold-search t)
+ (regions nil) (open-type nil) (open-line nil) (line 0))
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (setq line (1+ line))
+ (let ((text (buffer-substring-no-properties
+ (line-beginning-position) (line-end-position))))
+ (cond
+ (open-type
+ (when (string-match
+ (format "\\`[ \t]*#\\+end_%s[ \t]*\\'"
+ (regexp-quote open-type))
+ text)
+ (push (cons open-line line) regions)
+ (setq open-type nil open-line nil)))
+ ((string-match "\\`[ \t]*#\\+begin_\\([^ \t\n]+\\)" text)
+ (setq open-type (match-string 1 text)
+ open-line line))))
+ (forward-line 1)))
+ (setq lo--matched-blocks-cache (cons tick (nreverse regions)))
+ (cdr lo--matched-blocks-cache)))))
+
+(defun lo--in-matched-block-p (line)
+ "Non-nil when LINE sits within a correctly paired block, delimiters included.
+org-lint reports `invalid-block' at the delimiter lines themselves, so the
+range has to be inclusive for the suppression to reach them."
+ (cl-some (lambda (region)
+ (and (>= line (car region)) (<= line (cdr region))))
+ (lo--matched-block-regions)))
+
(defun lo--handle-item (item)
(let ((name (lo--checker-name item))
(line (lo--line item))
@@ -305,6 +364,13 @@ Craig-specific annotation marker rather than Babel src-block syntax."
wrong-header-argument))
(lo--cj-comment-block-opener-p line))
nil)
+ ;; `invalid-block' on a block that is in fact correctly paired — the
+ ;; checker is org-lint's own, so this filters its output rather than
+ ;; fixing a local checker. A genuinely unterminated block isn't in any
+ ;; matched region, so it still reports.
+ ((and (eq name 'invalid-block)
+ (lo--in-matched-block-p line))
+ nil)
((eq name 'item-number)
(lo--apply-or-preview name line msg #'lo-fix-item-number))
((eq name 'missing-language-in-src-block)
@@ -526,6 +592,42 @@ the live file on the next `task-sorted'."
"level-2 DONE/CANCELLED has no CLOSED date — add CLOSED: [YYYY-MM-DD Day]; task-sorted's aging step archives an undated completed task immediately"))))))))
;;; ---------------------------------------------------------------------------
+;;; task-missing-last-reviewed check (claude-rules/todo-format.md)
+;;
+;; A task is stamped `:LAST_REVIEWED:' when it is *created*, not a review cycle
+;; later. An agent filing a task has just written its body and graded its
+;; priority, which is a review by any honest reading — so a fresh task that
+;; carries no stamp reads as "never reviewed" and lands at the top of the next
+;; staleness batch, where re-reviewing it is pure ceremony. Every task filed
+;; during the 2026-07-23 sweep hit exactly that, which is what prompted the rule.
+;;
+;; Judgment-only, deliberately. The stamp's whole value is that its date is
+;; true, and nothing here can know when an unstamped task was actually last
+;; looked at. Auto-stamping today's date would convert a "nobody has reviewed
+;; this" signal into a false "reviewed today" one — worse than the gap it
+;; closes. Flag it; a human or the filing workflow supplies the honest date.
+;;
+;; Scope matches `task-review-staleness.sh' exactly (level-2, open keyword,
+;; priority cookie), so the checker and the staleness count never disagree
+;; about which headings are in the review pool.
+
+(defun lo--check-task-missing-last-reviewed ()
+ "Flag an open level-2 task with a priority cookie and no `:LAST_REVIEWED:'."
+ (save-excursion
+ (goto-char (point-min))
+ (let ((case-fold-search nil))
+ (while (re-search-forward "^\\*\\* \\(TODO\\|DOING\\|VERIFY\\) \\[#[A-D]\\]" 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]*:LAST_REVIEWED:[ \t]*[[0-9]"
+ entry-end t)
+ (lo--emit-judgment
+ 'task-missing-last-reviewed hline
+ "task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed"))))))))
+
+;;; ---------------------------------------------------------------------------
;;; 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
@@ -620,15 +722,22 @@ left unmodified and mechanical entries are recorded with :preview t."
;; After org-lint items: the custom table-standard scan. Runs on the
;; post-fix buffer; judgment-only, so order doesn't perturb fixes.
(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.
+ ;; Structural heading defects org-lint doesn't cover. These run on
+ ;; every org file, specs included.
(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)
- (lo--check-dated-log-active-timestamp)
+ ;; The todo-format family encodes todo.org completion conventions and
+ ;; misfires on a spec (a Decisions section's undated DONE, a
+ ;; review-history dated heading, a phases task with no LAST_REVIEWED).
+ ;; Scope them out of docs/specs/; link, table, and structural checks
+ ;; above still run there.
+ (unless (lo--spec-file-p)
+ (lo--check-level2-dated-headers)
+ (lo--check-level2-done-without-closed)
+ (lo--check-task-missing-last-reviewed)
+ (lo--check-subtask-done-not-dated)
+ (lo--check-dated-log-active-timestamp))
(when (and (not lo-check-only) (buffer-modified-p))
(save-buffer)))
(with-current-buffer buf (set-buffer-modified-p nil))
diff --git a/claude-templates/.ai/scripts/route_recommend.py b/claude-templates/.ai/scripts/route_recommend.py
index 7b36405..12ab132 100644
--- a/claude-templates/.ai/scripts/route_recommend.py
+++ b/claude-templates/.ai/scripts/route_recommend.py
@@ -71,6 +71,15 @@ def recommend(item: str, projects: list[str]) -> tuple[str | None, str]:
if not projects:
return (None, "none")
+ # Collapse identical names first. Projects are addressed by bare basename, so
+ # two projects sharing one across roots (~/code/notes, ~/projects/notes) arrive
+ # twice; both literal-match, and the tie test below then read that as ambiguity
+ # and downgraded a correct strong match to weak. Deduping here rather than in
+ # discover_destination_names protects every caller of the pure core, not just
+ # the CLI path. Order-preserving, and it collapses only identical names — two
+ # *different* projects matching is real ambiguity and still downgrades.
+ projects = list(dict.fromkeys(projects))
+
item_lower = item.lower()
item_tokens = _tokens(item)
diff --git a/claude-templates/.ai/scripts/tests/inbox-status.bats b/claude-templates/.ai/scripts/tests/inbox-status.bats
index bc8a734..27a497e 100644
--- a/claude-templates/.ai/scripts/tests/inbox-status.bats
+++ b/claude-templates/.ai/scripts/tests/inbox-status.bats
@@ -45,6 +45,18 @@ teardown() {
[[ "$output" == *"0 pending"* ]]
}
+@test "inbox-status: ignores an in-flight .inbox-send-* temp file" {
+ mkdir "$TMP/inbox"
+ # inbox-send writes to a .inbox-send-* temp then renames it into place;
+ # during that window the temp must not read as a pending handoff, or a
+ # concurrent boundary check blocks on a file that's about to become real.
+ touch "$TMP/inbox/.inbox-send-abc123.org"
+ cd "$TMP"
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"0 pending"* ]]
+}
+
@test "inbox-status: -q suppresses the per-item lines" {
mkdir "$TMP/inbox"
echo body > "$TMP/inbox/handoff.org"
diff --git a/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el
index 30a79bd..ceee209 100644
--- a/claude-templates/.ai/scripts/tests/test-lint-org.el
+++ b/claude-templates/.ai/scripts/tests/test-lint-org.el
@@ -193,6 +193,65 @@ real suspicious-language warning here
#+end_src
")
+;; invalid-block, false-positive case — a correctly paired example block whose
+;; body holds a heading-shaped line. org's parser reads the `** ' inside the
+;; verbatim body as a structural break, loses the open block, and flags BOTH
+;; delimiters as "Possible incomplete block".
+(defconst lo-test--verbatim-heading-block "\
+* Heading
+
+#+begin_example
+** Feature Name or Topic
+Body line.
+#+end_example
+
+Trailing prose.
+")
+
+;; invalid-block, literal-delimiter case — a paired src block whose body holds
+;; a literal `#+end_example' plus a heading-shaped line. Only `#+end_src'
+;; closes a src block, so all three findings here are false.
+(defconst lo-test--literal-end-in-src "\
+* Heading
+
+#+begin_src text
+#+end_example
+** heading shaped
+#+end_src
+")
+
+;; invalid-block, uppercase-delimiter case — org accepts #+BEGIN_/#+END_ in
+;; either case, and the pre-fix script flagged both delimiters here too.
+(defconst lo-test--uppercase-verbatim-block "\
+* Heading
+
+#+BEGIN_EXAMPLE
+** heading shaped
+#+END_EXAMPLE
+")
+
+;; invalid-block, genuine case — a block that really is never closed. The
+;; suppression must not reach this one.
+(defconst lo-test--unterminated-block "\
+* Heading
+
+#+begin_example
+truly unterminated block body
+")
+
+;; A genuinely unterminated block *after* a correctly paired one — verifies the
+;; suppression is scoped per block rather than per file.
+(defconst lo-test--paired-then-unterminated "\
+* Heading
+
+#+begin_example
+** heading shaped
+#+end_example
+
+#+begin_example
+never closed
+")
+
;; Mixed fixture — each category once.
(defconst lo-test--mixed "\
* Mixed
@@ -392,6 +451,55 @@ suspicious-language judgment."
(should (= 1 suspicious))))
;;; ---------------------------------------------------------------------------
+;;; invalid-block — false positives on correctly paired verbatim blocks
+
+(ert-deftest lo-verbatim-heading-block-emits-no-invalid-block ()
+ "Normal: a paired example block containing a heading-shaped body line emits
+no invalid-block judgment. Both delimiters are flagged by org-lint because the
+parser treats the `** ' inside the verbatim body as a structural break."
+ (let* ((out (lo-test--run lo-test--verbatim-heading-block))
+ (res (plist-get out :result))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ ;; File untouched, no fixes applied — suppression only, never a rewrite.
+ (should (equal lo-test--verbatim-heading-block res))
+ (should (= 0 (plist-get out :fixes)))
+ (should-not (member 'invalid-block (lo-test--checkers judgments)))))
+
+(ert-deftest lo-literal-end-delimiter-in-src-emits-no-invalid-block ()
+ "Boundary: a paired src block whose body holds a literal `#+end_example' and
+a heading-shaped line emits no invalid-block judgment. Only `#+end_src' closes
+a src block, so the interior delimiter is body text."
+ (let* ((out (lo-test--run lo-test--literal-end-in-src))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'invalid-block (lo-test--checkers judgments)))))
+
+(ert-deftest lo-uppercase-verbatim-block-emits-no-invalid-block ()
+ "Boundary: block delimiters are case-insensitive in org, so an uppercase
+`#+BEGIN_EXAMPLE' pair is suppressed the same as a lowercase one."
+ (let* ((out (lo-test--run lo-test--uppercase-verbatim-block))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'invalid-block (lo-test--checkers judgments)))))
+
+(ert-deftest lo-unterminated-block-still-emits-invalid-block ()
+ "Error: a block that is never closed still emits its invalid-block judgment.
+This is the finding the checker exists for — the suppression must not mask it."
+ (let* ((out (lo-test--run lo-test--unterminated-block))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (member 'invalid-block (lo-test--checkers judgments)))))
+
+(ert-deftest lo-invalid-block-suppression-is-scoped-per-block ()
+ "Boundary: a paired block and an unterminated block in the same file — the
+paired one is suppressed and the unterminated one still reports. Exactly one
+invalid-block judgment, and it points at the unterminated opener (line 7)."
+ (let* ((out (lo-test--run lo-test--paired-then-unterminated))
+ (judgments (lo-test--judgments (plist-get out :issues)))
+ (invalid (cl-remove-if-not
+ (lambda (i) (eq (plist-get i :checker) 'invalid-block))
+ judgments)))
+ (should (= 1 (length invalid)))
+ (should (= 7 (plist-get (car invalid) :line)))))
+
+;;; ---------------------------------------------------------------------------
;;; --check mode
(ert-deftest lo-check-mode-does-not-modify-file ()
@@ -859,3 +967,134 @@ heading, so it is not flagged — only two-or-more indented stars are."
(provide 'test-lint-org)
;;; test-lint-org.el ends here
+
+;;; ---------------------------------------------------------------------------
+;;; task-missing-last-reviewed (claude-rules/todo-format.md)
+
+(ert-deftest lo-task-without-last-reviewed-is-judgment ()
+ "An open level-2 task with no :LAST_REVIEWED: is flagged."
+ (let* ((out (lo-test--run "* Open Work\n** TODO [#B] A task :feature:\nBody.\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-task-with-last-reviewed-is-clean ()
+ "A task carrying the property is not flagged."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n"
+ "Body.\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-task-last-reviewed-accepts-org-timestamp ()
+ "The org-native [YYYY-MM-DD Day] form counts, matching the staleness script."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: [2026-07-23 Thu]\n:END:\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-done-task-without-last-reviewed-is-clean ()
+ "Completed tasks leave the review pool, so they are never flagged."
+ (let* ((out (lo-test--run (concat "* Open Work\n** DONE [#B] A task :feature:\n"
+ "CLOSED: [2026-07-23 Thu]\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-subtask-without-last-reviewed-is-clean ()
+ "Only level-2 tasks are in the review pool; deeper headings are not."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] Parent :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n"
+ "*** TODO A sub-task\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-cookieless-task-without-last-reviewed-is-clean ()
+ "The staleness script selects on a priority cookie, so match that scope."
+ (let* ((out (lo-test--run "* Open Work\n** TODO Manual testing and validation\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-verify-task-without-last-reviewed-is-judgment ()
+ "VERIFY is in the review pool too."
+ (let* ((out (lo-test--run "* Open Work\n** VERIFY [#B] Waiting on Craig\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+;;; ---------------------------------------------------------------------------
+;;; todo-format checkers skip docs/specs/ files (claude-rules/todo-format.md)
+;;
+;; The four todo-format-family checkers encode todo.org completion conventions.
+;; A spec legitimately uses ** DONE <decision> with no CLOSED cookie and
+;; ** <dated> — <who> review-history headings, so those checkers misfire on
+;; every spec. They must skip any file under a docs/specs/ path segment.
+
+(defun lo-test--run-at (relpath content)
+ "Write CONTENT to <tmpdir>/RELPATH, run lint on it, return :issues.
+RELPATH is a relative path (may contain slashes) so a docs/specs/ segment
+can be exercised — the checkers key on the file's path, not just its name."
+ (let* ((root (make-temp-file "lo-test-root-" t))
+ (file (expand-file-name relpath root)))
+ (make-directory (file-name-directory file) t)
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert content))
+ (lo-test--reset)
+ (lo-process-file file)
+ (prog1 (list :issues lo-issues)
+ (lo-test--drop-buffer file)))
+ (delete-directory root t))))
+
+(defconst lo-test--spec-decisions
+ "* Decisions [1/1]\n** DONE Some decision\n- Context: x\n"
+ "A spec Decisions section: a level-2 DONE with no CLOSED cookie.")
+
+(defconst lo-test--spec-history
+ "* Review history\n** 2026-07-14 Tue @ 02:03:28 -0500 — Claude — responder\n- What: x\n"
+ "A spec review-history section: a level-2 dated header.")
+
+(ert-deftest lo-todo-checkers-fire-on-a-normal-org-file ()
+ "Baseline: the checkers DO fire on a non-spec path (the bug is scope, not silence)."
+ (let* ((out (lo-test--run-at "todo.org" lo-test--spec-decisions))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should (memq 'level2-done-without-closed cs))))
+
+(ert-deftest lo-level2-done-without-closed-skips-specs ()
+ (let* ((out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" lo-test--spec-decisions))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'level2-done-without-closed cs))))
+
+(ert-deftest lo-level2-dated-header-skips-specs ()
+ (let* ((out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" lo-test--spec-history))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'level-2-dated-header cs))))
+
+(ert-deftest lo-dated-log-active-timestamp-skips-specs ()
+ (let* ((c "* History\n** 2026-07-14 Tue @ 02:03:28 -0500 — did a thing\nSCHEDULED: <2026-07-20 Mon>\n")
+ (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'dated-log-heading-active-timestamp cs))))
+
+(ert-deftest lo-subtask-done-not-dated-skips-specs ()
+ (let* ((c "* Work\n** TODO Parent\n*** DONE A sub-decision\n")
+ (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'subtask-done-not-dated cs))))
+
+(ert-deftest lo-link-checks-still-fire-on-specs ()
+ "Only the todo-format family is scoped out; a broken link in a spec still flags."
+ (let* ((c "* X\n[[file:does-not-exist-xyz.org][link]]\n")
+ (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should (memq 'link-to-local-file cs))))
+
+(ert-deftest lo-task-missing-last-reviewed-skips-specs ()
+ "The fifth todo-format checker (added 2026-07-23) skips specs too — a spec's
+phases section may carry ** TODO [#x] items that aren't backlog tasks."
+ (let* ((c "* Implementation phases\n** TODO [#B] Phase one\nBody.\n")
+ (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'task-missing-last-reviewed cs)))
+ ;; And still fires on a normal file.
+ (let* ((c "* Work\n** TODO [#B] Real backlog task\nBody.\n")
+ (out (lo-test--run-at "todo.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should (memq 'task-missing-last-reviewed cs))))
diff --git a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
index 838913f..a92d238 100644
--- a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
+++ b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
@@ -516,6 +516,12 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat
.gitignore contents or nil), :archive-ignored (whether git ignores the archive),
:archive-exists."
(let* ((root (make-temp-file "tc-git-" t))
+ ;; Private backup dir: this helper writes a file literally named
+ ;; todo.org and runs a real (non-check) pass, so without this its
+ ;; backup lands in the shared temp dir under the exact production
+ ;; name and is indistinguishable from a real one.
+ (temporary-file-directory
+ (file-name-as-directory (make-temp-file "tc-git-bk-" t)))
(todo (expand-file-name "todo.org" root))
(archive (expand-file-name "archive/task-archive.org" root))
(gi (expand-file-name ".gitignore" root)))
@@ -536,7 +542,8 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat
:archive-ignored
(eq 0 (call-process "git" nil nil nil "check-ignore" "-q" archive))
:archive-exists (file-readable-p archive)))
- (delete-directory root t))))
+ (delete-directory root t)
+ (delete-directory temporary-file-directory 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
@@ -1073,3 +1080,97 @@ line) is left untouched — the strip stops at the first non-planning line."
(provide 'test-todo-cleanup)
;;; test-todo-cleanup.el ends here
+
+;;; ---------------------------------------------------------------------------
+;;; Backup before mutating (parity with lint-org.el / wrap-org-table.el)
+;;
+;; todo-cleanup rewrites todo.org in place and left no copy behind, while both
+;; sibling org-mutators back up to /tmp first. It is also the one that runs most
+;; often (every wrap, every sentry fire). Emacs's own backup does not fire under
+;; --batch -q, so there was genuinely no undo short of git.
+
+(ert-deftest tc-backup-written-before-a-real-mutation ()
+ "A real (non-check) run leaves a copy holding the pre-edit content.
+
+`temporary-file-directory' is rebound to a private dir for the duration: the
+backup name derives from the *file's* basename, and the real todo.org shares
+that basename, so a live sentry run writing /tmp/todo.org.before-todo-cleanup.*
+would otherwise be indistinguishable from this test's own artifact. The first
+version of this test globbed the shared /tmp and passed only until a real run
+created one (2026-07-24)."
+ (let* ((dir (make-temp-file "tc-backup-" t))
+ (bdir (file-name-as-directory (make-temp-file "tc-bk-" t)))
+ (file (expand-file-name "todo.org" dir))
+ (before "* P Open Work\n** TODO [#B] parent\n*** DONE a subtask\nCLOSED: [2026-07-01 Tue]\n"))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert before))
+ (let ((tc-check-only nil)
+ (tc-convert-subtasks t)
+ (temporary-file-directory bdir))
+ (tc-process-file file))
+ (let ((backups (file-expand-wildcards
+ (concat bdir "todo.org.before-todo-cleanup.*"))))
+ (should backups)
+ (should (string-match-p
+ "a subtask"
+ (with-temp-buffer (insert-file-contents (car backups))
+ (buffer-string))))))
+ (delete-directory dir t)
+ (delete-directory bdir t))))
+
+(ert-deftest tc-no-backup-in-check-mode ()
+ "--check writes nothing, so it must not leave a backup either.
+Uses a private `temporary-file-directory' for the same isolation reason."
+ (let* ((dir (make-temp-file "tc-backup-" t))
+ (bdir (file-name-as-directory (make-temp-file "tc-bk-" t)))
+ (file (expand-file-name "todo.org" dir)))
+ (unwind-protect
+ (progn
+ (with-temp-file file
+ (insert "* P Open Work\n** TODO [#B] parent\n*** DONE sub\nCLOSED: [2026-07-01 Tue]\n"))
+ (let ((tc-check-only t)
+ (tc-convert-subtasks t)
+ (temporary-file-directory bdir))
+ (tc-process-file file))
+ (should-not (file-expand-wildcards
+ (concat bdir "todo.org.before-todo-cleanup.*"))))
+ (delete-directory dir t)
+ (delete-directory bdir t))))
+
+(ert-deftest tc-backup-never-overwrites-an-earlier-one ()
+ "Two invocations in the same second must not collapse to one backup.
+
+open-tasks.org runs --convert-subtasks then --archive-done back to back, each
+a sub-second batch run. With a second-resolution stamp and copy-file's
+OK-IF-ALREADY-EXISTS, the second invocation overwrote the first's backup with
+already-mutated content, so the true pre-session original was unrecoverable —
+the exact state the backup exists to preserve (found 2026-07-24 in review)."
+ (let* ((dir (make-temp-file "tc-collide-" t))
+ (bdir (file-name-as-directory (make-temp-file "tc-cbk-" t)))
+ (file (expand-file-name "todo.org" dir))
+ (original (concat "* P Open Work\n** TODO [#B] parent\n*** DONE sub\n"
+ "CLOSED: [2026-07-01 Tue]\n"
+ "* P Resolved\n** DONE [#C] old\nCLOSED: [2025-01-01 Wed]\n")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert original))
+ ;; Two back-to-back invocations, as the shipped workflow does.
+ (let ((temporary-file-directory bdir))
+ (let ((tc-check-only nil) (tc-convert-subtasks t))
+ (tc-process-file file))
+ (let ((tc-check-only nil) (tc-convert-subtasks nil) (tc-archive-done t)
+ (tc-archive-retain-days nil))
+ (tc-process-file file)))
+ (let ((backups (file-expand-wildcards
+ (concat bdir "todo.org.before-todo-cleanup.*"))))
+ ;; Both invocations kept their own backup.
+ (should (= (length backups) 2))
+ ;; And one of them still holds the true original.
+ (should (cl-some (lambda (b)
+ (string= original
+ (with-temp-buffer (insert-file-contents b)
+ (buffer-string))))
+ backups))))
+ (delete-directory dir t)
+ (delete-directory bdir t))))
diff --git a/claude-templates/.ai/scripts/tests/test_cj_remove_block.py b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py
index 2c8dade..3cdee46 100644
--- a/claude-templates/.ai/scripts/tests/test_cj_remove_block.py
+++ b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py
@@ -14,6 +14,34 @@ import pytest
SCRIPT = Path(__file__).parent.parent / "cj-remove-block.py"
+@pytest.fixture(autouse=True)
+def isolated_tmpdir(tmp_path, monkeypatch):
+ """Give every test in this module a private TMPDIR.
+
+ The script backs up to the system temp dir under a name derived from the
+ edited file's BASENAME. The real todo.org shares that basename, so any test
+ operating on a fixture named todo.org writes something indistinguishable
+ from a production backup — and an earlier version of this file globbed the
+ shared /tmp and unlinked every match, so a routine `make test` destroyed
+ Craig's real backups (found in review, 2026-07-24).
+
+ Isolating at module scope rather than per-test is deliberate: the same bug
+ was fixed once in the elisp sibling and left here, so relying on each new
+ test to remember is exactly how it recurred. Autouse makes it structural.
+ """
+ d = tmp_path / "_tmpdir"
+ d.mkdir()
+ # TMPDIR covers subprocess invocations of the script.
+ monkeypatch.setenv("TMPDIR", str(d))
+ # tempfile.gettempdir() caches its answer on first call, so a test that
+ # loads the module in-process would keep writing to the real /tmp no matter
+ # what TMPDIR says. Override the cache too — this is the gap that made the
+ # env-var-only version still leak one backup per suite run.
+ import tempfile as _tempfile
+ monkeypatch.setattr(_tempfile, "tempdir", str(d))
+ return d
+
+
@pytest.fixture
def run_remove(tmp_path):
"""Write content to a temp org file, run cj-remove-block, return new contents."""
@@ -155,3 +183,142 @@ class TestCjRemoveBlockSafety:
err, post_content = run_remove_expecting_failure(original, start=4, end=2)
assert err.returncode != 0
assert post_content == original
+
+
+class TestMultiBlockRangeRefused:
+ """The validation exists to catch a drifted range, but it only checked the
+ first and last lines of that range. A span from one block's opening fence to
+ a LATER block's closing fence passed, and the removal silently deleted every
+ line between — real prose, headings, whole tasks — with a zero exit. Drift is
+ the skill's normal operating mode (respond-to-cj-comments edits the file as it
+ processes, and a file under cj review usually holds several blocks), so this
+ is the exact scenario the check was written for. Reproduced 2026-07-24."""
+
+ TWO_BLOCKS = (
+ "* Alpha\n"
+ "#+begin_src cj:\n"
+ "note A\n"
+ "#+end_src\n"
+ "KEEP THIS LINE\n"
+ "* Beta\n"
+ "#+begin_src cj:\n"
+ "note B\n"
+ "#+end_src\n"
+ )
+
+ def test_range_spanning_two_blocks_is_refused(self, run_remove_expecting_failure):
+ # Lines 2..9: block one's opener through block two's closer.
+ err, content = run_remove_expecting_failure(self.TWO_BLOCKS, 2, 9)
+ assert err.returncode == 1
+ assert "KEEP THIS LINE" in content, "content between the blocks was destroyed"
+ assert "* Beta" in content, "a heading between the blocks was destroyed"
+
+ def test_refusal_names_the_reason(self, run_remove_expecting_failure):
+ err, _ = run_remove_expecting_failure(self.TWO_BLOCKS, 2, 9)
+ assert "more than one" in err.stderr.decode().lower()
+
+ def test_a_correct_single_block_range_still_removes(self, run_remove):
+ # The fix must not over-tighten: the legitimate range still works.
+ out = run_remove(self.TWO_BLOCKS, 2, 4)
+ assert "note A" not in out
+ assert "KEEP THIS LINE" in out
+ assert "note B" in out, "the second block must be untouched"
+
+ def test_a_nested_end_src_inside_the_range_is_refused(self, run_remove_expecting_failure):
+ # Any #+end_src before the final line means the range covers >1 block.
+ content = (
+ "#+begin_src cj:\n"
+ "a\n"
+ "#+end_src\n"
+ "middle\n"
+ "#+begin_src cj:\n"
+ "b\n"
+ "#+end_src\n"
+ )
+ err, after = run_remove_expecting_failure(content, 1, 7)
+ assert err.returncode == 1
+ assert "middle" in after
+
+
+class TestSafeMutation:
+ """The script rewrites Craig's org files (todo.org, notes.org). It wrote with
+ a bare write_text, which truncates the target on open, and took no backup —
+ so a mid-write failure left the file truncated with no copy to recover from.
+ lint-org.el, the other tool that mutates these files, backs up to a temp dir
+ first. Match that, and make the write atomic.
+
+ Every test here redirects TMPDIR to a private directory. The backup name
+ derives from the file's basename, and the real todo.org shares it, so a test
+ globbing the shared temp dir cannot tell its own artifact from a genuine
+ backup — and an earlier version of this class globbed /tmp and unlinked every
+ match, so a routine `make test` destroyed real backups (found in review,
+ 2026-07-24). Never glob or delete across the shared temp dir."""
+
+ ONE_BLOCK = "* T\n#+begin_src cj:\nnote\n#+end_src\nkeep\n"
+
+ def test_a_backup_is_written_before_mutating(self, tmp_path):
+ import subprocess, glob, os
+ bdir = tmp_path / "bk"
+ bdir.mkdir()
+ f = tmp_path / "todo.org"
+ f.write_text(self.ONE_BLOCK)
+ subprocess.run(
+ ["python3", str(SCRIPT), "--file", str(f), "--start", "2", "--end", "4"],
+ check=True, capture_output=True,
+ env={**os.environ, "TMPDIR": str(bdir)},
+ )
+ backups = glob.glob(str(bdir / "todo.org.before-cj-remove.*"))
+ assert backups, "no backup was written before mutating the org file"
+ assert "note" in Path(max(backups)).read_text()
+
+ def test_no_partial_file_when_the_write_fails(self, tmp_path, monkeypatch):
+ import importlib.util
+ spec = importlib.util.spec_from_file_location("crb", SCRIPT)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ bdir = tmp_path / "bk"
+ bdir.mkdir()
+ monkeypatch.setenv("TMPDIR", str(bdir))
+ f = tmp_path / "todo.org"
+ f.write_text(self.ONE_BLOCK)
+ def boom(*a, **k):
+ raise OSError("disk full")
+ monkeypatch.setattr(mod.os, "replace", boom)
+ with pytest.raises(OSError):
+ mod.remove_range(f, 2, 4)
+ # The original survives intact — no truncation, no partial.
+ assert f.read_text() == self.ONE_BLOCK
+
+
+class TestBackupNeverOverwrites:
+ """Same defect class as todo-cleanup's, and more reachable here: the
+ respond-to-cj-comments skill removes several annotations in quick
+ succession, so a second-resolution stamp collides and the later backup
+ overwrote the earlier one with already-mutated content."""
+
+ TWO_BLOCKS = (
+ "* A\n#+begin_src cj:\nfirst\n#+end_src\n"
+ "* B\n#+begin_src cj:\nsecond\n#+end_src\n"
+ )
+
+ def test_consecutive_removals_each_keep_a_backup(self, tmp_path, monkeypatch):
+ import subprocess, glob
+ bdir = tmp_path / "bk"
+ bdir.mkdir()
+ monkeypatch.setenv("TMPDIR", str(bdir))
+ f = tmp_path / "todo.org"
+ f.write_text(self.TWO_BLOCKS)
+ original = f.read_text()
+ # Remove the second block, then the first — back to back, same second.
+ subprocess.run(["python3", str(SCRIPT), "--file", str(f),
+ "--start", "6", "--end", "8"],
+ check=True, capture_output=True,
+ env={**__import__("os").environ, "TMPDIR": str(bdir)})
+ subprocess.run(["python3", str(SCRIPT), "--file", str(f),
+ "--start", "2", "--end", "4"],
+ check=True, capture_output=True,
+ env={**__import__("os").environ, "TMPDIR": str(bdir)})
+ backups = glob.glob(str(bdir / "todo.org.before-cj-remove.*"))
+ assert len(backups) == 2, f"expected 2 backups, got {len(backups)}"
+ contents = [Path(b).read_text() for b in backups]
+ assert original in contents, "no backup holds the true original"
diff --git a/claude-templates/.ai/scripts/tests/test_inbox_send.py b/claude-templates/.ai/scripts/tests/test_inbox_send.py
index f75d7a1..9b0a8c6 100644
--- a/claude-templates/.ai/scripts/tests/test_inbox_send.py
+++ b/claude-templates/.ai/scripts/tests/test_inbox_send.py
@@ -476,3 +476,117 @@ class TestFilenameCollisions:
assert len(files) == 2
bodies = "".join(f.read_text() for f in files)
assert "message one" in bodies and "message two" in bodies
+
+
+class TestAtomicWrite:
+ """A send wrote straight to the destination path in another project's
+ inbox/, and write_text truncates on open, so any mid-write failure left a
+ zero-byte .org there. inbox-status counts that phantom as a pending
+ handoff, blocking a turn in the receiving project over a file with no
+ content (2026-07-23). The write must be atomic: the inbox sees a complete
+ file or nothing."""
+
+ def test_send_text_writes_utf8(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ # An em dash and an accented char — both non-ASCII.
+ dest = mod.send_text(inbox, "accent café and dash — here", "src", None, now)
+ # Reading as utf-8 must round-trip; a locale-encoded write would raise
+ # under a C locale, and reading back proves the bytes are utf-8.
+ assert "—" in dest.read_text(encoding="utf-8")
+
+ def test_send_text_no_partial_on_write_failure(self, tmp_path, monkeypatch):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ # Force the atomic finalize to fail after the temp file is written.
+ def boom(*a, **k):
+ raise OSError("disk full")
+ monkeypatch.setattr(mod.os, "replace", boom)
+ with pytest.raises(OSError):
+ mod.send_text(inbox, "a message that should never half-land", "src", None, now)
+ # No phantom, no leftover temp: the inbox is empty.
+ assert list(inbox.iterdir()) == []
+
+ def test_send_text_leaves_no_temp_on_success(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ dest = mod.send_text(inbox, "clean send", "src", None, now)
+ assert list(inbox.iterdir()) == [dest]
+
+ def test_send_file_no_partial_on_write_failure(self, tmp_path, monkeypatch):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ src = tmp_path / "note.org"
+ src.write_text("body")
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ def boom(*a, **k):
+ raise OSError("disk full")
+ monkeypatch.setattr(mod.os, "replace", boom)
+ with pytest.raises(OSError):
+ mod.send_file(inbox, src, "src", None, now)
+ assert list(inbox.iterdir()) == []
+
+ def test_send_file_leaves_no_temp_on_success(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ src = tmp_path / "note.org"
+ src.write_text("payload")
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ dest = mod.send_file(inbox, src, "src", None, now)
+ assert list(inbox.iterdir()) == [dest]
+ assert dest.read_text() == "payload"
+
+
+class TestSmallerDefects:
+ """Two low-severity defects found reading inbox-send during the 2026-07-23
+ sweep: an unreadable source raised an uncaught traceback instead of the
+ clean error every other failure path produces, and a roots config naming
+ both a parent and one of its children listed the same project twice."""
+
+ def test_unreadable_source_gives_clean_error_not_traceback(
+ self, project_root, run_script, tmp_path
+ ):
+ project_root("sender")
+ project_root("receiver")
+ roots = [tmp_path / "projects"]
+ src = tmp_path / "secret.bin"
+ src.write_text("x")
+ src.chmod(0o000)
+ try:
+ result = run_script(
+ ["receiver", "--file", str(src)],
+ cwd=tmp_path / "projects" / "sender",
+ roots=roots,
+ expect_failure=True,
+ )
+ finally:
+ src.chmod(0o644)
+ assert result.returncode == 1
+ # The clean "inbox-send: <message>" shape, not a Python traceback.
+ assert result.stderr.startswith("inbox-send:")
+ assert "Traceback" not in result.stderr
+
+ def test_discover_projects_dedupes_parent_and_child_root(self, tmp_path):
+ mod = _load_module()
+ # A project directory, reachable both as a child of its parent root and
+ # as a root in its own right.
+ parent = tmp_path / "projects"
+ proj = parent / "app"
+ (proj / ".ai").mkdir(parents=True)
+ (proj / "inbox").mkdir()
+ found = mod.discover_projects([parent, proj])
+ resolved = [p.resolve() for p in found]
+ assert resolved.count(proj.resolve()) == 1
diff --git a/claude-templates/.ai/scripts/tests/test_route_recommend.py b/claude-templates/.ai/scripts/tests/test_route_recommend.py
index acc4755..2ec900a 100644
--- a/claude-templates/.ai/scripts/tests/test_route_recommend.py
+++ b/claude-templates/.ai/scripts/tests/test_route_recommend.py
@@ -122,3 +122,31 @@ def test_cli_exclude_drops_current_project(tmp_path):
r = _run(["--exclude", "foo"], roots=[tmp_path / "projects"], item="fix the foo widget")
assert r.returncode == 0
assert r.stdout.strip() == "none"
+
+
+# ----------------------------------------------------------------------
+# Duplicate candidate names
+#
+# Projects are collapsed to bare basenames, so two projects sharing a basename
+# across roots (~/code/notes and ~/projects/notes) appear twice in the candidate
+# list. Both literal-match, recommend read len(strong) > 1 as an ambiguous tie,
+# and a correct strong match was downgraded to weak. Latent when discovered
+# 2026-07-24 (27 projects, 27 distinct basenames) but real.
+# ----------------------------------------------------------------------
+
+def test_duplicate_candidate_name_keeps_strong_confidence():
+ assert rr.recommend("fix the notes thing", ["notes", "other"]) == ("notes", "strong")
+ # The same name twice must not read as a tie.
+ assert rr.recommend("fix the notes thing", ["notes", "notes", "other"]) == ("notes", "strong")
+
+
+def test_genuine_ambiguity_still_downgrades():
+ # Two DIFFERENT projects both matching is a real tie and stays weak — the
+ # dedupe must collapse identical names only, never real ambiguity.
+ dest, conf = rr.recommend("notes and other both", ["notes", "other"])
+ assert conf == "weak"
+
+
+def test_duplicates_do_not_change_the_chosen_destination():
+ dest, _ = rr.recommend("fix the notes thing", ["notes", "notes"])
+ assert dest == "notes"
diff --git a/claude-templates/.ai/scripts/todo-cleanup.el b/claude-templates/.ai/scripts/todo-cleanup.el
index c4a87de..cb333e2 100644
--- a/claude-templates/.ai/scripts/todo-cleanup.el
+++ b/claude-templates/.ai/scripts/todo-cleanup.el
@@ -100,6 +100,12 @@
;; --check-child-priority is the report-only alias for --sync-child-priority
;; --check.
+;; Before any modification a backup is copied to
+;; /tmp/<basename>.before-todo-cleanup.<YYYYMMDD-HHMMSS>
+;; matching lint-org.el and wrap-org-table.el. Skipped under --check, which
+;; writes nothing.
+;;
+
(require 'org)
(require 'cl-lib)
(require 'calendar)
@@ -832,9 +838,37 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors
;;; ---------------------------------------------------------------------------
;;; Driver + reporting
+(defun tc--backup (file)
+ "Copy FILE to /tmp before any modification. Skipped in --check mode.
+
+Matches `lint-org.el' and `wrap-org-table.el', the other tools that rewrite
+these org files. todo-cleanup runs the most often of the three (every wrap,
+every sentry fire), and Emacs's own backup does not fire under --batch -q, so
+without this a mechanical rewrite has no undo short of git — which recovers
+only to the last commit and loses intra-session work."
+ (let* ((base (format "%s%s.before-todo-cleanup.%s"
+ temporary-file-directory
+ (file-name-nondirectory file)
+ (format-time-string "%Y%m%d-%H%M%S")))
+ (backup base)
+ (n 2))
+ ;; Never overwrite an earlier backup. A second-resolution stamp collides
+ ;; when two invocations run back to back, which the shipped workflow does
+ ;; (open-tasks.org runs --convert-subtasks then --archive-done, each a
+ ;; sub-second batch run). Overwriting there replaces the true pre-session
+ ;; original with already-mutated content — losing exactly what the backup
+ ;; exists to preserve. Suffix instead, so every invocation keeps its own.
+ (while (file-exists-p backup)
+ (setq backup (format "%s-%d" base n))
+ (setq n (1+ n)))
+ (copy-file file backup nil)
+ backup))
+
(defun tc-process-file (file)
(setq tc-current-file (file-name-nondirectory file))
(setq tc-current-dir (file-name-directory (expand-file-name file)))
+ (unless tc-check-only
+ (tc--backup file))
(with-current-buffer (find-file-noselect file)
(org-mode)
(cond
diff --git a/claude-templates/.ai/workflows/INDEX.org b/claude-templates/.ai/workflows/INDEX.org
index 02c4264..f18d953 100644
--- a/claude-templates/.ai/workflows/INDEX.org
+++ b/claude-templates/.ai/workflows/INDEX.org
@@ -113,7 +113,7 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e
- Situational triggers: "broadcast the <event> to all projects", "broadcast that <situation>", "let every project know I'll be away ..."
- =flashcard-review.org= — review an org-drill flashcard file, restructure cards to question-form headings (no answer hints), audit content accuracy against project source-of-truth via subagent, rewrite source preserving SRS state, regenerate the Anki =.apkg= to =~/sync/phone/anki/=. Person cards use "Who is X? Tell me about their Y."; talking-points cards stay as-is. Script behavior: =flashcard-to-anki.py= strips =:PROPERTIES:= drawers + =SCHEDULED:= / =DEADLINE:= planning lines from Anki output.
- Triggers: "review the flashcards", "update the flashcards", "review the drill deck", "update the drill deck", "refresh the Anki cards", "let's run the flashcard-review workflow"
-- =page-me.org= — set a timed notification (desktop =notify=; phone via =agent-page= when Craig is away).
+- =page-me.org= — set a timed notification. "page me" desktop =notify=, "text me" phone via =agent-text=, "text and page me" both.
- Triggers: anything containing the word "page" used as a verb ("page me", "page me in 10 minutes", "page me at 3pm", "page my phone")
- =status-check.org= — proactive long-running-job updates.
- Triggers: "keep me posted on this", "provide status checks on this job", "let me know when it's done", "monitor this for me". Auto: any job estimated 10+ min.
diff --git a/claude-templates/.ai/workflows/page-me.org b/claude-templates/.ai/workflows/page-me.org
index bfa92c6..7a3b792 100644
--- a/claude-templates/.ai/workflows/page-me.org
+++ b/claude-templates/.ai/workflows/page-me.org
@@ -13,9 +13,17 @@ Uses the =notify= command (info type) for consistent notifications across all AI
Craig says *"page me"* (or variations like "page me in 10 minutes", "page me at 3pm").
-The word "page" is the trigger for this workflow. It means: set a timed notification.
+The word "page" is the trigger for this workflow. It means: set a timed notification on the *desktop* channel (=notify=).
-Previously called "set-alarm" -- renamed to "page-me" for a distinctive, short trigger phrase that won't collide with common words like "remind" or "alert."
+Two sibling triggers pick a different channel; the timed =at= machinery below is identical for all three, only the fired command changes:
+
+- *"page me"* — desktop =notify= (this workflow's default).
+- *"text me"* — a Signal push to Craig's phone via =agent-text= (the away channel).
+- *"text and page me"* — both, for when he might be either place.
+
+Scope the triggers to the reflexive "me": "page me" and "text me", not a bare "page" or "text" in prose. The full channel vocabulary lives in protocols.org "Reaching Craig".
+
+"page" was chosen (renamed from the old "set-alarm") for a distinctive, short trigger that won't collide with common words like "remind" or "alert".
* Problem We're Solving
@@ -113,18 +121,18 @@ notify info "Page" "Your message here" --persist
The =--persist= flag keeps the notification on screen until manually dismissed. All page-me notifications should use =--persist= by default.
-** Paging Craig's phone (away from the machine)
+** Texting Craig's phone (the "text me" channel)
-The timed =notify= alarm above is the desktop channel. When Craig is away from the machine (or asks to be paged "on my phone"), use the agent pager instead — a Signal push to his phone from any machine or agent runtime:
+The timed =notify= alarm above is the desktop channel. When Craig says "text me" (or a run expects him away from the machine), use =agent-text= instead, a Signal push to his phone from any machine or agent runtime:
#+begin_src bash
-agent-page "Build finished — ready for your eyes"
+agent-text "Build finished, ready for your eyes"
-# Timed phone page: same at-daemon pattern, different channel
-echo "agent-page 'Meeting starts in 5'" | at 3:25pm
+# Timed phone message: same at-daemon pattern, different channel
+echo "agent-text 'Meeting starts in 5'" | at 3:25pm
#+end_src
-Channel selection and the pager's mechanics live in protocols.org "Paging Craig — the agent pager". When in doubt, fire both: the desktop notification persists for whenever he returns, the phone push reaches him now.
+Channel selection and the mechanics live in protocols.org "Reaching Craig". On "text and page me", fire both: the desktop notification persists for whenever he returns, the phone push reaches him now.
** Managing Alarms
diff --git a/claude-templates/.ai/workflows/sentry.org b/claude-templates/.ai/workflows/sentry.org
index 6e70402..e25ca39 100644
--- a/claude-templates/.ai/workflows/sentry.org
+++ b/claude-templates/.ai/workflows/sentry.org
@@ -4,7 +4,7 @@
* Overview
-Sentry is an interval loop that keeps a project's hygiene current while Craig is away. Each fire walks a fixed list of hygiene passes — roam pull, inbox zero, triage, todo cleanup, task audit, working-files hygiene, spec board, link integrity, git health, prep freshness — and commits each pass's writing to a throwaway daily branch. Nothing pushes. In the morning Craig reviews the branch, squash-merges what he wants, and deletes it.
+Sentry is an interval loop that keeps a project's hygiene current while Craig is away. Each fire walks a fixed list of passes — roam pull, inbox zero, triage (no mail or messengers), todo cleanup, task audit, working-files hygiene, spec board, link integrity, git health, prep freshness, bug and refactor finding, and (opt-in) solo-task implementation — and commits each pass's writing to a throwaway daily branch. Nothing pushes. In the morning Craig reviews the branch, squash-merges what he wants, and deletes it.
The design goal is a project that greets the morning already tidy, with every judgment call and every destructive action parked in an approval queue rather than executed unattended. Sentry does the mechanical sweeping; Craig does the deciding.
@@ -38,6 +38,12 @@ If the marker is absent or not =yes=, decline to start and name the marker:
No half-running mode: a project without the grant doesn't run sentry's read-only passes either. The grant is one line away, so this is a deliberate opt-in, not a barrier.
+A second, *independent* marker gates the solo-task implementation pass (pass 12):
+
+: :SENTRY_MAY_IMPLEMENT: yes
+
+=:COMMIT_AUTONOMY:= lets sentry commit its hygiene sweeps to the branch; =:SENTRY_MAY_IMPLEMENT:= additionally lets it implement solo, decision-free backlog tasks on the branch. The split exists because the two carry different morning costs: hygiene is a two-minute merge, implemented code is a review session. A project can run hygiene-only sentry without the implement pass, and most should until sentry has quiet weeks behind it. Absent =:SENTRY_MAY_IMPLEMENT:=, pass 12 skips; sentry still runs every other pass. Requires =:COMMIT_AUTONOMY:= alongside it — implementing implies committing.
+
* Entry — interactive, with Craig present
Craig types the sentry trigger, so the first moves run with him at the terminal. Do them in order; each gate that fails stops entry until Craig answers.
@@ -129,11 +135,11 @@ In order. Each names its detection probe. A pass whose probe fails is one skip l
2. *Inbox zero* — run =inbox.org= roam mode under the no-approvals contract: quick+solo+agreed items execute, shared-asset and convention proposals park (prepared diff, =VERIFY= task, sender reply) in the approval queue. Edits to =~/org/roam/inbox.org= take the roam-write lock + =capture-guard=. Probe: the roam clone or a project =inbox/= exists. Tidying the shared roam inbox is allowed from *any* project session, work included — it's housekeeping on a shared resource, not a durable KB-node write, so the work-denylist doesn't gate it (=knowledge-base.md=). Never park it as a cross-project boundary crossing.
-3. *Triage intake* — run =triage-intake.org=. Probe: the project has at least one *active* triage source — a project-specific plugin (=.ai/project-workflows/triage-intake.*.org=), or a non-empty =:TRIAGE_SOURCES:= declaration naming general plugins that exist. Mere presence of the template-synced general plugins does *not* activate the pass; a project that declares no sources probe-skips (see =docs/specs/2026-07-20-triage-source-activation-spec.org=). Destructive actions (deleting, archiving, sending) queue; they never fire unattended.
+3. *Triage intake — mail and messenger sources excluded.* Run =triage-intake.org=, loading only its non-mail, non-messenger source plugins (calendar, PR/ticketing). The mail and messenger plugins — cmail, any Gmail variant, Telegram, Signal, chat DMs — are never loaded by a sentry fire: Craig ruled 2026-07-21 that sentry doesn't check email or messengers. A manual "triage intake" still scans everything. Probe: the project has at least one *active* triage source that survives that exclusion — a project-specific plugin (=.ai/project-workflows/triage-intake.*.org=), or a non-empty =:TRIAGE_SOURCES:= declaration naming general plugins that exist. Mere presence of the template-synced general plugins does *not* activate the pass; a project that declares no sources, or whose only declared sources are mail or messengers, probe-skips (see =docs/specs/2026-07-20-triage-source-activation-spec.org=). Destructive actions (deleting, archiving, sending) queue; they never fire unattended.
-4. *Todo cleanup* — the =clean-todo.org= mechanics (hygiene pass + =--archive-done= + =--convert-subtasks=). Probe: a root =todo.org=.
+4. *Todo cleanup* — the =clean-todo.org= mechanics (hygiene pass + =--archive-done= + =--convert-subtasks=). Probe: a root =todo.org=. Note that =--archive-done= is not purely an org-file pass on its first run in a project: it creates =archive/task-archive.org= and appends a =.gitignore= entry, so it produces a real tracked-file commit and correctly trips the fire-end conditional suite. (archangel, first live run 2026-07-21.)
-5. *Task audit* — =task-audit.org=. Probe: a root =todo.org=. Priority regrades and consolidations queue for review; factual staleness fixes that are unambiguous execute.
+5. *Task audit* — the *mechanical subset* of =task-audit.org= hourly (staleness counts, structural checks, cookie recomputation); the judgment half (priority regrades, consolidations, merge candidates) runs *once per night* and queues its findings rather than repeating them every fire. Probe: a root =todo.org=. A full audit every hour is too heavy and re-surfaces the same judgment calls all night. (takuzu, first live run 2026-07-21.) Factual staleness fixes that are unambiguous still execute.
6. *Working-files hygiene* — flag =working/<slug>/= directories whose backing task is closed (a filing candidate per =working-files.md=). Probe: a =working/= directory exists. The filing itself queues (it's a judgment move).
@@ -145,7 +151,11 @@ In order. Each names its detection probe. A pass whose probe fails is one skip l
10. *Prep + symlink freshness* — stale daily-prep docs, broken symlinks. Probe: the prep dir / symlinks exist (work and home only, in practice).
-(KB lesson promotion — the proposal's eleventh pass — is deferred to vNext. An unattended judgment pass writing to the shared knowledge base waits until sentry has quiet weeks behind it and a designed detection heuristic. See the filed lesson-detection-heuristic task.)
+11. *Bug and refactor finding* — hunt for real bugs and worthwhile refactoring opportunities in the project's codebase: static analysis (=shellcheck= for shell, the project's own linters for its languages), config sanity checks, plus one targeted code-reading area per fire. Rotate the area across fires and name it in the digest, so coverage accumulates over a night instead of re-reading the same corner. Randomized property sweeps (generate-and-verify against an engine's own invariants) are good quiet-fire work here, reaching past a frozen test corpus. Expect the pass to go honestly quiet after the first few fires find the standing defects; a quiet hunt is a result, not a failure. (takuzu, first live run 2026-07-21: three real fixes in the first four fires, then quiet.) This pass does *not* run the test suite — the entry baseline already ran it, and re-running it hourly is anti-pattern 5; read the entry result instead. Probe: the project carries a codebase — source under version control beyond its org and tooling files. File each verified bug as a graded task in =todo.org= per the severity × frequency matrix (=todo-format.md=), and each refactoring opportunity as a =:refactor:= task, deduped against existing tasks; an unverifiable suspicion is a digest line, not a task. *Find, never fix in this pass* — the finding files a task and stops. A fix happens only in the opt-in implementation pass below, and only after the finding is a filed task that pass then re-verifies from scratch (see the premise rule there). A freshly-found "bug" can be a misread — one was filed and retracted two fires apart on 2026-07-23 — so the file-then-verify-then-fix pipeline is deliberate: the task is the checkpoint, not a same-breath fix. (Added at Craig's order 2026-07-21, first dogfooded in dotfiles; refactor-finding added 2026-07-24.)
+
+12. *Solo-task implementation (opt-in — =:SENTRY_MAY_IMPLEMENT:=)* — work the backlog's solo, decision-free tasks on the branch. Probe: =.ai/notes.org= Workflow State carries =:SENTRY_MAY_IMPLEMENT: yes= *and* the project holds =:COMMIT_AUTONOMY:= (the implement pass commits). Absent the marker, skip — this pass is off by default, because it turns the morning from a two-minute merge into a code review, and that's the project owner's call. When on: invoke =work-the-backlog.org= under its unattended-loop contract (no pre-flight Q&A — there's no Craig overnight), eligibility =TODO= + =:solo:=, with the defer checklist deciding act-vs-file. The overnight-only tightening: only the *ready* bucket implements (clears every checklist item with zero open decisions); a task needing even one quick decision defers to a =VERIFY= rather than guessing, exactly as the loop caller already does. Commit each logical change to the sentry branch; *never push* — the morning review and merge is the gate, same as every other pass. The full quality bar holds (TDD, suite green before each commit, =/review-code=, =/voice=), and =/review-code= here runs the *premise check first*: reproduce the bug or confirm the problem is real before judging the diff. The review is the fact-checker that a filed claim never got, and it is what makes fixing-on-a-branch safe (Craig, 2026-07-24). A task that fails its premise check is not implemented — the finding was wrong, and that outcome is a digest line, not a commit. (Added at Craig's direction 2026-07-24: overnight implement-on-branch, gated and never-pushed.)
+
+(KB lesson promotion — the pass the original proposal listed eleventh — is deferred to vNext. An unattended judgment pass writing to the shared knowledge base waits until sentry has quiet weeks behind it and a designed detection heuristic. See the filed lesson-detection-heuristic task.)
* Fire-end — conditional suite, then the digest commit
@@ -176,6 +186,8 @@ Sentry never merges its own branch. In the morning Craig:
A bad night is discarded by deleting one branch — nothing reached main, nothing was pushed.
+In a project that gitignores =.ai/=, the whole spine is untracked, so quiet fires produce no commits at all and =git log main..sentry/<date>-<host>= understates the night's activity. There the anchor's heartbeat list is the only record of what fired. Read the anchor, not just the log. (archangel, first live run 2026-07-21.)
+
* Stop Sentry
Trigger: "stop sentry" (and synonyms above). Sentry owns its own shutdown:
@@ -200,7 +212,7 @@ Stopping sentry is the only way to reclaim the working tree mid-night. The entry
3. *Committing onto main* — every writing pass commits to the daily =sentry/*= branch. A fire that finds HEAD off the sentry branch skips rather than commits.
4. *Running a =git= write against =~/org/roam=* — roam-sync is the only committer. Sentry edits the tree under the roam-write lock and triggers the sync; it never commits or pushes roam.
5. *A per-pass suite run* — the suite runs at entry (baseline) and conditionally at fire-end (only when a pass touched non-org files). Hourly per-commit runs all night is the anti-pattern the suite policy exists to prevent.
-6. *Executing a judgment or destructive action unattended* — those queue for the morning with their exact command. The pass did its detection; Craig makes the call.
+6. *Executing a judgment or destructive action unattended* — those queue for the morning with their exact command. The pass did its detection; Craig makes the call. The one sanctioned exception is pass 12's solo-task implementation, and only because it inherits work-the-backlog's full defer checklist (data-loss and irreversible actions defer, never execute) plus a premise-verifying review, and it commits to the branch rather than acting on anything live.
7. *A silent skip* — inside a working fire, every skip writes a digest line naming why; a missing pass with no line reads as "ran clean" when it didn't. The one exception is not a violation: an all-quiet fire collapses to a single =sentry at HH:MM: nothing= heartbeat instead of one skip line per pass — the heartbeat is the explicit "nothing to do" record, per the silent-until-signal policy.
8. *Degrading a pass to a reduced form* — a pass runs fully or skips. No half-passes.
9. *Letting an unmerged branch stall silently* — after two consecutive unmerged-branch skips, the persistent desktop notify fires. Don't suppress it.
@@ -208,7 +220,7 @@ Stopping sentry is the only way to reclaim the working tree mid-night. The entry
* Living Document
-Sentry ships with ten mechanical passes and a deferred KB pass. The pass list, the interval default, and the queue-vs-execute line for each pass are the knobs most likely to move with dogfooding. Fold in what the live trial surfaces — a pass that queues too eagerly, a probe that misfires, a digest line that wants more detail. Refine as the signal arrives.
+Sentry ships with eleven finding/hygiene passes, one opt-in implementation pass, and a deferred KB pass. The pass list, the interval default, the =:SENTRY_MAY_IMPLEMENT:= default, and the queue-vs-execute line for each pass are the knobs most likely to move with dogfooding. The implement pass especially is new (2026-07-24) and unproven at scale — watch the corrections signal (work-the-backlog's metric for autonomous commits later reverted or hand-fixed) before widening it past the projects that opt in. Fold in what the live trial surfaces — a pass that queues too eagerly, a probe that misfires, a digest line that wants more detail. Refine as the signal arrives.
* History
diff --git a/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org
index 929d482..1dff1db 100644
--- a/claude-templates/.ai/workflows/startup.org
+++ b/claude-templates/.ai/workflows/startup.org
@@ -29,10 +29,16 @@ Inside a rulesets session, the project-repo refresh below covers this — the ru
#+begin_src bash
rs="$HOME/code/rulesets"
if [ -d "$rs/.git" ]; then
- if (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then
+ gate="$rs/claude-templates/bin/git-worktree-gate"
+ if [ -x "$gate" ] && "$gate" sync-safe "$rs"; then
+ (cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3
+ elif [ ! -x "$gate" ] \
+ && (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then
+ # Bootstrap fallback for a checkout old enough not to have the shared
+ # gate yet. The pull that follows installs it for subsequent starts.
(cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3
else
- echo "rulesets: dirty working tree — using as-is, skipping pull"
+ echo "rulesets: changes beyond untracked inbox deliveries — using as-is, skipping pull"
fi
else
echo "rulesets: not a git checkout — skipping"
@@ -40,11 +46,11 @@ fi
#+end_src
Behavior:
-- *Clean working tree* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance.
-- *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).
+- *Clean working tree, or untracked deliveries only beneath =inbox/=* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance. Inbox files are queue input, not source-tree work, and do not block other projects from receiving rulesets updates.
+- *Any staged or tracked change, dirty submodule, Git operation in progress, or untracked file outside =inbox/=* → 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.
+*Template-freshness policy (applies to every dirty-check in the synced workflows).* The shared =git-worktree-gate sync-safe= policy is the source of truth: untracked files beneath =inbox/= and gitignored files do not block a pull, fast-forward, or monitoring gate; every other staged, tracked, untracked, submodule, or in-progress-operation state does. Projects must not fall behind merely because somebody sent them a task, but an arbitrary scratch file is not silently treated as safe. One deliberate exception remains: the rsync WIP-guard below is narrower than the repository gate and counts untracked files within rulesets' own synced source paths, because an untracked half-written template is exactly the WIP it exists to hold back.
*** Install rulesets symlinks into ~/.claude (idempotent)
@@ -74,8 +80,11 @@ if [ -d .git ]; then
current=$(git symbolic-ref --short HEAD 2>/dev/null)
dirty=0
- if ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \
- || [ -n "$(git status --porcelain --untracked-files=no)" ]; then
+ gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+ if [ -x "$gate" ]; then
+ "$gate" sync-safe "$PWD" >/dev/null 2>&1 || dirty=1
+ elif ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \
+ || [ -n "$(git status --porcelain --untracked-files=no)" ]; then
dirty=1
fi
@@ -107,8 +116,8 @@ fi
#+end_src
Behavior, per branch:
-- *Behind only, current branch, clean tree* → =git merge --ff-only= advances HEAD.
-- *Behind only, current branch, dirty tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the dirty state.
+- *Behind only, current branch, sync-safe tree* → =git merge --ff-only= advances HEAD. An untracked =inbox/= delivery is sync-safe.
+- *Behind only, current branch, sync-blocking tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the reported state.
- *Behind only, non-checkout branch* → =git fetch . upstream:branch= advances the ref without touching the working tree.
- *Diverged* (ahead and behind) → leave alone. Surface for Craig to resolve. Don't auto-rebase or auto-merge.
- *Ahead only* or *up to date* → silent no-op.
diff --git a/claude-templates/.ai/workflows/triage-intake.org b/claude-templates/.ai/workflows/triage-intake.org
index 7451fed..55cc939 100644
--- a/claude-templates/.ai/workflows/triage-intake.org
+++ b/claude-templates/.ai/workflows/triage-intake.org
@@ -209,6 +209,22 @@ Auto mode runs as a =/loop= in the *live session*, not a detached cron job:
Running in the live session means MCP auth (Slack, Gmail, Linear) is inherited from the session — the headless-auth wall that blocks a detached cron run does not apply. A durable cross-session schedule is out of scope here; that belongs to the morning-ops orchestrator, which can later invoke auto mode's accumulate behavior as its triage limb. The close/stop commands below require a live session by design.
+*** Phone delivery — push each signal sweep via =agent-text=
+
+Auto mode exists for when Craig is away from the desk, so a sweep that surfaces something worth seeing is delivered to his phone, not just printed into a session he isn't watching. After a sweep that renders the full three sections — one with real deltas or an unacked-list change (see "End-of-sweep output" below) — send that same output to his phone over Signal with =agent-text=:
+
+#+begin_src bash
+agent-text "$SWEEP_SUMMARY"
+#+end_src
+
+The pushed text is the *fuller* three-section shape, not a terse one-liner: the per-source deltas, the responses-awaiting-acknowledgment list, and the timestamp, led by a ⚠ SCAN FAILED banner if any source failed.
+
+*Signal-only — never on a quiet sweep.* An empty sweep (the =triage intake at HH:MM: nothing= heartbeat) does *not* push to the phone. Silent-until-signal (see =docs/specs/2026-07-20-silent-until-signal-monitors-spec.org=) governs the phone channel too, so the phone stays silent until a sweep has real signal. The in-session heartbeat still prints as proof the loop ran; the phone is reserved for something that actually needs Craig. (Craig's ruling, 2026-07-20: the higher-cost channel doesn't buzz with "nothing.")
+
+If =agent-text= isn't on =PATH=, fall back to inline delivery and say so once.
+
+*Reply polling is deferred.* The send half ships here; polling the phone for Craig's replies (the =phone-recv= half of the retired ntfy design) waits on the reply-correlation follow-up. With the Signal account linked on more than one device, a reply fans out to every device and neither knows which page it answers — that has to be resolved before auto mode reads replies back. Until then auto mode pushes but does not poll, and Craig acts on a pushed summary from wherever he picks it up.
+
** Preconditions and Close-out
Auto mode borrows the inbox monitor-mode gates (=inbox.org= monitor mode): do not start on a dirty worktree or a red test suite — a close's batch commit would otherwise sweep up unrelated changes — and leave the tree clean and green when the loop stops. Surface a blocker with inline numbered options per =interaction.md= and wait.
@@ -226,7 +242,7 @@ Each sweep runs Phase 0 (load *both* plugin dirs — the loud requirement still
** End-of-sweep output — three sections, or one heartbeat
-*Silent-until-signal (see =docs/specs/2026-07-20-silent-until-signal-monitors-spec.org=).* An *empty sweep* — no deltas since the previous sweep and no change to the awaiting-acknowledgment list — collapses to a single heartbeat line and nothing else: =triage intake at HH:MM: nothing= (HH:MM local, from =date=). Detection still runs in full (Phase 0 plus the A-D scan, against the session's inherited MCP auth); only the output collapses, so a long unattended run stops filling the session with identical "no changes" blocks. A sweep with real deltas or an unacked-list change prints the full three sections below.
+*Silent-until-signal (see =docs/specs/2026-07-20-silent-until-signal-monitors-spec.org=).* An *empty sweep* — no deltas since the previous sweep and no change to the awaiting-acknowledgment list — collapses to a single heartbeat line and nothing else: =triage intake at HH:MM: nothing= (HH:MM local, from =date=). Detection still runs in full (Phase 0 plus the A-D scan, against the session's inherited MCP auth); only the output collapses, so a long unattended run stops filling the session with identical "no changes" blocks. A sweep with real deltas or an unacked-list change prints the full three sections below, and — when away — pushes them to Craig's phone via =agent-text= (see "Phone delivery" above). The empty-sweep heartbeat is never pushed.
1. *Deltas* — what changed since the *previous sweep* (the standard Phase C summary scoped to the inter-sweep delta).
2. *Responses awaiting your acknowledgment* — every Slack reply, email, or message directed at Craig that he hasn't acknowledged or had the agent answer. A *running list carried forward across sweeps* until Craig acks each item or closes the triage. An away user's first need is "who's waiting to hear back from me," which a delta-only sweep loses the moment it scrolls past.
@@ -424,6 +440,9 @@ Update the engine as the orchestration pattern evolves; update a plugin as its s
*** Updates and Learnings
+**** 2026-07-20: Phone delivery for signal sweeps (=agent-text=, send half)
+Auto mode now pushes a full-three-section sweep to Craig's phone over Signal via =agent-text=, the away-from-desk delivery the retired ntfy design carried before ntfy was torn down (2026-07-04). Transport is =agent-text= (the renamed Signal pager), not ntfy. Signal-only by Craig's 2026-07-20 ruling: a quiet sweep's =nothing= heartbeat never reaches the phone — silent-until-signal governs the phone channel too, so the higher-cost channel only fires when a sweep has real signal, while the in-session heartbeat stays as proof the loop ran. Falls back to inline when =agent-text= is absent. Only the send half ships; reply polling (the old =phone-recv=) waits on the reply-correlation follow-up, because a Signal reply fans out to every linked device and neither knows which page it answers.
+
**** 2026-07-18: Three-section digest + close-by-default (Phase C/D rewrite)
Craig's ruling after a 42h-gap sweep where the long-form report (top signals + per-source breakdown + 7-option action menu) was followed by "summarize the notable items" — and the digest that answered it was the report he wanted first. Phase C now renders ==TASKS== (work items needing Craig, solo-executable first, priority order) / ==FYI== (work context, no action owed) / ==MISC== (everything outside the project, actions stated inline), then exactly two options (close-and-file / close-and-execute-solo, the latter only when solo items exist), timestamp last. Per-source blocks and the itemized action menu are gone from the default surface (long form on request). Phase D became the close: it runs as the next action no matter what Craig replies (unless he explicitly holds), includes the mail hygiene on every scanned account without itemized confirmation, files the TASKS, clears resolved unacked items, advances the sentinel, and tears down started services. Solo = mechanical + standing-approved + no prose under Craig's name; prose sends and destructive non-mail actions stay gated. The stay-open-until-confirmed exit loop is retired. Same-day addendum: the "and reroute" modifier ("1 and reroute") — MISC items are surfaced-only by default (never filed to this project's todo.org); appending the modifier delivers each outside-project item to its owner's inbox via inbox-send per the cross-project rule.
diff --git a/claude-templates/.ai/workflows/work-the-backlog.org b/claude-templates/.ai/workflows/work-the-backlog.org
index ab24d3b..a0b24a8 100644
--- a/claude-templates/.ai/workflows/work-the-backlog.org
+++ b/claude-templates/.ai/workflows/work-the-backlog.org
@@ -70,6 +70,8 @@ A task is autonomous-safe when *both* hold. This layer is a lookup, not a judgme
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.
+Terminology: *speedrunnable means tagged =:solo:=*. It does not mean =:quick:= or require =:quick:solo:=. The =TODO= status check above is the execution-state gate over that speedrunnable set.
+
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).
@@ -152,7 +154,7 @@ With paging on, fire one page when the set is done or the cap is hit — end-of-
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. =notify= is the desktop paging surface; a run that expects Craig to be away also fires =agent-page= with the same message (the Signal phone channel — protocols.org "Paging Craig — the agent pager").
+=--persist= keeps it on screen until dismissed, and =info= is the notification urgency convention (persistent but never crash-scary). The notification 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. =notify= is the desktop channel (the "page me" surface); a run that expects Craig to be away also fires =agent-text= with the same message (the Signal phone channel, "text me"). See protocols.org "Reaching Craig".
* Metrics
diff --git a/claude-templates/.ai/workflows/wrap-it-up.org b/claude-templates/.ai/workflows/wrap-it-up.org
index 41f2e54..7d55a55 100644
--- a/claude-templates/.ai/workflows/wrap-it-up.org
+++ b/claude-templates/.ai/workflows/wrap-it-up.org
@@ -24,7 +24,7 @@ The wrap-up is complete when:
2. *File is archived.* =.ai/session-context.org= has been renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=. The old path no longer exists.
3. *todo.org is clean.* Cleanup script ran. Any auto-fixes are staged for the wrap-up commit. Orphan planning lines surfaced for manual fix if there are any.
4. *Linear board is honest* (skip if project doesn't use Linear). Any Dev-Review ticket whose PR has merged was moved to Done or PM Acceptance per the classification rule.
-5. *Git state is clean.* All changes committed + pushed to all remotes. Working tree clean.
+5. *Git state is certified clean.* All changes are committed + pushed to all remotes, =git-worktree-gate certify= succeeded at the current HEAD, and the working tree has no staged, unstaged, untracked, submodule, or in-progress-operation state.
6. *Valediction delivered.* Brief, warm closing with key accomplishments and reminders, ending with =session wrapped.= on its own line as the signoff marker.
The absence of =.ai/session-context.org= is the signal that the last session wrapped up cleanly. Its presence at session start means the previous session was interrupted.
@@ -189,6 +189,16 @@ Preview the moves without writing:
emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done --check todo.org
#+end_src
+*** Clear temp/
+
+#+begin_src bash
+[ -d temp ] && find temp -mindepth 1 -delete && echo "temp/ cleared"
+#+end_src
+
+=temp/= holds throwaway artifacts — discarded prototypes, scratch output, intermediate data (see =working-files.md=). It's gitignored in every project, so nothing here rides a commit and nothing is recoverable from git once deleted. Clearing it at wrap is what keeps ephemeral work from silting up across sessions, and it's the counterpart to =working/=, which is tracked and *never* cleared here.
+
+Two guards. Confirm before deleting if =temp/= holds anything a reasonable reader would call in-progress rather than throwaway — misfiled work belongs in =working/=, so move it there instead of deleting it. And skip the step entirely in a project where =temp/= is not gitignored, since that means the project is using the directory for something else.
+
*** Sync child priorities
#+begin_src bash
@@ -263,7 +273,7 @@ For an interactive walk of the judgments mid-day, run =/lint-org todo.org=.
*** Inbox sanity check (surface unprocessed handoffs)
-If the project has an =inbox/= directory, verify it holds nothing but =.gitkeep=, =lint-followups.org= (the lint-org pipeline file the next morning's daily-prep consumes), and any explicitly-deferred =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with a dirty inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
+If the project has an =inbox/= directory, verify it holds nothing but =.gitkeep=, =lint-followups.org= (the lint-org pipeline file the next morning's daily-prep consumes), and =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with an unprocessed inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
#+begin_src bash
unprocessed=$(find inbox -maxdepth 1 -type f \
@@ -272,7 +282,7 @@ unprocessed=$(find inbox -maxdepth 1 -type f \
! -name 'PROCESSED-*' \
2>/dev/null | wc -l)
if [ "$unprocessed" -gt 0 ]; then
- echo "wrap-up: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping, or explicitly defer each item with a one-line reason in the valediction."
+ echo "wrap-up blocked: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping."
find inbox -maxdepth 1 -type f \
! -name '.gitkeep' \
! -name 'lint-followups.org' \
@@ -281,7 +291,7 @@ if [ "$unprocessed" -gt 0 ]; then
fi
#+end_src
-If the count is zero or the project has no =inbox/= directory, the check is a silent no-op. If non-zero, the wrap is incomplete by default. The user resolves each item (process now, defer with reason in the valediction, or delete with rationale) before the validation checklist passes.
+If the count is zero or the project has no =inbox/= directory, the check is a silent no-op. If non-zero, the wrap is blocked. Process each item through its value-gate disposition, or delete it only when that workflow's rationale authorizes deletion, before continuing.
The check exempts =lint-followups.org= explicitly because lint-org runs earlier in the same wrap-up workflow and writes its judgment items to that file in =inbox/= by design. The file is a pipeline artifact for the next morning's =daily-prep=, not a handoff that needs the value gate.
@@ -491,17 +501,17 @@ Behavior:
git status --short
#+end_src
-*Default policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no "leave it alone" default — every leftover gets an active resolution. The only way for a file to stay dirty across the wrap is the user explicitly saying "defer this one, leave it dirty." Surface each leftover with a concrete recommendation; the user has to actively opt out for the dirt to persist.
+*Hard policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no deferral exception and no "wrapped with known changes" state: unresolved dirt means the session remains open and wrap-up does not occur.
This inverts the older "intentional carryover" default, which let pre-existing dirty state accumulate across sessions silently. Carryover that lives for days or weeks is almost always one of: a forgotten commit from a prior wrap, a stale change that should be discarded, or genuine in-flight work that needs an explicit stash/branch home. None of those should default to "leave it dirty."
**** Three kinds of leftover
-| Pattern | What it is | Recommended action (apply unless user defers) |
+| Pattern | What it is | Recommended action |
|---+---+---|
| Generated, runtime, or lock files that no human edits — e.g., =.claude/scheduled_tasks.lock=, =.pytest_cache/=, build outputs, IDE state, editor swap files | *Runtime artifact* — created by tooling or the harness, not by the user, and shouldn't be tracked | Add the matching pattern to =.gitignore= (project-level, not =~/.gitignore_global=). For tracked files, =git rm --cached <path>=. Stage =.gitignore= and any =rm --cached= changes in *one* follow-up commit (=chore: gitignore X=), push. Re-run =git status= to confirm clean. |
| Modified or created during the session but not staged into the wrap-up commit | *Forgotten change* — real session work that should have been in the wrap commit but missed it | Stage and create a follow-up commit. Don't =--amend= the wrap-up commit once pushed (diverging history without a clear win). Push the follow-up to all remotes. |
-| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, (d) move to a feature branch if it's longer-running, (e) user explicitly defers and accepts the dirt. Do not silently leave dirty. |
+| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, or (d) move to a feature branch if it's longer-running. Do not silently leave dirty. |
**** Per-file flow
@@ -509,18 +519,40 @@ For each leftover line in =git status --short=:
1. Identify which of the three kinds above it matches.
2. State what the file is (one line) and the recommended action.
-3. Apply the action unless the user explicitly defers.
-4. Re-run =git status --short= after each follow-up commit until empty (or until every remaining line is an explicit user-deferred entry).
+3. Apply the action when it is safe and authorized.
+4. Re-run =git status --short= after each follow-up commit until empty.
The pre-existing-dirt case (third row) is the one this rule most cares about. Treat each pre-existing-dirty file as a question that must get an answer this session, not as "carryover that's fine to inherit." A file that was dirty for a week before this session probably isn't going to get cleaner by waiting another week. Look at the diff, check the originating session's notes, and recommend a real resolution.
-**** When the user defers
+**** When cleanup cannot be completed
+
+Stop the wrap. Do not deliver the valediction, print =session wrapped.=, drop a teardown/shutdown sentinel, or describe the session as complete. Report:
+
+1. Every remaining path and its exact Git state.
+2. What the file is and why the agent cannot safely resolve it alone.
+3. The concrete action or decision Craig needs to provide to make the tree clean.
+
+An explicit decision to keep a file dirty changes the outcome from "wrapping" to "leaving the session interrupted." It never satisfies this workflow.
+
+*** Final clean-tree certificate — hard gate
+
+After all commits are pushed and every leftover appears resolved, run the shared gate:
+
+#+begin_src bash
+gate="$(command -v git-worktree-gate 2>/dev/null || true)"
+[ -n "$gate" ] || gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+if [ ! -x "$gate" ]; then
+ echo "wrap blocked: git-worktree-gate is unavailable; install rulesets tooling and retry"
+ exit 1
+fi
+"$gate" certify "$PWD"
+#+end_src
-If the user does say "leave this one dirty for now" after seeing the recommendation, that is fine — log the deferral in the valediction so the next session knows it was an explicit choice, not a miss. Format: "Deferred (per Craig's decision today): =path/to/file= — <one-line reason>". Without that note, the next session can't distinguish "we agreed to defer" from "we forgot again."
+The certificate lives inside the Git directory, so it does not dirty the worktree. It records the exact verified HEAD. A non-zero result is a hard stop governed by "When cleanup cannot be completed" above. Step 5 is unreachable until certification succeeds.
** Step 5: Valediction
-Brief, warm closing. 3-4 sentences max.
+Only after the final clean-tree certificate succeeds, deliver a brief, warm closing. 3-4 sentences max.
Include:
- What was accomplished (specific, not generic)
@@ -557,7 +589,7 @@ Do nothing. The buffer, the =aiv-<project>= tmux session, and =claude= all stay
*** Teardown mode (default)
-Confirm commit + push succeeded (Exit Criteria 5 — never tear down over unpushed work), then drop the sentinel:
+Confirm commit + push and the final clean-tree certificate succeeded (Exit Criteria 5 — never tear down over unpushed or dirty work), then drop the sentinel:
#+begin_src bash
touch "/tmp/ai-wrap-teardown-$(basename "$PWD")"
@@ -595,7 +627,8 @@ If =emacsclient= isn't resolvable or the daemon is down, the gate can't run —
7. *Leaving =.ai/session-context.org= in place* — its presence means "interrupted session", confuses next startup
8. *Long preachy valediction* — brief beats thorough
9. *Leaving runtime/generated files dirty without gitignoring them* — pollutes every future =git status= and erodes trust in "working tree clean" as a signal. Fix =.gitignore= during the wrap, not later.
-10. *Treating "was dirty at session start, still dirty now" as fine by default* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file needs an active resolution recommendation this session. Deferral is allowed only with an explicit user choice, logged in the valediction.
+10. *Treating "was dirty at session start, still dirty now" as fine* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file must be resolved or the wrap remains blocked.
+11. *Calling a blocked cleanup a wrap* — if the strict gate fails, report the paths and needed decisions; do not valedict, certify completion, or tear down.
* Validation Checklist
@@ -608,19 +641,20 @@ Before considering wrap-up complete:
- [ ] =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
+- [ ] Inbox carries nothing but expected committed or ignored pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes); any untracked inbox delivery was processed before wrap
- [ ] Linear Dev-Review sweep ran; any merged-PR tickets moved to Done or PM Acceptance (skip if project doesn't use Linear)
- [ ] Template-sync churn committed as its own =chore: sync .ai tooling from templates= (consuming projects only; skipped in rulesets), or surfaced if a synced path didn't match canonical
-- [ ] After wrap-up commit + push, =git status --short= is empty OR every remaining line has an explicit user-deferred decision logged in the valediction
+- [ ] After wrap-up commit + push, =git-worktree-gate certify "$PWD"= succeeded at the current HEAD
- [ ] Each leftover was investigated and the user saw a concrete resolution recommendation
- [ ] Runtime artifacts added to =.gitignore=, follow-up commit pushed, =git status= re-verified
- [ ] Forgotten changes committed in a follow-up and pushed
-- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch) or explicitly deferred with a one-line reason in the valediction
+- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch); otherwise wrap stopped with an actionable blocker report
- [ ] Current branch pushed to ALL remotes (verified with =git remote -v=)
- [ ] All other local branches with a tracking upstream pushed to their remote
- [ ] Any untracked-upstream branches surfaced for manual =git push -u=
- [ ] Step 6 teardown matches the trigger phrase: no-teardown leaves the buffer; teardown drops only =/tmp/ai-wrap-teardown-<project>=; shutdown gates on =cj/ai-term-live-count= = 1 and drops only =/tmp/ai-wrap-shutdown-<project>=
- [ ] No teardown/shutdown sentinel was dropped before commit + push was verified
+- [ ] The teardown hook can re-verify the clean-tree certificate before consuming a sentinel
- [ ] Shutdown aborted (fell back to normal wrap, logged in the valediction) when another =aiv-*= session was live or the gate couldn't run
- [ ] Commit message follows format (no =session:=, no Claude attribution)
- [ ] Valediction delivered (brief, specific, warm)