aboutsummaryrefslogtreecommitdiff
path: root/claude-templates
diff options
context:
space:
mode:
Diffstat (limited to 'claude-templates')
-rw-r--r--claude-templates/.ai/protocols.org4
-rwxr-xr-xclaude-templates/.ai/scripts/flashcard-to-anki.py26
-rwxr-xr-xclaude-templates/.ai/scripts/inbox-send.py23
-rw-r--r--claude-templates/.ai/scripts/route_recommend.py136
-rw-r--r--claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py31
-rw-r--r--claude-templates/.ai/scripts/tests/test_inbox_send.py46
-rw-r--r--claude-templates/.ai/scripts/tests/test_route_recommend.py124
-rw-r--r--claude-templates/.ai/workflows/INDEX.org9
-rw-r--r--claude-templates/.ai/workflows/code-quality.org83
-rw-r--r--claude-templates/.ai/workflows/inbox.org2
-rw-r--r--claude-templates/.ai/workflows/open-tasks.org25
-rw-r--r--claude-templates/.ai/workflows/readability-audit.org242
-rw-r--r--claude-templates/.ai/workflows/suspend.org112
-rw-r--r--claude-templates/.ai/workflows/task-audit.org15
14 files changed, 862 insertions, 16 deletions
diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org
index 3048df2..46bea50 100644
--- a/claude-templates/.ai/protocols.org
+++ b/claude-templates/.ai/protocols.org
@@ -242,6 +242,10 @@ Execute the wrap-up workflow (details in Session Protocols section below):
2. Git commit and push all changes
3. Valediction summary
+** "Suspend the session" / "Suspend" / "I need to go" / "Stick a pin in everything"
+
+Execute the suspend workflow ([[file:workflows/suspend.org][suspend.org]]): a capture-only mid-session pause for an abrupt departure. It appends a resume-weighted =SUSPENDED= entry to the Session Log, notes uncommitted work, and LEAVES =.ai/session-context.org= in place so the next startup resumes from it — no archive, no teardown, no valediction. The capture-only counterpart to "wrap it up" (which ends + archives + tears down) and to =/flush= (which prompts =/clear= and resumes the same session). "I need to go" is broad — if it reads as a conversational aside, confirm before suspending.
+
* User Information
** Calendar Management
diff --git a/claude-templates/.ai/scripts/flashcard-to-anki.py b/claude-templates/.ai/scripts/flashcard-to-anki.py
index 7227683..ca4c70b 100755
--- a/claude-templates/.ai/scripts/flashcard-to-anki.py
+++ b/claude-templates/.ai/scripts/flashcard-to-anki.py
@@ -13,9 +13,11 @@ Parses org-drill structure:
text (sans :drill: tag). Back = entry body with newlines converted
to <br>.
-Deck name defaults to the input basename, case preserved. Deck and model
-IDs are derived from the deck name via stable hash so re-importing the
-same deck updates existing cards instead of duplicating them.
+Deck name defaults to the org #+TITLE: (so the phone deck reads as the
+curated title), falling back to the input basename when the source has
+no #+TITLE. Deck and model IDs are derived from the deck name via stable
+hash so re-importing the same deck updates existing cards instead of
+duplicating them.
Output defaults to ~/sync/phone/anki/<input-basename>.apkg. The .apkg is
a mobile-Anki artifact the phone picks up from its sync dir, so it lands
@@ -177,7 +179,19 @@ def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck:
return deck
-def default_deck_name(input_path: Path) -> str:
+def default_deck_name(input_path: Path, org_text: str) -> str:
+ """Deck name defaults to the org #+TITLE:, falling back to the basename.
+
+ The #+TITLE drives both the org-drill display in Emacs and the Anki
+ deck name on the phone, so the consumed deck reads as the curated
+ title ("Refutations") rather than the filename slug
+ ("refutation-drill"). Falls back to the input basename (case
+ preserved) when the source has no non-empty #+TITLE line.
+ """
+ for line in org_text.splitlines():
+ m = re.match(r"^#\+TITLE:\s*(.*\S)\s*$", line, re.IGNORECASE)
+ if m:
+ return m.group(1).strip()
return input_path.stem
@@ -197,7 +211,7 @@ def main() -> int:
)
parser.add_argument(
"--deck",
- help="Deck name. Defaults to the input basename.",
+ help="Deck name. Defaults to the org #+TITLE, or the input basename.",
)
parser.add_argument(
"--output",
@@ -213,7 +227,7 @@ def main() -> int:
return 1
org_text = input_path.read_text(encoding="utf-8")
- deck_name = args.deck or default_deck_name(input_path)
+ deck_name = args.deck or default_deck_name(input_path, org_text)
output_path: Path = (args.output or default_output_path(input_path)).expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
diff --git a/claude-templates/.ai/scripts/inbox-send.py b/claude-templates/.ai/scripts/inbox-send.py
index 5373bd4..1362a1f 100755
--- a/claude-templates/.ai/scripts/inbox-send.py
+++ b/claude-templates/.ai/scripts/inbox-send.py
@@ -136,8 +136,21 @@ def slugify_filename(stem: str, max_length: int = MAX_SLUG_LENGTH) -> str:
return truncated.strip("-._")
+def display_name(path: Path) -> str:
+ """The name a project is referred to by — its basename with dots stripped.
+
+ Dotted directories (`.emacs.d`, `.dotfiles`) are awkward to name in
+ conversation, so they're addressed dot-stripped: `emacsd`, `dotfiles`.
+ """
+ return path.name.replace(".", "")
+
+
def find_target(target_name: str, projects: list[Path]) -> Path | None:
- """Resolve `target_name` against the project list (basename or numeric index)."""
+ """Resolve `target_name` against the project list (basename or numeric index).
+
+ An exact basename match wins. Failing that, a dot-stripped alias matches —
+ so `emacsd` resolves `.emacs.d` and `dotfiles` resolves `.dotfiles`.
+ """
if target_name.isdigit():
idx = int(target_name) - 1
if 0 <= idx < len(projects):
@@ -146,6 +159,10 @@ def find_target(target_name: str, projects: list[Path]) -> Path | None:
for p in projects:
if p.name == target_name:
return p
+ norm = target_name.replace(".", "")
+ for p in projects:
+ if display_name(p) == norm:
+ return p
return None
@@ -206,9 +223,9 @@ def print_project_list(projects: list[Path], current: Path | None) -> None:
print("No projects (.ai/ + inbox/) found under the configured roots.")
return
print(f"Available .ai projects ({len(others)}):")
- width = max(len(p.name) for p in others)
+ width = max(len(display_name(p)) for p in others)
for i, p in enumerate(others, 1):
- print(f" {i}. {p.name:<{width}} {p}")
+ print(f" {i}. {display_name(p):<{width}} {p}")
def main() -> int:
diff --git a/claude-templates/.ai/scripts/route_recommend.py b/claude-templates/.ai/scripts/route_recommend.py
new file mode 100644
index 0000000..7b36405
--- /dev/null
+++ b/claude-templates/.ai/scripts/route_recommend.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""Wrap-up routing recommendation engine.
+
+Given an inbox keeper's text and a list of candidate project names, infer which
+project the item belongs to, with a confidence tier:
+
+ strong a project's name (or its dot-stripped form, or a path containing it)
+ appears literally in the item
+ weak a distinctive name token overlaps, but the full name doesn't
+ none no overlap; the item stays put
+
+A multi-way tie at the top tier is ambiguous, so it downgrades to weak with a
+deterministic pick (most token overlap, then alphabetical). An empty candidate
+list yields none.
+
+The pure core is `recommend(item, projects) -> (destination, confidence)` — the
+shape the wrap-up router (Phase 4) and the process-inbox marker (Phase 2) both
+call. The CLI wires it to inbox-send.py's `discover_projects` so the candidate
+set is the same project universe inbox-send already knows.
+
+CLI:
+ route_recommend.py --item "<text>" [--exclude <current-project>]
+prints "<destination>\\t<confidence>" on a match, or "none".
+"""
+
+import argparse
+import importlib.util
+import re
+import sys
+from pathlib import Path
+
+# A distinctive-enough token for weak matching; shorter tokens (of, to, id) are
+# too noisy to route on.
+MIN_WEAK_TOKEN = 4
+
+_TOKEN_RE = re.compile(r"[a-z0-9]+")
+
+
+def _tokens(text: str) -> set[str]:
+ return set(_TOKEN_RE.findall(text.lower()))
+
+
+def _name_variants(name: str) -> set[str]:
+ """A project name and its dot-stripped alias (.emacs.d -> emacsd)."""
+ return {v for v in (name.lower(), name.replace(".", "").lower()) if v}
+
+
+def _literal_present(name: str, item_lower: str) -> bool:
+ """True if a name variant appears in the item on word-ish boundaries.
+
+ Boundaries keep 'home' from matching inside 'homeowner' while still
+ matching it inside a path ('~/code/home/...') or a hyphenated name.
+ """
+ for variant in _name_variants(name):
+ if re.search(r"(?<![a-z0-9])" + re.escape(variant) + r"(?![a-z0-9])", item_lower):
+ return True
+ return False
+
+
+def _tiebreak(candidates: list[str], item_tokens: set[str]) -> str:
+ """Most token overlap first, then alphabetical — deterministic."""
+ return sorted(candidates, key=lambda p: (-len(_tokens(p) & item_tokens), p))[0]
+
+
+def recommend(item: str, projects: list[str]) -> tuple[str | None, str]:
+ """Infer the destination project for `item` from `projects`.
+
+ Returns (destination, confidence). confidence is "strong" / "weak" / "none";
+ destination is None exactly when confidence is "none".
+ """
+ if not projects:
+ return (None, "none")
+
+ item_lower = item.lower()
+ item_tokens = _tokens(item)
+
+ strong: list[str] = []
+ weak: list[str] = []
+ for project in projects:
+ if _literal_present(project, item_lower):
+ strong.append(project)
+ continue
+ name_tokens = {t for t in _tokens(project) if len(t) >= MIN_WEAK_TOKEN}
+ if name_tokens & item_tokens:
+ weak.append(project)
+
+ if len(strong) == 1:
+ return (strong[0], "strong")
+ if len(strong) > 1:
+ return (_tiebreak(strong, item_tokens), "weak")
+ if len(weak) == 1:
+ return (weak[0], "weak")
+ if len(weak) > 1:
+ return (_tiebreak(weak, item_tokens), "weak")
+ return (None, "none")
+
+
+def _load_inbox_send():
+ """Load the sibling kebab-named inbox-send.py as a module for its discovery."""
+ path = Path(__file__).with_name("inbox-send.py")
+ spec = importlib.util.spec_from_file_location("inbox_send", path)
+ if spec is None or spec.loader is None:
+ raise ImportError(f"cannot load {path}")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def discover_destination_names(exclude: str | None = None) -> list[str]:
+ """The candidate project names, reusing inbox-send's discovery.
+
+ `exclude` drops the current project (matched by exact name or dot-stripped
+ alias) so the engine never recommends routing an item to where it already is.
+ """
+ mod = _load_inbox_send()
+ names = [p.name for p in mod.discover_projects(mod.resolve_roots())]
+ if exclude:
+ drop = _name_variants(exclude)
+ names = [n for n in names if not (_name_variants(n) & drop)]
+ return names
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Recommend a routing destination for an inbox keeper.")
+ parser.add_argument("--item", required=True, help="the keeper's text")
+ parser.add_argument("--exclude", help="current project to exclude from candidates")
+ args = parser.parse_args()
+
+ projects = discover_destination_names(exclude=args.exclude)
+ destination, confidence = recommend(args.item, projects)
+ print("none" if destination is None else f"{destination}\t{confidence}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py
index 058b0cd..87008a8 100644
--- a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py
+++ b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py
@@ -34,14 +34,33 @@ def test_default_output_path_targets_phone_anki_dir(drill):
assert result == Path.home() / "sync" / "phone" / "anki" / "health-drill.apkg"
-def test_default_deck_name_is_raw_basename(drill):
- """Deck name is the input basename with case preserved; #+TITLE is ignored."""
- assert drill.default_deck_name(Path("/x/deepsat.org")) == "deepsat"
+def test_default_deck_name_uses_org_title(drill):
+ """The #+TITLE drives the Anki deck name, not the filename slug."""
+ org = "#+TITLE: Refutations\n* Section\n** Q? :drill:\na\n"
+ assert drill.default_deck_name(Path("/x/refutation-drill.org"), org) == "Refutations"
-def test_default_deck_name_keeps_hyphens(drill):
- """A hyphenated basename is kept verbatim rather than title-cased."""
- assert drill.default_deck_name(Path("/x/health-drill.org")) == "health-drill"
+def test_default_deck_name_title_is_trimmed(drill):
+ """Surrounding whitespace on the #+TITLE value is stripped."""
+ org = "#+TITLE: DeepSat Flashcards \n"
+ assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "DeepSat Flashcards"
+
+
+def test_default_deck_name_title_match_is_case_insensitive(drill):
+ """A lowercase #+title: keyword is still recognized."""
+ org = "#+title: Health Flashcards\n"
+ assert drill.default_deck_name(Path("/x/health-drill.org"), org) == "Health Flashcards"
+
+
+def test_default_deck_name_falls_back_to_basename_without_title(drill):
+ """No #+TITLE line falls back to the input basename, case preserved."""
+ org = "* Section\n** Q? :drill:\na\n"
+ assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "deepsat"
+
+
+def test_default_deck_name_blank_title_falls_back_to_basename(drill):
+ """An empty #+TITLE value is ignored in favour of the basename."""
+ assert drill.default_deck_name(Path("/x/health-drill.org"), "#+TITLE: \n") == "health-drill"
# --- section_to_tag (pure) ---
diff --git a/claude-templates/.ai/scripts/tests/test_inbox_send.py b/claude-templates/.ai/scripts/tests/test_inbox_send.py
index a0094dc..cb60e63 100644
--- a/claude-templates/.ai/scripts/tests/test_inbox_send.py
+++ b/claude-templates/.ai/scripts/tests/test_inbox_send.py
@@ -97,6 +97,52 @@ class TestInboxSendDiscovery:
result = run_script(["--list"], roots=[tmp_path / "does-not-exist"])
assert result.returncode == 0
+ def test_inbox_send_list_displays_dot_stripped_name(self, project_root, run_script, tmp_path):
+ """Dotted project basenames display dot-stripped (.emacs.d → emacsd)."""
+ project_root(".emacs.d")
+ result = run_script(["--list"], roots=[tmp_path / "projects"])
+ assert "emacsd" in result.stdout
+
+
+class TestInboxSendDotAlias:
+ """A dotted project basename resolves both verbatim and dot-stripped."""
+
+ def test_resolves_by_dot_stripped_alias(self, project_root, run_script, tmp_path):
+ """'emacsd' delivers to the .emacs.d project."""
+ project_root(".emacs.d")
+ cwd = project_root("source")
+ run_script(
+ ["emacsd", "--text", "hi"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / ".emacs.d" / "inbox").iterdir())
+ assert len(files) == 1
+
+ def test_resolves_by_exact_dotted_name_still(self, project_root, run_script, tmp_path):
+ """Backward-compat: the verbatim '.emacs.d' target still resolves."""
+ project_root(".emacs.d")
+ cwd = project_root("source")
+ run_script(
+ [".emacs.d", "--text", "hi"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / ".emacs.d" / "inbox").iterdir())
+ assert len(files) == 1
+
+ def test_exact_match_wins_over_alias(self, project_root, run_script, tmp_path):
+ """An exact basename match is preferred over a dot-stripped collision."""
+ project_root("emacsd") # exact
+ project_root(".emacs.d") # would also normalize to 'emacsd'
+ cwd = project_root("source")
+ run_script(
+ ["emacsd", "--text", "hi"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ exact = list((tmp_path / "projects" / "emacsd" / "inbox").iterdir())
+ dotted = list((tmp_path / "projects" / ".emacs.d" / "inbox").iterdir())
+ assert len(exact) == 1
+ assert dotted == []
+
# ----------------------------------------------------------------------
# Slug derivation from text and from filenames
diff --git a/claude-templates/.ai/scripts/tests/test_route_recommend.py b/claude-templates/.ai/scripts/tests/test_route_recommend.py
new file mode 100644
index 0000000..acc4755
--- /dev/null
+++ b/claude-templates/.ai/scripts/tests/test_route_recommend.py
@@ -0,0 +1,124 @@
+"""Tests for route_recommend.py — the wrap-up routing recommendation engine.
+
+The core is a pure function recommend(item, projects) -> (destination, confidence):
+- strong: a project's name (or its dot-stripped form) appears literally in the item
+- weak: a distinctive name token overlaps, but the full name doesn't
+- none: no overlap; the item stays put (destination is None)
+
+A multi-way tie at the top tier downgrades to weak with a deterministic pick.
+An empty project list yields none.
+
+The CLI wires this to inbox-send.py's discover_projects (sandboxed here via the
+INBOX_SEND_ROOTS env var, the same hook inbox-send's own tests use).
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+SCRIPTS = Path(__file__).parent.parent
+SCRIPT = SCRIPTS / "route_recommend.py"
+sys.path.insert(0, str(SCRIPTS))
+
+import route_recommend as rr # noqa: E402
+
+
+# --- pure function: the five spec'd cases -----------------------------------
+
+def test_strong_match_named_literally():
+ dest, conf = rr.recommend("fix the rulesets refactor command", ["rulesets", "home", "work"])
+ assert (dest, conf) == ("rulesets", "strong")
+
+
+def test_strong_match_via_dot_stripped_name():
+ # ".emacs.d" addressed as "emacsd" in the item is still a literal hit.
+ dest, conf = rr.recommend("update the emacsd ai-term module", [".emacs.d", "rulesets"])
+ assert (dest, conf) == (".emacs.d", "strong")
+
+
+def test_strong_match_dotted_name_verbatim():
+ dest, conf = rr.recommend("patch .emacs.d startup", [".emacs.d", "rulesets"])
+ assert (dest, conf) == (".emacs.d", "strong")
+
+
+def test_weak_match_topic_token_only():
+ # "wttrin" is a token of "emacs-wttrin" but the full name isn't present.
+ dest, conf = rr.recommend("the wttrin weather bug", ["emacs-wttrin", "rulesets"])
+ assert (dest, conf) == ("emacs-wttrin", "weak")
+
+
+def test_no_match_stays_put():
+ dest, conf = rr.recommend("calibrate the telescope mount", ["rulesets", "deepsat"])
+ assert dest is None
+ assert conf == "none"
+
+
+def test_two_project_strong_tie_downgrades_to_weak():
+ # Both named literally → ambiguous → weak, deterministic tie-break (alphabetical).
+ dest, conf = rr.recommend("sync rulesets and home configs", ["rulesets", "home", "work"])
+ assert conf == "weak"
+ assert dest == "home" # tie-break: most-overlap then alphabetical
+
+
+def test_empty_project_list_is_none():
+ assert rr.recommend("anything at all", []) == (None, "none")
+
+
+# --- boundary / robustness --------------------------------------------------
+
+def test_literal_name_requires_word_boundary():
+ # "home" must not match inside "homeowner".
+ dest, conf = rr.recommend("the homeowner association meeting", ["home", "rulesets"])
+ assert dest is None and conf == "none"
+
+
+def test_path_mention_counts_as_literal():
+ dest, conf = rr.recommend("edit ~/code/rulesets/Makefile", ["rulesets", "home"])
+ assert (dest, conf) == ("rulesets", "strong")
+
+
+def test_strong_beats_weak_when_both_present():
+ # "rulesets" named literally (strong) outranks an emacs-wttrin token hit (weak).
+ dest, conf = rr.recommend("the wttrin fix belongs in rulesets", ["rulesets", "emacs-wttrin"])
+ assert (dest, conf) == ("rulesets", "strong")
+
+
+# --- CLI + discovery reuse (sandboxed roots) --------------------------------
+
+def _run(args, roots, item):
+ import os
+ env = {"PATH": os.environ.get("PATH", ""), "HOME": os.environ.get("HOME", "/tmp"),
+ "INBOX_SEND_ROOTS": ":".join(str(r) for r in roots)}
+ return subprocess.run([sys.executable, str(SCRIPT), "--item", item, *args],
+ capture_output=True, text=True, env=env)
+
+
+def _mk_project(tmp_path, name):
+ proj = tmp_path / "projects" / name
+ (proj / ".ai").mkdir(parents=True, exist_ok=True)
+ (proj / "inbox").mkdir(exist_ok=True)
+ return proj
+
+
+def test_cli_discovers_and_recommends(tmp_path):
+ _mk_project(tmp_path, "foo")
+ _mk_project(tmp_path, "bar")
+ r = _run([], roots=[tmp_path / "projects"], item="fix the foo widget")
+ assert r.returncode == 0
+ assert r.stdout.strip() == "foo\tstrong"
+
+
+def test_cli_no_match_prints_none(tmp_path):
+ _mk_project(tmp_path, "foo")
+ r = _run([], roots=[tmp_path / "projects"], item="unrelated grocery list")
+ assert r.returncode == 0
+ assert r.stdout.strip() == "none"
+
+
+def test_cli_exclude_drops_current_project(tmp_path):
+ _mk_project(tmp_path, "foo")
+ _mk_project(tmp_path, "bar")
+ # Item names foo, but foo is excluded as the current project → no other match.
+ r = _run(["--exclude", "foo"], roots=[tmp_path / "projects"], item="fix the foo widget")
+ assert r.returncode == 0
+ assert r.stdout.strip() == "none"
diff --git a/claude-templates/.ai/workflows/INDEX.org b/claude-templates/.ai/workflows/INDEX.org
index eef81df..a474b29 100644
--- a/claude-templates/.ai/workflows/INDEX.org
+++ b/claude-templates/.ai/workflows/INDEX.org
@@ -22,6 +22,8 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e
- Triggers: "wrap it up", "that's a wrap", "let's call it a wrap"
- No-teardown triggers: "wrap it up with summary", "wrap it up and summarize"
- Shutdown trigger: "wrap it up and shutdown"
+- =suspend.org= — capture-only mid-session pause for an abrupt departure: append a resume-weighted =SUSPENDED= entry to the Session Log, note uncommitted work, and LEAVE =.ai/session-context.org= in place so the next startup resumes from it. The capture-only counterpart to =wrap-it-up= (which archives + tears down) and to =flush= (=/flush=, which prompts =/clear= and resumes the same session). Provides only the capture half; startup's interrupted-session path is the resume half.
+ - Triggers: "suspend the session", "suspend", "I need to go", "stick a pin in everything"
- =retrospective.org= — post-mortem after a tough session.
- Triggers: "let's do a retrospective", "retrospective time"
@@ -87,6 +89,13 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e
- =spec-response.org= — fold a spec review back in: decide accept / modify / reject for every finding, weave accepts into the spec body, complete each finding task in place (the reason recorded on modifies and rejects), reconcile cross-spec tensions, iterate to implementation-ready. The *author* side; consumes the =* Review findings= =spec-review.org= produces.
- Triggers: "respond to the review", "process the spec reviews", "spec-response workflow", "fold in the review"
+** Code quality
+
+- =code-quality.org= — one trigger that sequences every behavior-preserving quality pass over a scope of existing code: =/refactor= (complexity, duplication, dead-code, simplification) then =readability-audit= (comments, headers, names, organization), then surfaces the =:refactor:= tasks readability filed and any deferred =/refactor= findings. A thin orchestrator — each pass keeps its own gate. Excludes =/simplify= (that's for the current diff, not existing code).
+ - Triggers: "code quality sweep", "quality sweep", "run every quality pass on <scope>", "give me every pass on <scope>"
+- =readability-audit.org= — make code readable to a future maintainer: audit file-top commentary, inline comments (why-not-what), names (intention-revealing), and organization (co-location / stepdown / cohesion). The cheap comment- and name-only fixes (dimensions A/B/C) land inline, verified by a green suite; the structural findings (dimension D — split a module, rename a public symbol) are *filed* as =:refactor:= tasks, not done here. Language-agnostic. Feeds =/refactor= (which executes the filed structural work); distinct from =/refactor='s metric scans and =/simplify='s diff cleanup.
+ - Triggers: "let's run the readability-audit workflow", "audit the comments and commentary in <area>", "clean up the structure/organization of <module>", "readability audit"
+
** Tools and meta
- =process-meeting-transcript.org= — record → transcript → labeled archive.
diff --git a/claude-templates/.ai/workflows/code-quality.org b/claude-templates/.ai/workflows/code-quality.org
new file mode 100644
index 0000000..2406f4c
--- /dev/null
+++ b/claude-templates/.ai/workflows/code-quality.org
@@ -0,0 +1,83 @@
+#+TITLE: Code-Quality Sweep Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-28
+
+* Overview
+
+One trigger that runs every behavior-preserving quality pass over a scope of
+*existing* code, in order, then surfaces what got filed for later. It's a thin
+orchestrator — each pass keeps its own discipline and its own confirm gate; this
+workflow only sequences them and collects the residue.
+
+The passes it chains:
+
+1. =/refactor= — structural and logic cleanup on measurable metrics (complexity,
+ duplication, dead-code) plus the simplification lens.
+2. =readability-audit= ([[file:readability-audit.org][readability-audit.org]]) — prose and human-reader clarity
+ (comments, file headers, names, organization).
+
+It deliberately does *not* run =/simplify=: that works the current uncommitted
+diff, not existing committed code, so it belongs to the moment you've just made a
+change, not to a sweep of code already in the tree (see "The /simplify boundary"
+below).
+
+* When to Use This Workflow
+
+- "code quality sweep" / "quality sweep"
+- "run every quality pass on <scope>" / "full quality pass on <scope>"
+- "give me every pass on <file/module/tree>"
+
+Do NOT use it for:
+- *In-flight diff cleanup* — that's =/simplify= on the change you just made.
+- *Bug hunting* — these passes are behavior-preserving; for defects use =debug=
+ or =/review-code=.
+- *Performing the structural refactors it files* — those become =:refactor:=
+ tasks; work them later via =/refactor rename= / =/refactor simplification= or
+ =/start-work=.
+
+* Steps
+
+** 1. Scope
+
+Pick the target: one file, a named module set, or the whole tree (honor
+=.aiignore=). The same scope is passed to both passes so they cover the same
+code.
+
+** 2. /refactor <scope>
+
+Run =/refactor= on the scope. Its default full scan covers complexity,
+duplication, dead-code, and simplification. It presents findings and applies
+only what's approved (its own gate) — structure and logic first, so the
+readability pass audits the cleaned-up code.
+
+** 3. readability-audit on <scope>
+
+Run the readability-audit workflow on the same scope. Its cheap comment- and
+name-only fixes (dimensions A/B/C) land inline and are verified by a green
+suite; its structural findings (dimension D — split a module, rename a public
+symbol) are *filed* as =:refactor:= tasks rather than done here.
+
+** 4. Surface the residue
+
+Collect and report what the sweep left behind for later work:
+
+- The =:refactor:= tasks readability-audit filed (the structural backlog).
+- Any =/refactor= findings deferred rather than applied in step 2.
+
+That residue is the "do this next" list the sweep produces; it's not a failure
+to finish, it's the structural work that needs its own design and test pass.
+
+* The /simplify boundary
+
+=/simplify= and this sweep don't overlap: =/simplify= cleans the *current diff*
+and applies its fixes directly, so reach for it right after making a change,
+before committing. This sweep works *existing committed code* and runs the
+scan-and-present passes. One trigger can't sensibly do both — a diff you're
+holding and a tree you're auditing are different inputs.
+
+* Verification
+
+Each pass owns its verification (=/refactor= runs the suite after applying;
+readability-audit verifies inline fixes against a green suite). The umbrella
+adds nothing beyond sequencing, so when both passes report green, the sweep is
+clean — confirm that before reporting done rather than assuming it.
diff --git a/claude-templates/.ai/workflows/inbox.org b/claude-templates/.ai/workflows/inbox.org
index c442d17..5fc855f 100644
--- a/claude-templates/.ai/workflows/inbox.org
+++ b/claude-templates/.ai/workflows/inbox.org
@@ -114,6 +114,8 @@ The item extends a task already filed. Update the parent TODO's body with a date
** File as TODO
Substantive but waits, or needs design/triage before implementation. Add the TODO under =* <Project> Open Work= with priority + tags per the priority-scheme check (core §6). Body summarizes the proposal and links the inbox content if it's been moved to =docs/design/=. Delete the inbox file (or move it to =docs/design/= first if the content survives).
+*Blocking-dependency handoff.* A special shape: another project sends a note that *this* project's work is blocking one of theirs ("your task X is blocked on us — we need Y"). File or link the owning task, tag it =:blocker:=, and name the requesting project in the body (see the cross-project dependency convention in =todo-format.md=). The =:blocker:= tag makes =open-tasks.org= surface that task *first*, since clearing it unblocks the other project. Dedup against an existing task rather than filing a duplicate. When the work later lands, drop =:blocker:= and notify the waiting project (=inbox-send <their-project> --text "Delivered: <what> — you're unblocked."=) so it can lift its own =:blocked:=.
+
** Defer
Rename in place to =inbox/PROCESSED-<original-filename>= and add a brief comment line at the top: =# Deferred YYYY-MM-DD: <condition>=. Don't accumulate deferred items indefinitely — sweep them on a future process pass when the condition is met or the deferral has aged out.
diff --git a/claude-templates/.ai/workflows/open-tasks.org b/claude-templates/.ai/workflows/open-tasks.org
index fe782d6..4ba29dd 100644
--- a/claude-templates/.ai/workflows/open-tasks.org
+++ b/claude-templates/.ai/workflows/open-tasks.org
@@ -176,6 +176,10 @@ Next Mode answers two questions in one output: "what matters most right now?" (t
Apply the prioritization cascade in order. Stop at the first matching step. This is the importance/urgency answer.
+*Exclude blocked tasks.* A task tagged =:blocked:= has an unmet cross-project dependency (its body names the project and the work owed, per =todo-format.md=). It can't be worked until that other project delivers, so it is *never* the cascade recommendation — skip it at every cascade step below. Blocked tasks are surfaced on their own in Step 3 so the stalled dependency stays visible instead of silently dropping out of view.
+
+*Surface blocking tasks first.* The mirror of the above: a task tagged =:blocker:= is holding up work in *another* project (its body names which project and what's owed, per =todo-format.md=). Clearing it unblocks that project, so it carries borrowed urgency — surface it at the *top* of the cascade recommendation regardless of its own priority cookie, ahead of the normal In-Progress / deadline / priority order. When several =:blocker:= tasks exist, lead with the one blocking the most, or the longest. This is the "do the thing that unblocks someone else first" rule; a =:blocker:= task left at its own low priority is exactly how a cross-project dependency stalls.
+
**** 1. In-Progress Tasks
- Look for tasks marked =DOING= or partially complete.
- *If found:* Recommend that task (always finish what's started).
@@ -228,11 +232,22 @@ Within each row, pick a single task per the same-level tie-breakers above (block
The friction filter is the override path. When the cascade winner is partially blocked, hardware-dependent, or simply too large for the user's current state, one of the friction rows is what they pick instead.
+*** Step 3 — Blocked-on-other-projects surface
+
+Independently of the cascade and the friction filter, collect every open task tagged =:blocked:=. These are tasks this project can't advance until another project delivers; surfacing them keeps a cross-project dependency from rotting at low priority on the other side — the exact failure the tag exists to prevent (a blocked task whose blocker is a =[#D]= in another project sits forever otherwise).
+
+For each blocked task, read its body for the blocking project and what's owed, and present one line: the task, the blocking project, and what that project owes. Then offer — per blocked task — to nudge the blocker: an =inbox-send <project> --text= note naming what's needed and why it's blocking, so the dependency gets attention in the project that owns it. Don't send without the user's go.
+
+If no =:blocked:= tasks exist, omit this surface entirely (the common case).
+
*** Output Format
-Pair the cascade recommendation with the friction block beneath it. Recommendation-at-item-1 convention applies to the friction rows — quick+solo first, since it's the strongest low-friction pick.
+Pair the cascade recommendation with the friction block beneath it, and the blocked-on-other-projects surface (Step 3) beneath that when any blocked task exists. Recommendation-at-item-1 convention applies to the friction rows — quick+solo first, since it's the strongest low-friction pick.
#+begin_example
+Unblocks other projects (do these first):
+- ai-term wrap-teardown companion — :blocker:, unblocks rulesets (the three ai-term functions)
+
Cascade recommendation (importance/urgency):
- Fix org-noter reliability — [#A], Method 1, 8/18 complete, blocks daily reading/annotation
@@ -240,17 +255,25 @@ If you want lower friction instead:
1. Quick + solo: Bump linter config — [#C] :quick:solo:, ~15 min
2. Quick: Confirm new dirvish setup — [#B] :quick:, needs your eye
3. Solo: Refactor config-utilities — [#B] :solo:, bounded but multi-hour
+
+Blocked on other projects (can't advance until the blocker delivers):
+- Wrap-teardown feature — blocked by emacsd: ai-term companion functions — nudge?
#+end_example
+The =:blocker:= surface sits at the very top — clearing one of those is the highest-leverage thing on the list, since it frees work in another project. Omit it when no =:blocker:= task exists (the common case).
+
Include for each row:
- Task name / description.
- Priority + tag cluster.
- One-line reasoning. For the cascade row, name which cascade step matched. For friction rows, an effort hint when one is obvious.
- Progress indicator (for V2MOM-structured todos) on the cascade row only.
+- For a =:blocker:= row: the project it unblocks and what's owed (from the task body).
+- For a blocked row: the blocking project and what it owes (from the task body), plus the nudge offer.
**** Edge cases
- *Empty friction block.* If no =:quick:= or =:solo:= tagged tasks exist in the open set, omit the friction block entirely. Present only the cascade recommendation.
+- *No =:blocker:= tasks.* Omit the "Unblocks other projects" surface entirely (the common case) — show it only when a task carries the =:blocker:= tag.
- *Dedupe.* If the cascade recommendation IS the same task as one of the friction rows (e.g. it's =:quick:solo:= and also won the cascade), show it once at the top with both labels. Don't list it twice.
- *Decline behavior.* If the user declines the cascade recommendation, drop straight to the friction block as the natural next prompt. Do not fall through to lower-cascade-tier tasks; the friction filter IS the override.
diff --git a/claude-templates/.ai/workflows/readability-audit.org b/claude-templates/.ai/workflows/readability-audit.org
new file mode 100644
index 0000000..8223a03
--- /dev/null
+++ b/claude-templates/.ai/workflows/readability-audit.org
@@ -0,0 +1,242 @@
+#+TITLE: Readability Audit Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-28
+
+* Overview
+
+A pass over one file, a set of modules, or the whole tree that makes the code
+*readable to a future maintainer*. It checks four things and fixes the cheap
+ones in place: the file-top commentary, the inline comments, the names, and the
+physical organization of the code. Structural changes that need a real refactor
+(splitting a module, renaming a public symbol) are not done here — they are
+filed as =:refactor:= tasks so they get their own design and test pass.
+
+This is language-agnostic. Where a step names a language-specific tool or
+convention, it's stated as "the project's <X>, if it has one" — read the
+project's =CLAUDE.md= / =notes.org= and the language bundle to resolve the
+concrete tool.
+
+* Where it sits among the code-quality tools
+
+These tools are a pipeline, not duplicates. Knowing which to reach for:
+
+- *readability-audit* (this workflow) — prose and human-reader clarity:
+ comments, file headers, names, and physical organization. Judgment-driven
+ (does this comment lie? does this name reveal intent? can a newcomer place
+ this file in a minute?).
+- =/refactor= — structure on measurable metrics: complexity, duplication,
+ dead-code, the =simplification= lens (behavior-preserving logic/size
+ reduction), and =rename= (executes a codebase-wide symbol rename).
+- =/simplify= — behavior-preserving cleanup of the current diff, applied
+ directly.
+
+The link that keeps them from overlapping: when this audit finds a structural
+problem too big for a comment/name fix — a module to split, a *public* symbol to
+rename across call sites — it *files* a =:refactor:= task rather than doing it
+here. =/refactor= (rename, simplification) or =/start-work= then executes that
+filed task with a proper design and test plan. Readability finds and files;
+=/refactor= transforms.
+
+* Problem We're Solving
+
+Source files drift toward two opposite failure modes, and both hurt the next
+person to open the file:
+
+- *Documentation rot and noise.* Headers carry stale user-manual content
+ (quick-starts, full option matrices, setup walkthroughs) that belongs in user
+ docs; comments restate what the next line already says; comments go out of
+ date and start lying; placeholder =TODO=/=FIXME= stubs and conversational
+ asides accumulate. A blank summary or a missing file-top description leaves a
+ reader with no map.
+- *Structural fog.* Names that don't reveal intent force the reader to decode
+ them; related functions scatter; a public entry point sits far from the
+ private helpers it calls; a file grows to hold several unrelated
+ responsibilities.
+
+Left alone, opening a file costs more every month. The fix is a repeatable audit
+with a clear, checkable standard, run on demand or as files are touched.
+
+* Exit Criteria
+
+For the audited scope:
+
+1. *Every file has an accurate top section* that states what the file does and
+ how it fits the rest of the codebase — terse, no user-manual content, and
+ carrying the project's file-header convention where it has one.
+2. *Every surviving comment earns its place* — it explains a *why* the code
+ can't (a constraint, a workaround and its reason, an ordering dependency, a
+ warning), it is accurate against the current code, and it is terse. Obvious
+ "describe the next line" comments are gone.
+3. *Names reveal intent* — no cryptic abbreviations; the project's
+ public/private visibility convention is applied consistently.
+4. *Related code is co-located* — a public function's private helpers sit right
+ after it; the file reads top-to-bottom by descending abstraction; sections
+ group what belongs together.
+5. *Structural problems too big to fix in a comment pass are filed* as
+ =:refactor:= tasks, not left as a vague note and not half-done inline.
+6. *Nothing broke* — the build is clean and the test suite is green
+ (comment/name edits are behavior-preserving, so this should always hold; it
+ is the proof, not a hope). See "Graceful degradation" for projects without a
+ suite.
+
+* When to Use This Workflow
+
+- "Let's run the readability-audit workflow."
+- "Audit the comments and commentary in <file/area>."
+- "Clean up the structure/organization of <module>."
+- After landing a feature, on the files it touched, before moving on.
+- On a single file you just found hard to read.
+- As a tree-wide sweep: inventory all the source files, audit each, batch the
+ fixes.
+
+Do NOT use this to *perform* the structural refactors themselves (use
+=/refactor= or =/start-work= against a filed task) or to hunt for bugs /
+complexity / duplication (that is =/refactor=, not a readability pass).
+
+* Approach: How We Work Together
+
+** Phase 1 — Scope and inventory
+
+Pick the target: one file, a named module set, or the whole tree. For a sweep,
+list the source files (honor =.aiignore=) and decide coverage. Lean on the
+language's own doc linters as a first filter where they exist — many flag a
+missing or blank file summary and malformed headers; run the project's lint
+target first.
+
+** Phase 2 — Audit each file against the four dimensions
+
+Record findings as =file:line — issue — proposed fix=. The four dimensions:
+
+*** A. File-top commentary (the map)
+
+- Present, and *accurate* against what the file now does.
+- States purpose, the file's role/architecture, and key entry points —
+ *tersely*. A reader should learn what this is and how it connects in a few
+ lines.
+- Carries the project's file-header convention where it has one (a metadata
+ block, a module docstring, a standard header comment). If the project has no
+ header convention, skip this sub-check — don't invent one.
+- Does *not* carry user-manual content — quick-starts, full option matrices,
+ step-by-step setup. That belongs in user docs; move it, don't keep it in the
+ source header.
+- Mechanics are correct for the language: a filled summary line (not blank), the
+ expected section markers, the expected footer.
+
+*** B. Inline comments (why, not what)
+
+- Explains a *why* the code cannot: a workaround *and its reason*, an ordering
+ or load dependency, business-logic rationale, a real warning ("do not reorder
+ these — deadlock").
+- Is *accurate* — matches the current code. A wrong comment is worse than none;
+ fix or delete on sight.
+- Is *terse and useful*. Delete the obvious "describe the next line" comment
+ unless it names a non-obvious constraint. Replace a stale placeholder or a
+ rambling aside with the real one-line reason, or remove it.
+- Convert a comment that's only restating the code into a better *name* instead
+ (see C).
+
+*** C. Names (carry the what/how so comments don't have to)
+
+- Intention-revealing variable and function names; no cryptic single letters or
+ abbreviations outside tight local scopes.
+- The project's public/private convention is applied consistently and correctly:
+ a helper only called within the file is private; a user-facing or
+ intentionally-reusable symbol is public. (Resolve the concrete convention from
+ the language and the project — a naming prefix, an export list, an
+ access modifier.)
+- When a comment exists only to explain a name, rename instead.
+
+*** D. Organization (co-location and ordering)
+
+- Related functions sit together. A public function's private helpers come
+ *right after* it (stepdown / proximity / "reads like a newspaper").
+- The file reads top-to-bottom by descending abstraction.
+- Sections group what belongs together.
+- *Cohesion check:* if the file holds several unrelated responsibilities, or has
+ grown large enough that the top no longer describes one coherent thing, flag a
+ split into layered owners — but see Phase 4: that's a filed refactor, not an
+ inline fix.
+
+** Phase 3 — Apply the cheap, safe fixes inline
+
+Dimensions A, B, and C are *comment- and name-only* and *solo* (no design or
+preference call): apply them directly. After each file (or a batch), verify with
+the project's gates: parse/syntax check, a clean build (no new warnings), and a
+green test suite. Comment/name edits can't change behavior, so green is the proof
+the edit was clean, not a behavior check.
+
+For a tree-wide sweep, drive the uniform rewrites mechanically and verify the
+whole batch at once: a *mechanical applier with a boundary assertion* that
+replaces a well-defined header span is reliable and fast, then one suite run
+covers the batch. Keep the varied cases (header-line fixes, summary fixes that
+must preserve surrounding metadata, inline-comment surgery, generated-file
+headers) as careful per-file edits. (The boundary markers are language-specific;
+the principle — mechanical applier + assert + one suite run for uniform
+rewrites, per-file judgment for varied cases — is not.)
+
+** Phase 4 — File the structural refactors, don't do them here
+
+Dimension D's bigger findings — split a module, rename a *public* symbol across
+call sites, move a function to a different file — are real refactors with their
+own risk and test surface. Do *not* slip them into a readability pass. File each
+as a =:refactor:= task in =todo.org= with the specific finding, so it gets
+=/refactor= or =/start-work= with a proper design and test plan. This is the
+line between the cheap clarity win and the structural change; keeping it sharp is
+what lets the audit stay safe and fast.
+
+** Phase 5 — Verify and commit in logical batches
+
+Full suite green, build clean. Commit the doc/comment changes as =docs:= (or
+=refactor:= where a header/structure normalized) in cohesive batches — one
+commit per coherent slice (a set of condensed commentaries, the
+generated-file-header fixes, the obvious-comment prune), not one mega-commit and
+not one-per-file. Generated files are fixed *in their generator* and then
+regenerated, so the next regen stays compliant.
+
+* Graceful degradation
+
+The audit adapts to what the project provides:
+
+- *No file-header convention* → skip dimension A's metadata sub-check; still
+ check the summary/description for accuracy and terseness.
+- *No test suite* → the green-suite proof in Phases 3 and 5 is unavailable. Fall
+ back to the strongest gate the project has (compile/byte-compile, parse check,
+ linters) and *flag the weaker proof as a known limit* — a behavior-preserving
+ edit is lower-risk, but say plainly that there's no suite to confirm it.
+- *No doc linter* → do the Phase 1 first-filter by reading instead; the audit
+ still runs, just without the cheap pre-pass.
+
+* Principles to Follow
+
+- *Comments explain why; code explains what.* If a comment restates the code,
+ delete it or turn it into a better name.
+- *Accuracy beats completeness.* A wrong or stale comment is worse than no
+ comment. When in doubt, delete.
+- *Terse and useful.* Every comment and every header line earns its place. The
+ source header is not the user manual — move manuals to user docs.
+- *Readable means the next person, fast.* The test of the top-section and the
+ organization is whether a maintainer who has never seen the file can place it
+ and navigate it in under a minute.
+- *Keep the cheap pass cheap.* Comment/name fixes are solo and land inline.
+ Structural splits and public renames are not — they get filed, designed, and
+ tested separately.
+- *Preserve legal and attribution headers verbatim.* Vendored / GPL / copyright
+ notices are never condensed away by a readability pass.
+- *Manual validation is still Craig's.* Solo means no input is needed to *do*
+ the work; visual/behavior confirmation afterward is expected where relevant.
+
+* Living Document
+
+Update this with what real runs teach. Lessons worth keeping as the standard
+sharpens:
+
+- *Interpretation default for "fix blank summary":* when a rewrite shows only a
+ header + summary and omits a metadata block the file already has, keep the
+ existing metadata and replace only the header line and the summary. Its
+ absence from the rewrite means "leave it," not "delete it."
+- *Generated files:* fix the *generator*, then regenerate. Editing the generated
+ file directly is reverted on the next regen.
+- *Vendored files:* preserve the copyright/attribution; do not auto-condense a
+ licensed header.
+- *Mechanical applier + assert + one suite run* is the safe way to do a
+ many-file uniform rewrite; per-file judgment is for the varied cases.
diff --git a/claude-templates/.ai/workflows/suspend.org b/claude-templates/.ai/workflows/suspend.org
new file mode 100644
index 0000000..1c16bb9
--- /dev/null
+++ b/claude-templates/.ai/workflows/suspend.org
@@ -0,0 +1,112 @@
+#+TITLE: Session Suspend Workflow
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-28
+
+* Overview
+
+This workflow captures the live state of a session when Craig must leave
+abruptly, so a future session resumes with nothing lost. It is the fast,
+capture-only workflow for departure: it writes down where every thread stands,
+notes any uncommitted work, then STOPS — no cleanup, no archive, no teardown.
+
+Triggered by Craig saying "suspend the session," "suspend," "I need to go,"
+"stick a pin in everything," or similar. "I need to go" is broad — if it reads
+as a conversational aside rather than a request to suspend, confirm before
+running.
+
+* Where suspend sits among its neighbors
+
+Three workflows touch the session anchor (=.ai/session-context.org=); keep them
+straight:
+
+- =flush= ([[file:../../flush/SKILL.md]] / =/flush=) — *stay and sharpen.*
+ Refreshes the anchor in place, prompts Craig to type =/clear=, and a hook
+ resumes the *same* logical session in a fresh context. Craig is still here.
+- *suspend* (this workflow) — *leave.* Captures richly into the anchor, leaves
+ the file in place, and Craig walks away. The next session is a cold startup
+ that detects the present anchor and resumes from it.
+- =wrap-it-up= ([[file:wrap-it-up.org][wrap-it-up.org]]) — *end.* Writes the
+ Summary, archives the anchor into =.ai/sessions/=, commits + pushes, and runs
+ the phrase-dependent teardown.
+
+Suspend and flush share one core — capture into the anchor, leave it in place.
+They differ in the exit (leave vs clear-and-continue) and the resume path
+(startup vs the =/clear= hook). Suspend reuses flush's capture discipline (its
+Phase 1 anchor-refresh) rather than restating it, and adds a richer,
+resume-weighted Session Log entry because it's written for a cold resume after a
+gap, not a same-session reset.
+
+* Suspend vs wrap-up — the one structural difference
+
+=wrap-it-up= ARCHIVES =.ai/session-context.org= (renames it into
+=.ai/sessions/=); its absence at the next startup is the signal that the last
+session ended cleanly.
+
+Suspend does the opposite: it LEAVES =.ai/session-context.org= in place. Its
+presence at startup is exactly the signal that the previous session was
+interrupted, so the startup workflow reads it and resumes. Suspend provides only
+the *capture* half — startup's existing interrupted-session path (Phase A checks
+for the anchor, Phase B reads it, Phase C offers to resume) is the *resume* half,
+already built.
+
+So: never archive, never rename the context file in a suspend. Capture into it
+and leave it.
+
+* What gets captured
+
+The point is zero lost information, weighted toward RESUME. Into the
+=* Session Log= of =.ai/session-context.org=, append one dated
+=** YYYY-MM-DD ... — SUSPENDED= entry holding:
+
+1. *Open threads — resume here.* For each active or pending thread: the topic,
+ its status (ACTIVE / PINNED / SET ASIDE / DEFERRED), the immediate next
+ step, and the pointers needed to act on it cold (files + line numbers,
+ commit SHAs, the specific finding or decision). This is the core; spend the
+ most words here. Order newest / most-active first.
+2. *Pending decisions / open questions* awaiting Craig — anything blocked on
+ his input, with enough context that the answer is actionable.
+3. *Shipped this session* — a terse list of what landed, each with its commit
+ SHA, so the resume knows what is already done and need not re-derive it.
+4. *Uncommitted work* — anything modified on disk but not committed, named
+ file by file, so the resume knows what state the tree is in.
+5. *Key findings not yet recorded elsewhere* — anything learned this session
+ that isn't already in a commit, a file, or memory, so it survives.
+6. *Background work* — any running task, agent, or job, and how to check it.
+7. *Resume hint* — the single most likely "start here" next action.
+
+Also update the top of =* Summary= (Active Goal) with a one-line SUSPENDED
+pointer to the entry, so startup reading the top sees the current state even
+when the Summary body is from an earlier thread.
+
+* Steps
+
+1. *Write the SUSPENDED entry* into the Session Log, per "What gets captured"
+ above. Timestamp with =date "+%Y-%m-%d %a @ %H:%M:%S %z"=.
+2. *Update the Active Goal pointer* at the top of =* Summary=.
+3. *Record uncommitted work, don't force-commit it.* A suspend records state, it
+ does not tidy it. Name every uncommitted change in the SUSPENDED entry and
+ leave the tree as it is — on an abrupt departure, a dirty tree (like any
+ crash) is safer than a blind commit of arbitrary mid-work state. (If a
+ project defines a standing always-commit set in its own workflow, commit only
+ that set — but the default shared behavior is to leave the tree alone.)
+4. *Leave =.ai/session-context.org= in place.* Do not archive it.
+5. *Brief handoff* — one or two lines: what was captured, where the resume
+ pointer is, the most-active thread. End and let Craig go.
+
+* What suspend does NOT do
+
+Speed over completeness. A suspend deliberately skips everything wrap-it-up
+does beyond capture:
+
+- No =* Summary= rewrite beyond the one-line Active Goal pointer.
+- No todo.org cleanup / archive-done.
+- No KB / memory promotion sweep.
+- No Linear / board reconciliation.
+- No session-record archive (the file stays live).
+- No teardown (the ai-term buffer + tmux session stay up). It drops no
+ =Stop=-hook teardown sentinel, so the wrap-teardown hook stays dormant.
+- No blind commit of working files (step 3).
+- No valediction. A suspend is a pause, not a goodbye.
+
+If Craig later wants the clean end, he runs wrap-it-up, which picks up the
+captured state and finishes the job.
diff --git a/claude-templates/.ai/workflows/task-audit.org b/claude-templates/.ai/workflows/task-audit.org
index 67ce496..94b99da 100644
--- a/claude-templates/.ai/workflows/task-audit.org
+++ b/claude-templates/.ai/workflows/task-audit.org
@@ -84,6 +84,21 @@ For every STALE task, edit it in the main thread:
Follow =todo-format.md= for completion mechanics (depth-based DONE vs dated-rewrite) and the working-files / link-hygiene rules when moving artifacts.
+** Phase C.5 — Consolidate related tasks (interactive)
+
+Phase C's *Consolidate duplicates* bullet folds tasks that track the *same* thing. This step is the broader case: tasks that aren't duplicates but are really *one effort* fragmented across the list. A spread-out effort — several tasks all circling "make the tooling agent-agnostic," say — is harder to see, plan, and finish as a whole than one task, or one parent with the pieces as children.
+
+After the Phase C edits, read the open-task set as a whole and look for *clusters*: tasks that share a goal, a subsystem, or an obvious sequence. Use judgment over the task bodies, not a keyword heuristic — adjacency is a semantic call, and a brittle title-match both misses real clusters and invents false ones.
+
+For each cluster, surface it to Craig (inline numbered options per =interaction.md=, no popup) with a recommendation, offering the two shapes:
+
+- *Merge* — fold the cluster into one task when the members are genuinely the same work split up (near-duplicates, or steps with no independent value). The merged task keeps the strongest priority, unions the type tags, and absorbs each member's body as a dated note or a short list; the absorbed tasks close per =todo-format.md= (a =**= task → =CANCELLED= + =CLOSED:= with a one-line "merged into <task>", or deletion if it carried nothing unique).
+- *Parent with children* — when the members are related but distinct (each ships independently or has its own value), promote a parent task and re-home the members beneath it as sub-tasks, so the list shows the effort as a unit without losing the individual pieces.
+
+Never merge or re-parent autonomously — which tasks belong together, and whether they're one-work or related-distinct, is a judgment only Craig ratifies. Propose, don't apply, until he picks. A cluster he declines stays as separate tasks; don't re-surface it every audit (note the decline in the session log).
+
+When no clear cluster exists, say so in one line and move on — most audits won't find one, and forcing a merge fragments worse than it consolidates.
+
** Phase D — Flag the judgment calls (interactive)
Present the NEEDS-USER bucket as a short, scannable list — one line per task, naming the decision or the fact required. Adjudicate with the user one item at a time (inline numbered options per =interaction.md=, no popup). Apply the user's calls as they come (which may itself produce more autonomous updates, or new tasks).