aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-15 16:16:18 -0500
committerCraig Jennings <c@cjennings.net>2026-05-15 16:16:18 -0500
commitee721ee96f984ccd38233309f0dfe6362057e644 (patch)
treef84a3b21ae846c82a2677a59f54947ee5b557174
parent421b17a15219c7061ee92c07451993965fad88ea (diff)
downloadrulesets-ee721ee96f984ccd38233309f0dfe6362057e644.tar.gz
rulesets-ee721ee96f984ccd38233309f0dfe6362057e644.zip
chore(ai): sync scripts and workflows from claude-templates
- todo-cleanup.el: :no-sync: tag now inherits down the outline tree - task-review.org: completion procedure scoped to top-level entries - cj-scan.py + cj-remove-block.py: helpers for cj-comment block handling - inbox-send.py: cross-project messaging via inbox directories
-rw-r--r--.ai/scripts/cj-remove-block.py101
-rw-r--r--.ai/scripts/cj-scan.py162
-rw-r--r--.ai/scripts/inbox-send.py262
-rw-r--r--.ai/scripts/tests/test_cj_remove_block.py157
-rw-r--r--.ai/scripts/tests/test_cj_scan.py250
-rw-r--r--.ai/scripts/tests/test_inbox_send.py329
-rw-r--r--.ai/scripts/todo-cleanup.el46
-rw-r--r--.ai/workflows/task-review.org2
8 files changed, 1296 insertions, 13 deletions
diff --git a/.ai/scripts/cj-remove-block.py b/.ai/scripts/cj-remove-block.py
new file mode 100644
index 0000000..71c7b3d
--- /dev/null
+++ b/.ai/scripts/cj-remove-block.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+"""cj-remove-block — Remove a cj annotation block from an org file by line range.
+
+Idempotently deletes lines [start, end] (1-indexed, inclusive) from the file,
+but only after validating that those lines actually look like a cj annotation
+(either a `#+begin_src cj: ... #+end_src` fence pair or a single `cj:` line).
+The validation step is the point — it protects against accidentally trimming
+the wrong block when line numbers drift between a `cj-scan` call and a remove call.
+
+Usage:
+ cj-remove-block --file FILE.org --start N --end M
+
+Companion to the /respond-to-cj-comments skill and to cj-scan.py.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:", re.IGNORECASE)
+SRC_CLOSE_RE = re.compile(r"^\s*#\+end_src\s*$", re.IGNORECASE)
+LEGACY_CJ_RE = re.compile(r"^\s*cj:\s")
+
+
+def looks_like_cj_range(lines: list[str], start: int, end: int) -> tuple[bool, str]:
+ """Return (ok, reason). Validates start..end (1-indexed, inclusive) is a cj range."""
+ if end < start:
+ return False, f"Range end ({end}) is before start ({start})"
+ if start < 1 or end > len(lines):
+ return False, (
+ f"Range {start}..{end} is out of bounds for a file with {len(lines)} lines"
+ )
+
+ first = lines[start - 1]
+ last = lines[end - 1]
+
+ if start == end:
+ # Single-line removal must look like legacy inline.
+ if LEGACY_CJ_RE.match(first):
+ return True, ""
+ return False, (
+ f"Line {start} does not look like a legacy inline cj: line "
+ f"(got: {first[:60]!r})"
+ )
+
+ # Multi-line removal must look like a source-block fence pair.
+ if not SRC_OPEN_RE.match(first):
+ return False, (
+ f"Line {start} does not look like a #+begin_src cj: opening fence "
+ f"(got: {first[:60]!r})"
+ )
+ if not SRC_CLOSE_RE.match(last):
+ return False, (
+ f"Line {end} does not look like a #+end_src closing fence "
+ f"(got: {last[:60]!r})"
+ )
+ return True, ""
+
+
+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()
+ had_trailing_newline = text.endswith("\n")
+ lines = text.splitlines(keepends=False)
+
+ ok, reason = looks_like_cj_range(lines, start, end)
+ if not ok:
+ print(f"cj-remove-block: refusing to remove — {reason}", file=sys.stderr)
+ sys.exit(1)
+
+ new_lines = lines[: start - 1] + lines[end:]
+ new_text = "\n".join(new_lines)
+ if new_lines and had_trailing_newline:
+ new_text += "\n"
+ elif not new_lines and had_trailing_newline:
+ new_text = ""
+ path.write_text(new_text)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Remove a cj annotation block from an org file by line range.",
+ )
+ parser.add_argument("--file", required=True, type=Path, help="Path to the org file.")
+ parser.add_argument("--start", required=True, type=int, help="Start line (1-indexed, inclusive).")
+ parser.add_argument("--end", required=True, type=int, help="End line (1-indexed, inclusive).")
+ args = parser.parse_args()
+
+ if not args.file.is_file():
+ print(f"Not a file: {args.file}", file=sys.stderr)
+ return 2
+
+ remove_range(args.file, args.start, args.end)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/cj-scan.py b/.ai/scripts/cj-scan.py
new file mode 100644
index 0000000..54e2bf9
--- /dev/null
+++ b/.ai/scripts/cj-scan.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""cj-scan — Parse an org file for cj annotations and VERIFY-placement audit.
+
+Output: JSON to stdout with three top-level keys:
+- cj_blocks: every cj annotation found (source-block or legacy-inline form)
+- verify_tasks: every VERIFY heading with placement validity + suggested promotion target
+- unclosed_blocks: any source-block fence that opened but never closed
+
+Usage:
+ cj-scan FILE.org
+
+Companion to the /respond-to-cj-comments skill — the skill calls this script
+to get a single structured view of every cj annotation and every VERIFY
+placement violation in a single tool call, instead of stitching the picture
+together from multiple grep + Read round-trips.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+# VERIFY placement: top-level under a `*` section, or first-level child of a
+# `**` parent task. Anything else gets a promotion_target suggestion.
+VALID_VERIFY_DEPTHS = {2, 3}
+
+HEADING_RE = re.compile(r"^(\*+)\s+(.*)$")
+SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:\s*(\S*)\s*$", re.IGNORECASE)
+SRC_CLOSE_RE = re.compile(r"^\s*#\+end_src\s*$", re.IGNORECASE)
+LEGACY_CJ_RE = re.compile(r"^\s*cj:\s*(.*)$")
+VERIFY_KEYWORD_RE = re.compile(r"^VERIFY(\s|\[|$)")
+
+
+@dataclass
+class HeadingFrame:
+ depth: int
+ heading: str
+
+
+def promotion_target(depth: int) -> int | None:
+ """Return the suggested target depth for a misplaced VERIFY, or None if valid."""
+ if depth in VALID_VERIFY_DEPTHS:
+ return None
+ if depth < 2:
+ return 2
+ return 3
+
+
+def is_verify_heading(heading_text: str) -> bool:
+ """True when heading text begins with the VERIFY keyword (optional priority cookie)."""
+ return bool(VERIFY_KEYWORD_RE.match(heading_text))
+
+
+def scan_file(path: Path) -> dict[str, object]:
+ """Scan an org file and return cj_blocks + verify_tasks + unclosed_blocks."""
+ cj_blocks: list[dict[str, object]] = []
+ verify_tasks: list[dict[str, object]] = []
+ unclosed_blocks: list[dict[str, object]] = []
+ heading_stack: list[HeadingFrame] = []
+
+ in_cj_block = False
+ block_start_line: int | None = None
+ block_label: str | None = None
+ block_body: list[str] = []
+
+ file_str = str(path)
+ lines = path.read_text().splitlines()
+
+ for lineno, line in enumerate(lines, start=1):
+ if in_cj_block:
+ if SRC_CLOSE_RE.match(line):
+ cj_blocks.append({
+ "file": file_str,
+ "form": "source-block",
+ "start_line": block_start_line,
+ "end_line": lineno,
+ "body": "\n".join(block_body),
+ "label": block_label,
+ "parent_heading_chain": [asdict(h) for h in heading_stack],
+ "parent_depth": heading_stack[-1].depth if heading_stack else 0,
+ })
+ in_cj_block = False
+ block_start_line = None
+ block_label = None
+ block_body = []
+ else:
+ block_body.append(line)
+ continue
+
+ m_heading = HEADING_RE.match(line)
+ if m_heading:
+ depth = len(m_heading.group(1))
+ heading_text = m_heading.group(2).strip()
+ # Pop frames at this depth or deeper before pushing the new one.
+ while heading_stack and heading_stack[-1].depth >= depth:
+ heading_stack.pop()
+ heading_stack.append(HeadingFrame(depth=depth, heading=heading_text))
+ if is_verify_heading(heading_text):
+ pt = promotion_target(depth)
+ verify_tasks.append({
+ "file": file_str,
+ "line": lineno,
+ "depth": depth,
+ "heading": heading_text,
+ "valid_depth": pt is None,
+ "promotion_target": pt,
+ })
+ continue
+
+ m_src_open = SRC_OPEN_RE.match(line)
+ if m_src_open:
+ in_cj_block = True
+ block_start_line = lineno
+ block_label = m_src_open.group(1) or None
+ block_body = []
+ continue
+
+ m_legacy = LEGACY_CJ_RE.match(line)
+ if m_legacy:
+ cj_blocks.append({
+ "file": file_str,
+ "form": "legacy-inline",
+ "start_line": lineno,
+ "end_line": lineno,
+ "body": m_legacy.group(1).strip(),
+ "parent_heading_chain": [asdict(h) for h in heading_stack],
+ "parent_depth": heading_stack[-1].depth if heading_stack else 0,
+ })
+
+ if in_cj_block:
+ unclosed_blocks.append({
+ "file": file_str,
+ "start_line": block_start_line,
+ "label": block_label,
+ })
+
+ return {
+ "cj_blocks": cj_blocks,
+ "verify_tasks": verify_tasks,
+ "unclosed_blocks": unclosed_blocks,
+ }
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print("Usage: cj-scan FILE.org", file=sys.stderr)
+ return 2
+ path = Path(sys.argv[1])
+ if not path.is_file():
+ print(f"Not a file: {path}", file=sys.stderr)
+ return 2
+ result = scan_file(path)
+ json.dump(result, sys.stdout, indent=2)
+ sys.stdout.write("\n")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/inbox-send.py b/.ai/scripts/inbox-send.py
new file mode 100644
index 0000000..8e650ff
--- /dev/null
+++ b/.ai/scripts/inbox-send.py
@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+"""inbox-send — send text or a file to another project's top-level inbox/.
+
+Universal cross-project inbox messaging tool. A "project" here is a
+directory that contains both a `.ai/` marker (signalling it's a
+Claude-managed project) and a top-level `inbox/` directory (Craig's
+inbox convention). The script lets you drop a text message or copy a
+file into a target project's `inbox/`, with a dated filename that
+records the source project so the target's next session picks it up
+cleanly.
+
+Usage:
+ inbox-send --list
+ inbox-send <target> --text "your message" [--name custom-slug]
+ inbox-send <target> --file <path> [--name custom-slug]
+
+<target> is the project's basename (or the numeric index from --list).
+
+Discovery roots default to ~/projects/ and ~/code/ (parent dirs whose
+children are scanned). Override with INBOX_SEND_ROOTS (colon-separated
+paths) or write paths into ~/.claude/inbox-roots.txt, one per line.
+A root may be either a parent directory or a specific project root
+(e.g. ~/.emacs.d); if the root itself is a project, it's included
+directly.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import shutil
+import sys
+from datetime import datetime
+from pathlib import Path
+
+DEFAULT_ROOTS = [Path.home() / "projects", Path.home() / "code"]
+MAX_SLUG_LENGTH = 40
+TS_FILENAME_FMT = "%Y-%m-%d-%H%M"
+TS_DOC_FMT = "%Y-%m-%d %H:%M:%S %z"
+
+
+def resolve_roots() -> list[Path]:
+ """Resolve discovery roots: env var → config file → defaults."""
+ env_roots = os.environ.get("INBOX_SEND_ROOTS")
+ if env_roots:
+ return [Path(p) for p in env_roots.split(":") if p]
+ config = Path.home() / ".claude" / "inbox-roots.txt"
+ if config.is_file():
+ paths: list[Path] = []
+ for line in config.read_text().splitlines():
+ line = line.strip()
+ if line and not line.startswith("#"):
+ paths.append(Path(line).expanduser())
+ if paths:
+ return paths
+ return DEFAULT_ROOTS
+
+
+def _is_project(path: Path) -> bool:
+ """A project has a `.ai/` marker AND a top-level `inbox/` directory."""
+ return (path / ".ai").is_dir() and (path / "inbox").is_dir()
+
+
+def discover_projects(roots: list[Path]) -> list[Path]:
+ """Return absolute paths of projects discovered under `roots`.
+
+ A root may be either a parent directory (its children are scanned) or
+ a specific project root (included directly if it qualifies).
+ """
+ projects: list[Path] = []
+ for root in roots:
+ if not root.is_dir():
+ continue
+ if _is_project(root):
+ projects.append(root)
+ continue
+ for child in sorted(root.iterdir()):
+ if not child.is_dir():
+ continue
+ if _is_project(child):
+ projects.append(child)
+ return projects
+
+
+def find_current_project(start: Path) -> Path | None:
+ """Walk up from `start` looking for the nearest dir containing .ai/."""
+ cur = start.resolve()
+ while cur != cur.parent:
+ if (cur / ".ai").is_dir():
+ return cur
+ cur = cur.parent
+ return None
+
+
+def slugify(text: str, max_length: int = MAX_SLUG_LENGTH) -> str:
+ """Turn freeform text into a filename-safe slug."""
+ text = text.lower()
+ text = re.sub(r"[^a-z0-9\s]+", " ", text)
+ text = re.sub(r"\s+", " ", text).strip()
+ if not text:
+ return ""
+ if len(text) <= max_length:
+ return text.replace(" ", "-")
+ # Truncate, then walk back to the last whitespace to keep a word boundary.
+ truncated = text[:max_length]
+ last_space = truncated.rfind(" ")
+ if last_space > 0:
+ truncated = truncated[:last_space]
+ return truncated.strip().replace(" ", "-")
+
+
+def find_target(target_name: str, projects: list[Path]) -> Path | None:
+ """Resolve `target_name` against the project list (basename or numeric index)."""
+ if target_name.isdigit():
+ idx = int(target_name) - 1
+ if 0 <= idx < len(projects):
+ return projects[idx]
+ return None
+ for p in projects:
+ if p.name == target_name:
+ return p
+ return None
+
+
+def build_text_org(message: str, source_name: str, timestamp: str) -> str:
+ """Wrap a text message in a minimal org-mode skeleton."""
+ title = message.strip().splitlines()[0][:60] if message.strip() else "(empty)"
+ return (
+ f"#+TITLE: {title}\n"
+ f"#+SOURCE: from {source_name}\n"
+ f"#+DATE: {timestamp}\n\n"
+ f"{message.rstrip()}\n"
+ )
+
+
+def send_text(
+ target_inbox: Path,
+ message: str,
+ source_name: str,
+ custom_name: str | None,
+ now: datetime,
+) -> Path:
+ """Write a text message into target_inbox as a dated .org file."""
+ if not message.strip():
+ raise ValueError("--text cannot be empty or whitespace-only")
+ slug = custom_name or slugify(message)
+ if not slug:
+ raise ValueError(f"could not derive a slug from text: {message!r}")
+ filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org"
+ dest = target_inbox / filename
+ dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT)))
+ return dest
+
+
+def send_file(
+ target_inbox: Path,
+ src_path: Path,
+ source_name: str,
+ custom_name: str | None,
+ now: datetime,
+) -> Path:
+ """Copy src_path into target_inbox with a dated, source-tagged name."""
+ if not src_path.is_file():
+ raise FileNotFoundError(f"source file not found: {src_path}")
+ slug = custom_name or slugify(src_path.stem)
+ if not slug:
+ raise ValueError(f"could not derive a slug from file: {src_path}")
+ ext = src_path.suffix
+ filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}"
+ dest = target_inbox / filename
+ shutil.copy2(src_path, dest)
+ return dest
+
+
+def print_project_list(projects: list[Path], current: Path | None) -> None:
+ """Print numbered list of projects, with the current one excluded."""
+ others = [p for p in projects if current is None or p.resolve() != current.resolve()]
+ if not others:
+ 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)
+ for i, p in enumerate(others, 1):
+ print(f" {i}. {p.name:<{width}} {p}")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Send text or a file to another .ai project's inbox/.",
+ )
+ parser.add_argument(
+ "--list", action="store_true",
+ help="List available .ai projects and exit.",
+ )
+ parser.add_argument(
+ "target", nargs="?",
+ help="Target project basename or numeric index from --list.",
+ )
+ parser.add_argument(
+ "--text",
+ help="Text message to drop as an .org file in the target inbox.",
+ )
+ parser.add_argument(
+ "--file", type=Path,
+ help="Path to a file to copy into the target inbox.",
+ )
+ parser.add_argument(
+ "--name",
+ help="Override the auto-derived filename slug.",
+ )
+ args = parser.parse_args()
+
+ roots = resolve_roots()
+ projects = discover_projects(roots)
+ current = find_current_project(Path.cwd())
+
+ if args.list:
+ print_project_list(projects, current)
+ return 0
+
+ if not args.target:
+ parser.error("must provide a target project (or --list)")
+ if args.text is None and args.file is None:
+ parser.error("must provide --text or --file")
+ if args.text is not None and args.file is not None:
+ parser.error("--text and --file are mutually exclusive")
+
+ others = [p for p in projects if current is None or p.resolve() != current.resolve()]
+ target = find_target(args.target, others)
+ if target is None:
+ print(f"inbox-send: unknown target {args.target!r}.", file=sys.stderr)
+ print("Run `inbox-send --list` to see available projects.", file=sys.stderr)
+ return 1
+
+ target_inbox = target / "inbox"
+ if not target_inbox.is_dir():
+ print(
+ f"inbox-send: target {target.name!r} has no top-level inbox/ directory.",
+ file=sys.stderr,
+ )
+ return 1
+
+ source_name = current.name if current else Path.cwd().name
+ now = datetime.now().astimezone()
+
+ try:
+ if args.text is not None:
+ dest = send_text(target_inbox, args.text, source_name, args.name, now)
+ else:
+ assert args.file is not None
+ dest = send_file(target_inbox, args.file, source_name, args.name, now)
+ except (ValueError, FileNotFoundError) as exc:
+ print(f"inbox-send: {exc}", file=sys.stderr)
+ return 1
+
+ print(f"Sent: {dest}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/tests/test_cj_remove_block.py b/.ai/scripts/tests/test_cj_remove_block.py
new file mode 100644
index 0000000..2c8dade
--- /dev/null
+++ b/.ai/scripts/tests/test_cj_remove_block.py
@@ -0,0 +1,157 @@
+"""Tests for cj-remove-block.py — idempotent removal of cj annotations by line range.
+
+The script removes lines [start, end] (1-indexed, inclusive) from an org file but
+validates first that those lines actually look like a cj annotation. Refusing on
+mismatch protects against accidentally trimming the wrong block when line numbers
+drift between scan and remove calls.
+"""
+
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).parent.parent / "cj-remove-block.py"
+
+
+@pytest.fixture
+def run_remove(tmp_path):
+ """Write content to a temp org file, run cj-remove-block, return new contents."""
+ def _run(content: str, start: int, end: int) -> str:
+ f = tmp_path / "test.org"
+ f.write_text(content)
+ subprocess.run(
+ ["python3", str(SCRIPT),
+ "--file", str(f),
+ "--start", str(start),
+ "--end", str(end)],
+ check=True,
+ capture_output=True,
+ )
+ return f.read_text()
+ return _run
+
+
+@pytest.fixture
+def run_remove_expecting_failure(tmp_path):
+ """Write content, run cj-remove-block expecting non-zero exit; return CalledProcessError."""
+ def _run(content: str, start: int, end: int):
+ f = tmp_path / "test.org"
+ f.write_text(content)
+ with pytest.raises(subprocess.CalledProcessError) as excinfo:
+ subprocess.run(
+ ["python3", str(SCRIPT),
+ "--file", str(f),
+ "--start", str(start),
+ "--end", str(end)],
+ check=True,
+ capture_output=True,
+ )
+ return excinfo.value, f.read_text() # file should be unchanged on failure
+ return _run
+
+
+# ----------------------------------------------------------------------
+# Source-block removal
+# ----------------------------------------------------------------------
+
+class TestCjRemoveBlockSourceBlock:
+ """Removing #+begin_src cj: ... #+end_src blocks."""
+
+ def test_cj_remove_block_minimal_three_line_source_block(self, run_remove):
+ """Normal: the three lines of a minimal source-block are removed."""
+ content = "* S\n#+begin_src cj: comment\nbody\n#+end_src\nafter\n"
+ result = run_remove(content, start=2, end=4)
+ assert result == "* S\nafter\n"
+
+ def test_cj_remove_block_source_block_multiline_body(self, run_remove):
+ """Normal: source-block with multi-line body removed cleanly."""
+ content = "* S\n#+begin_src cj: comment\nline 1\nline 2\nline 3\n#+end_src\nafter\n"
+ result = run_remove(content, start=2, end=6)
+ assert result == "* S\nafter\n"
+
+ def test_cj_remove_block_preserves_lines_before_and_after(self, run_remove):
+ """Normal: surrounding lines outside the range stay intact."""
+ content = "before\n#+begin_src cj: comment\nx\n#+end_src\nafter\n"
+ result = run_remove(content, start=2, end=4)
+ assert result == "before\nafter\n"
+
+ def test_cj_remove_block_source_block_with_label_variant(self, run_remove):
+ """Boundary: source-block with no trailing label (#+begin_src cj:) also removable."""
+ content = "* S\n#+begin_src cj:\nbody\n#+end_src\nafter\n"
+ result = run_remove(content, start=2, end=4)
+ assert result == "* S\nafter\n"
+
+ def test_cj_remove_block_case_insensitive_fence(self, run_remove):
+ """Boundary: case-variant fences (#+BEGIN_SRC / #+END_SRC) also removable."""
+ content = "* S\n#+BEGIN_SRC cj: comment\nbody\n#+END_SRC\nafter\n"
+ result = run_remove(content, start=2, end=4)
+ assert result == "* S\nafter\n"
+
+
+# ----------------------------------------------------------------------
+# Legacy-inline removal
+# ----------------------------------------------------------------------
+
+class TestCjRemoveBlockLegacyInline:
+ """Removing single-line legacy `cj: ...` annotations."""
+
+ def test_cj_remove_block_legacy_inline_single_line(self, run_remove):
+ """Normal: single legacy-inline cj line removed."""
+ content = "* S\ncj: legacy note\nafter\n"
+ result = run_remove(content, start=2, end=2)
+ assert result == "* S\nafter\n"
+
+ def test_cj_remove_block_legacy_inline_at_eof(self, run_remove):
+ """Boundary: legacy-inline cj at last line; file ends cleanly."""
+ content = "* S\ncj: at end\n"
+ result = run_remove(content, start=2, end=2)
+ assert result == "* S\n"
+
+
+# ----------------------------------------------------------------------
+# Refusal-on-mismatch safety
+# ----------------------------------------------------------------------
+
+class TestCjRemoveBlockSafety:
+ """Refuses to remove if the specified range doesn't look like a cj annotation."""
+
+ def test_cj_remove_block_refuses_non_cj_single_line(self, run_remove_expecting_failure):
+ """Error: a single non-cj line is rejected."""
+ err, post_content = run_remove_expecting_failure(
+ "* S\nthis is not a cj line\nafter\n", start=2, end=2,
+ )
+ assert err.returncode != 0
+ # File must be unchanged
+ assert post_content == "* S\nthis is not a cj line\nafter\n"
+
+ def test_cj_remove_block_refuses_mismatched_fence(self, run_remove_expecting_failure):
+ """Error: multi-line range where line N isn't an opening fence is rejected."""
+ err, post_content = run_remove_expecting_failure(
+ "* S\nbody1\nbody2\n#+end_src\nafter\n", start=2, end=4,
+ )
+ assert err.returncode != 0
+ assert "body1" in post_content # file unchanged
+
+ def test_cj_remove_block_refuses_missing_closing_fence(self, run_remove_expecting_failure):
+ """Error: multi-line range where line M isn't a closing fence is rejected."""
+ err, post_content = run_remove_expecting_failure(
+ "* S\n#+begin_src cj: comment\nbody\nnot-a-close\nafter\n", start=2, end=4,
+ )
+ assert err.returncode != 0
+ assert "not-a-close" in post_content
+
+ def test_cj_remove_block_refuses_out_of_bounds(self, run_remove_expecting_failure):
+ """Error: range outside the file is rejected, file unchanged."""
+ err, post_content = run_remove_expecting_failure(
+ "* S\nafter\n", start=5, end=7,
+ )
+ assert err.returncode != 0
+ assert post_content == "* S\nafter\n"
+
+ def test_cj_remove_block_refuses_inverted_range(self, run_remove_expecting_failure):
+ """Error: end < start is rejected, file unchanged."""
+ original = "* S\n#+begin_src cj: comment\nbody\n#+end_src\n"
+ err, post_content = run_remove_expecting_failure(original, start=4, end=2)
+ assert err.returncode != 0
+ assert post_content == original
diff --git a/.ai/scripts/tests/test_cj_scan.py b/.ai/scripts/tests/test_cj_scan.py
new file mode 100644
index 0000000..7844474
--- /dev/null
+++ b/.ai/scripts/tests/test_cj_scan.py
@@ -0,0 +1,250 @@
+"""Tests for cj-scan.py — org-file cj-annotation scanner.
+
+The script parses an org file and emits JSON describing:
+- cj_blocks: every cj annotation found (source-block or legacy-inline form)
+- verify_tasks: every VERIFY heading + placement validity (top-level or first-level child only)
+- unclosed_blocks: any source-block fence that opened but never closed
+"""
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).parent.parent / "cj-scan.py"
+
+
+@pytest.fixture
+def run_scan(tmp_path):
+ """Write content to a temp org file and run cj-scan; return parsed JSON output."""
+ def _run(content: str) -> dict:
+ f = tmp_path / "test.org"
+ f.write_text(content)
+ result = subprocess.run(
+ ["python3", str(SCRIPT), str(f)],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return json.loads(result.stdout)
+ return _run
+
+
+# ----------------------------------------------------------------------
+# cj-block detection
+# ----------------------------------------------------------------------
+
+class TestCjScanCjBlockDetection:
+ """Detection of cj annotations — source-block and legacy-inline forms."""
+
+ def test_cj_scan_source_block_single_detected(self, run_scan):
+ """Normal: a single source-block cj is detected with correct line range and body."""
+ content = "* Section\n#+begin_src cj: comment\nplease check this\n#+end_src\n"
+ result = run_scan(content)
+ assert len(result["cj_blocks"]) == 1
+ b = result["cj_blocks"][0]
+ assert b["form"] == "source-block"
+ assert b["body"] == "please check this"
+ assert b["start_line"] == 2
+ assert b["end_line"] == 4
+
+ def test_cj_scan_source_block_multiline_body_preserved(self, run_scan):
+ """Normal: multi-line body is preserved with embedded newlines."""
+ content = "* S\n#+begin_src cj: comment\nline 1\nline 2\nline 3\n#+end_src\n"
+ result = run_scan(content)
+ assert result["cj_blocks"][0]["body"] == "line 1\nline 2\nline 3"
+
+ def test_cj_scan_multiple_source_blocks_each_detected(self, run_scan):
+ """Normal: multiple source-blocks in a file are detected as separate items."""
+ content = (
+ "* A\n#+begin_src cj: comment\nfirst\n#+end_src\n"
+ "* B\n#+begin_src cj: comment\nsecond\n#+end_src\n"
+ )
+ result = run_scan(content)
+ assert len(result["cj_blocks"]) == 2
+ bodies = [b["body"] for b in result["cj_blocks"]]
+ assert bodies == ["first", "second"]
+
+ def test_cj_scan_legacy_inline_single_line_detected(self, run_scan):
+ """Normal: a legacy inline cj line is detected with form=legacy-inline."""
+ content = "* Section\ncj: please check this\n"
+ result = run_scan(content)
+ assert len(result["cj_blocks"]) == 1
+ b = result["cj_blocks"][0]
+ assert b["form"] == "legacy-inline"
+ assert b["body"] == "please check this"
+ assert b["start_line"] == 2
+ assert b["end_line"] == 2
+
+ def test_cj_scan_mixed_forms_in_same_file(self, run_scan):
+ """Normal: source-block + legacy inline coexist; both detected as separate items."""
+ content = (
+ "* A\ncj: legacy form\n"
+ "* B\n#+begin_src cj: comment\nnew form\n#+end_src\n"
+ )
+ result = run_scan(content)
+ assert len(result["cj_blocks"]) == 2
+ forms = sorted(b["form"] for b in result["cj_blocks"])
+ assert forms == ["legacy-inline", "source-block"]
+
+ def test_cj_scan_empty_file_returns_empty_lists(self, run_scan):
+ """Boundary: empty file → empty cj_blocks and verify_tasks lists."""
+ result = run_scan("")
+ assert result["cj_blocks"] == []
+ assert result["verify_tasks"] == []
+ assert result["unclosed_blocks"] == []
+
+ def test_cj_scan_no_cj_content_returns_empty_blocks(self, run_scan):
+ """Boundary: org file with no cj content → empty cj_blocks."""
+ content = "* Section\n** TODO Task\nbody text\n** TODO Another\n"
+ result = run_scan(content)
+ assert result["cj_blocks"] == []
+
+ def test_cj_scan_block_before_any_heading_empty_chain(self, run_scan):
+ """Boundary: cj block at top of file (before any heading) → empty parent chain."""
+ content = "#+begin_src cj: comment\ntop-level note\n#+end_src\n"
+ result = run_scan(content)
+ assert result["cj_blocks"][0]["parent_heading_chain"] == []
+ assert result["cj_blocks"][0]["parent_depth"] == 0
+
+ @pytest.mark.parametrize("fence", [
+ "#+begin_src cj: comment",
+ "#+begin_src cj:",
+ "#+begin_src cj: anything",
+ "#+BEGIN_SRC cj: comment", # case-insensitive
+ ])
+ def test_cj_scan_source_block_fence_variants_all_recognized(self, run_scan, fence):
+ """Boundary: fence label and case variants are all valid forms."""
+ content = f"* S\n{fence}\nbody\n#+end_src\n"
+ result = run_scan(content)
+ assert len(result["cj_blocks"]) == 1
+ assert result["cj_blocks"][0]["body"] == "body"
+
+ def test_cj_scan_unclosed_source_block_reported(self, run_scan):
+ """Error: a source-block that opens but never closes → reported in unclosed_blocks."""
+ content = "* S\n#+begin_src cj: comment\nbody that never ends\n"
+ result = run_scan(content)
+ assert result["cj_blocks"] == []
+ assert len(result["unclosed_blocks"]) == 1
+ assert result["unclosed_blocks"][0]["start_line"] == 2
+
+
+# ----------------------------------------------------------------------
+# Parent heading chain reconstruction
+# ----------------------------------------------------------------------
+
+class TestCjScanParentChain:
+ """Parent heading chain construction — walking the org tree backward."""
+
+ def test_cj_scan_nested_parent_chain_three_levels(self, run_scan):
+ """Normal: cj block inside three nested headings → chain reflects all three."""
+ content = (
+ "* Work\n"
+ "** DOING [#A] Kostya's contract\n"
+ "*** VERIFY Question?\n"
+ "#+begin_src cj: comment\nanswer\n#+end_src\n"
+ )
+ result = run_scan(content)
+ chain = result["cj_blocks"][0]["parent_heading_chain"]
+ assert len(chain) == 3
+ assert chain[0] == {"depth": 1, "heading": "Work"}
+ assert chain[1] == {"depth": 2, "heading": "DOING [#A] Kostya's contract"}
+ assert chain[2] == {"depth": 3, "heading": "VERIFY Question?"}
+ assert result["cj_blocks"][0]["parent_depth"] == 3
+
+ def test_cj_scan_depth_skip_only_actual_ancestors(self, run_scan):
+ """Normal: heading depth skip (e.g., * then ***) → chain captures only present headings."""
+ content = "* Section\n*** Deep child\n#+begin_src cj: comment\nbody\n#+end_src\n"
+ result = run_scan(content)
+ chain = result["cj_blocks"][0]["parent_heading_chain"]
+ assert [h["depth"] for h in chain] == [1, 3]
+
+ def test_cj_scan_shallower_sibling_pops_deeper_frames(self, run_scan):
+ """Normal: when a shallower heading appears, deeper frames pop off the stack."""
+ content = (
+ "* A\n** A.1\n*** A.1.1\n"
+ "** B\n"
+ "#+begin_src cj: comment\nunder B\n#+end_src\n"
+ )
+ result = run_scan(content)
+ chain = result["cj_blocks"][0]["parent_heading_chain"]
+ assert len(chain) == 2
+ assert chain[0]["heading"] == "A"
+ assert chain[1]["heading"] == "B"
+
+
+# ----------------------------------------------------------------------
+# VERIFY task detection + placement audit
+# ----------------------------------------------------------------------
+
+class TestCjScanVerifyPlacement:
+ """VERIFY task detection and placement audit per the canonical rule."""
+
+ def test_cj_scan_verify_at_depth_2_is_valid(self, run_scan):
+ """Normal: ** VERIFY (top-level) is valid placement."""
+ content = "* Work\n** VERIFY [#C] Hayk's Farearth Evaluation :research:hayk:\n"
+ result = run_scan(content)
+ assert len(result["verify_tasks"]) == 1
+ v = result["verify_tasks"][0]
+ assert v["depth"] == 2
+ assert v["valid_depth"] is True
+ assert v["promotion_target"] is None
+
+ def test_cj_scan_verify_at_depth_3_is_valid(self, run_scan):
+ """Normal: *** VERIFY (first-level child) is valid placement."""
+ content = "* Work\n** TODO Parent\n*** VERIFY Question?\n"
+ result = run_scan(content)
+ v = result["verify_tasks"][0]
+ assert v["depth"] == 3
+ assert v["valid_depth"] is True
+
+ def test_cj_scan_verify_at_depth_4_invalid_promote_to_3(self, run_scan):
+ """Normal: **** VERIFY is buried; suggests promotion to depth 3."""
+ content = "* W\n** P\n*** Q\n**** VERIFY Buried?\n"
+ result = run_scan(content)
+ v = result["verify_tasks"][0]
+ assert v["depth"] == 4
+ assert v["valid_depth"] is False
+ assert v["promotion_target"] == 3
+
+ def test_cj_scan_verify_at_depth_6_invalid_promote_to_3(self, run_scan):
+ """Normal: ****** VERIFY at any deep level → promotion target is still 3."""
+ content = "* W\n** P\n*** Q\n**** Q2\n***** Q3\n****** VERIFY Very buried?\n"
+ result = run_scan(content)
+ v = result["verify_tasks"][0]
+ assert v["depth"] == 6
+ assert v["promotion_target"] == 3
+
+ def test_cj_scan_verify_at_depth_1_invalid_promote_to_2(self, run_scan):
+ """Boundary: * VERIFY at top-section depth → promotion target is 2 (top-level under section)."""
+ content = "* VERIFY Should-be-deeper\n"
+ result = run_scan(content)
+ v = result["verify_tasks"][0]
+ assert v["depth"] == 1
+ assert v["valid_depth"] is False
+ assert v["promotion_target"] == 2
+
+ def test_cj_scan_verify_heading_with_priority_and_tags(self, run_scan):
+ """Boundary: VERIFY heading with priority cookie + tags → heading text captured fully."""
+ content = "* W\n** VERIFY [#C] Hayk's Farearth Evaluation :research:hayk:\n"
+ result = run_scan(content)
+ v = result["verify_tasks"][0]
+ assert "Hayk's Farearth Evaluation" in v["heading"]
+ assert ":research:" in v["heading"]
+
+ def test_cj_scan_no_verify_tasks_empty_list(self, run_scan):
+ """Boundary: file with only TODO/DOING headings → empty verify_tasks list."""
+ content = "* W\n** TODO X\n*** DOING Y\n"
+ result = run_scan(content)
+ assert result["verify_tasks"] == []
+
+ def test_cj_scan_verify_word_in_body_is_not_a_task(self, run_scan):
+ """Error: the word VERIFY appearing in body prose is not detected as a task."""
+ content = (
+ "* Work\n"
+ "** TODO Important task\n"
+ "Body line mentioning VERIFY in prose.\n"
+ )
+ result = run_scan(content)
+ assert result["verify_tasks"] == []
diff --git a/.ai/scripts/tests/test_inbox_send.py b/.ai/scripts/tests/test_inbox_send.py
new file mode 100644
index 0000000..597a7e9
--- /dev/null
+++ b/.ai/scripts/tests/test_inbox_send.py
@@ -0,0 +1,329 @@
+"""Tests for inbox-send.py — universal cross-project inbox messaging tool.
+
+The script:
+- discovers .ai projects with an inbox/ subdirectory under known roots,
+- writes a text message as a dated .org file in the target's inbox/, or
+- copies a file into the target's inbox/ with a dated, source-tagged name.
+
+All discovery is roots-driven (env var INBOX_SEND_ROOTS overrides the
+defaults) so tests can sandbox everything inside tmp_path.
+"""
+
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).parent.parent / "inbox-send.py"
+
+
+@pytest.fixture
+def project_root(tmp_path):
+ """Build a fake project under tmp_path/projects/<name>/ with .ai/ + top-level inbox/."""
+ def _make(name: str, has_inbox: bool = True) -> Path:
+ proj = tmp_path / "projects" / name
+ proj.mkdir(parents=True, exist_ok=True)
+ (proj / ".ai").mkdir(exist_ok=True)
+ if has_inbox:
+ (proj / "inbox").mkdir(exist_ok=True)
+ return proj
+ return _make
+
+
+@pytest.fixture
+def run_script(tmp_path):
+ """Invoke inbox-send with sandboxed roots via INBOX_SEND_ROOTS env var."""
+ def _run(args, cwd=None, roots=None, expect_failure=False):
+ env = {}
+ # Preserve PATH and a few essentials for python3 to launch.
+ import os as _os
+ env["PATH"] = _os.environ.get("PATH", "")
+ env["HOME"] = _os.environ.get("HOME", "/tmp")
+ if roots:
+ env["INBOX_SEND_ROOTS"] = ":".join(str(r) for r in roots)
+ cmd = ["python3", str(SCRIPT)] + args
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ cwd=cwd or tmp_path,
+ env=env,
+ check=not expect_failure,
+ )
+ return result
+ return _run
+
+
+# ----------------------------------------------------------------------
+# Discovery (--list)
+# ----------------------------------------------------------------------
+
+class TestInboxSendDiscovery:
+ """Discovering available .ai projects under the configured roots."""
+
+ def test_inbox_send_list_detects_projects_with_ai_inbox(self, project_root, run_script, tmp_path):
+ """Normal: --list shows projects that have .ai/inbox/."""
+ project_root("foo")
+ project_root("bar")
+ result = run_script(["--list"], roots=[tmp_path / "projects"])
+ assert "foo" in result.stdout
+ assert "bar" in result.stdout
+
+ def test_inbox_send_list_skips_projects_without_inbox(self, project_root, run_script, tmp_path):
+ """Boundary: project with .ai/ but no inbox/ is not surfaced."""
+ project_root("withinbox", has_inbox=True)
+ project_root("noinbox", has_inbox=False)
+ result = run_script(["--list"], roots=[tmp_path / "projects"])
+ assert "withinbox" in result.stdout
+ assert "noinbox" not in result.stdout
+
+ def test_inbox_send_list_skips_current_project(self, project_root, run_script, tmp_path):
+ """Normal: --list excludes the project the user is currently in."""
+ cwd_project = project_root("current")
+ project_root("other")
+ result = run_script(["--list"], cwd=cwd_project, roots=[tmp_path / "projects"])
+ assert "other" in result.stdout
+ assert "current" not in result.stdout
+
+ def test_inbox_send_list_empty_when_no_projects(self, run_script, tmp_path):
+ """Boundary: no projects under roots → friendly informational message."""
+ (tmp_path / "projects").mkdir()
+ result = run_script(["--list"], roots=[tmp_path / "projects"])
+ assert result.returncode == 0
+ assert "No projects" in result.stdout
+
+ def test_inbox_send_list_handles_missing_root(self, run_script, tmp_path):
+ """Boundary: configured root doesn't exist → skip silently."""
+ result = run_script(["--list"], roots=[tmp_path / "does-not-exist"])
+ assert result.returncode == 0
+
+
+# ----------------------------------------------------------------------
+# Slug derivation from text and from filenames
+# ----------------------------------------------------------------------
+
+def _slug_from(inbox_files, source_name):
+ """Helper: extract the slug from a deposited file's basename."""
+ assert len(inbox_files) == 1
+ name = inbox_files[0].stem
+ marker = f"from-{source_name}-"
+ return name.split(marker, 1)[1]
+
+
+class TestInboxSendNaming:
+ """Slug derivation from --text (and override via --name)."""
+
+ def test_inbox_send_text_slug_hyphenated_lowercase(self, project_root, run_script, tmp_path):
+ """Normal: 'ATM cash reminder' → slug 'atm-cash-reminder'."""
+ project_root("target")
+ cwd = project_root("source")
+ run_script(
+ ["target", "--text", "ATM cash reminder"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert _slug_from(files, "source") == "atm-cash-reminder"
+
+ def test_inbox_send_text_slug_truncated_at_word_boundary(self, project_root, run_script, tmp_path):
+ """Normal: long text truncated under 40 chars at the nearest word boundary."""
+ project_root("target")
+ cwd = project_root("source")
+ long_text = (
+ "Please review the SOFWeek prep doc and confirm the AirBnB kitchen details"
+ )
+ run_script(
+ ["target", "--text", long_text],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ slug = _slug_from(files, "source")
+ assert slug.startswith("please-review-the-sofweek")
+ assert len(slug) <= 40
+ # Truncation should land on a word boundary (last char is a letter/digit, not mid-word).
+ assert "-" not in slug[-1]
+
+ def test_inbox_send_text_slug_strips_punctuation(self, project_root, run_script, tmp_path):
+ """Normal: punctuation stripped, lowercased."""
+ project_root("target")
+ cwd = project_root("source")
+ run_script(
+ ["target", "--text", "Hey! What's the plan? See you @ 5PM."],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ slug = _slug_from(files, "source")
+ for ch in "!?'@.":
+ assert ch not in slug
+ assert slug == slug.lower()
+
+ def test_inbox_send_name_override_overrides_slug(self, project_root, run_script, tmp_path):
+ """Normal: --name wins over derived slug."""
+ project_root("target")
+ cwd = project_root("source")
+ run_script(
+ ["target", "--text", "ok", "--name", "pre-call-ack"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert _slug_from(files, "source") == "pre-call-ack"
+
+
+# ----------------------------------------------------------------------
+# --text mode end-to-end
+# ----------------------------------------------------------------------
+
+class TestInboxSendText:
+ """--text mode writes a .org file with the message body."""
+
+ def test_inbox_send_text_writes_org_file_with_message(self, project_root, run_script, tmp_path):
+ """Normal: produces a .org file whose body contains the message."""
+ project_root("target")
+ cwd = project_root("source")
+ run_script(
+ ["target", "--text", "Remember the ATM run"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert len(files) == 1
+ assert files[0].suffix == ".org"
+ body = files[0].read_text()
+ assert "Remember the ATM run" in body
+
+ def test_inbox_send_text_filename_includes_source_project_name(self, project_root, run_script, tmp_path):
+ """Normal: filename includes 'from-<source>-' so the target knows where it came from."""
+ project_root("target")
+ cwd = project_root("emacs")
+ run_script(
+ ["target", "--text", "hello"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert "from-emacs-" in files[0].name
+
+
+# ----------------------------------------------------------------------
+# --file mode end-to-end
+# ----------------------------------------------------------------------
+
+class TestInboxSendFile:
+ """--file mode copies the source file into the target inbox."""
+
+ def test_inbox_send_file_copies_text_file(self, project_root, run_script, tmp_path):
+ """Normal: copies a text file to the target inbox, preserving content."""
+ project_root("target")
+ cwd = project_root("source")
+ src = tmp_path / "doc.org"
+ src.write_text("file content")
+ run_script(
+ ["target", "--file", str(src)],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert len(files) == 1
+ assert files[0].read_text() == "file content"
+
+ def test_inbox_send_file_preserves_extension(self, project_root, run_script, tmp_path):
+ """Normal: extension carried from source file."""
+ project_root("target")
+ cwd = project_root("source")
+ src = tmp_path / "image.png"
+ src.write_bytes(b"\x89PNG\r\n...")
+ run_script(
+ ["target", "--file", str(src)],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert files[0].suffix == ".png"
+
+ def test_inbox_send_file_slug_from_source_basename(self, project_root, run_script, tmp_path):
+ """Normal: filename slug derived from the source file's basename when --name omitted."""
+ project_root("target")
+ cwd = project_root("source")
+ src = tmp_path / "branching-strategy-notes.md"
+ src.write_text("notes")
+ run_script(
+ ["target", "--file", str(src)],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert "branching-strategy-notes" in files[0].name
+
+ def test_inbox_send_file_name_override(self, project_root, run_script, tmp_path):
+ """Normal: --name overrides the basename-derived slug; extension preserved."""
+ project_root("target")
+ cwd = project_root("source")
+ src = tmp_path / "random.pdf"
+ src.write_bytes(b"%PDF-1.4...")
+ run_script(
+ ["target", "--file", str(src), "--name", "branching-strategy"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert "branching-strategy" in files[0].name
+ assert files[0].suffix == ".pdf"
+
+
+# ----------------------------------------------------------------------
+# Errors and refusal cases
+# ----------------------------------------------------------------------
+
+class TestInboxSendErrors:
+ """Refusal cases — surface clearly, exit non-zero, leave filesystem untouched."""
+
+ def test_inbox_send_refuses_unknown_target(self, project_root, run_script, tmp_path):
+ """Error: target project not found in discovery → refuse."""
+ cwd = project_root("source")
+ result = run_script(
+ ["nonexistent", "--text", "hi"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ expect_failure=True,
+ )
+ assert result.returncode != 0
+
+ def test_inbox_send_refuses_no_text_and_no_file(self, project_root, run_script, tmp_path):
+ """Error: must provide one of --text / --file."""
+ project_root("target")
+ cwd = project_root("source")
+ result = run_script(
+ ["target"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ expect_failure=True,
+ )
+ assert result.returncode != 0
+
+ def test_inbox_send_refuses_both_text_and_file(self, project_root, run_script, tmp_path):
+ """Error: --text and --file are mutually exclusive."""
+ project_root("target")
+ cwd = project_root("source")
+ src = tmp_path / "doc.org"
+ src.write_text("x")
+ result = run_script(
+ ["target", "--text", "hi", "--file", str(src)],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ expect_failure=True,
+ )
+ assert result.returncode != 0
+
+ def test_inbox_send_refuses_missing_source_file(self, project_root, run_script, tmp_path):
+ """Error: --file path doesn't exist → refuse."""
+ project_root("target")
+ cwd = project_root("source")
+ result = run_script(
+ ["target", "--file", str(tmp_path / "definitely-missing.org")],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ expect_failure=True,
+ )
+ assert result.returncode != 0
+
+ def test_inbox_send_refuses_empty_text(self, project_root, run_script, tmp_path):
+ """Error: empty --text refused; nothing written to target inbox."""
+ project_root("target")
+ cwd = project_root("source")
+ result = run_script(
+ ["target", "--text", " "],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ expect_failure=True,
+ )
+ assert result.returncode != 0
+ files = list((tmp_path / "projects" / "target" / "inbox").iterdir())
+ assert files == []
diff --git a/.ai/scripts/todo-cleanup.el b/.ai/scripts/todo-cleanup.el
index 11851b2..569e7c7 100644
--- a/.ai/scripts/todo-cleanup.el
+++ b/.ai/scripts/todo-cleanup.el
@@ -41,7 +41,10 @@
;; match a child. Children with no priority cookie at all are left alone, as
;; are parents with no priority cookie. A child can opt out of being bumped
;; by carrying the `:no-sync:' tag — useful for `Follow-up:'/`Spike:' children
-;; that are deliberately deprioritized. Because the walk visits parents
+;; that are deliberately deprioritized. The opt-out inherits down the tree:
+;; if any ancestor heading carries `:no-sync:', every descendant under it is
+;; skipped, so tagging a top-level PROJECT once is enough to keep its whole
+;; subtree from cascading. Because the walk visits parents
;; before their descendants in document order, a multi-level chain
;; ([#A] → [#B] → [#D]) collapses to the top priority in a single pass.
;; --check-child-priority is the report-only alias for --sync-child-priority
@@ -60,7 +63,9 @@
"Regexp matching an org priority cookie. Match group 1 is the letter.")
(defconst tc-no-sync-tag "no-sync"
- "Org tag a child heading carries to opt out of `--sync-child-priority'.")
+ "Org tag that opts a heading and all its descendants out of
+`--sync-child-priority'. Inherits down: a tag on an ancestor counts for
+every heading below it.")
(defvar tc-fixes 0)
(defvar tc-archived 0)
@@ -271,20 +276,36 @@ the alphabet, since A is highest in org's default priority scheme."
(and child parent (> child parent)))
(defun tc--heading-has-no-sync-tag-p ()
- "Non-nil when the heading line at point carries `:no-sync:' as a trailing
-tag-style marker. Uses a literal regex match rather than `org-get-tags'
-because org's default tag character class (`org-tag-re') excludes hyphens —
+ "Non-nil when the heading line at point carries the literal substring
+`:no-sync:'. Uses a literal regex match rather than `org-get-tags' because
+org's default tag character class (`org-tag-re') excludes hyphens —
`no-sync' isn't recognized as a real org tag in batch mode unless the user
has extended that regex. The literal `:no-sync:' is what wrap-up sessions
-actually type, so match it directly: any `:no-sync:' preceded by whitespace
-on the heading line counts."
+actually type, so match it directly anywhere on the heading line; the
+heading line is scoped narrowly enough that a false-positive match in title
+text is unlikely, and the cost would only be skipping a bump."
(save-excursion
(org-back-to-heading t)
(let ((line (buffer-substring-no-properties
(line-beginning-position) (line-end-position))))
- (string-match-p (format "[ \t]:%s:" (regexp-quote tc-no-sync-tag))
+ (string-match-p (format ":%s:" (regexp-quote tc-no-sync-tag))
line))))
+(defun tc--ancestor-or-self-has-no-sync-tag-p ()
+ "Non-nil when the heading at point, or any strict ancestor, carries the
+literal `:no-sync:' tag on its own heading line. Walks up the outline
+chain via `org-up-heading-safe', which returns nil at the top level
+instead of erroring."
+ (save-excursion
+ (org-back-to-heading t)
+ (catch 'found
+ (when (tc--heading-has-no-sync-tag-p)
+ (throw 'found t))
+ (while (org-up-heading-safe)
+ (when (tc--heading-has-no-sync-tag-p)
+ (throw 'found t)))
+ nil)))
+
(defun tc--set-heading-priority (letter)
"Rewrite the priority cookie on the heading at point to LETTER (a character)."
(save-excursion
@@ -313,9 +334,10 @@ level deeper than the parent."
(defun tc-sync-child-priority-at-heading ()
"If the heading at point carries a priority cookie, bump any direct child
-heading whose own priority is lower, skipping children tagged
-`tc-no-sync-tag'. A priority-less parent is a no-op; priority-less children
-are left untouched (down-only does not invent priorities)."
+heading whose own priority is lower, skipping children whose own heading
+or any ancestor carries `tc-no-sync-tag'. A priority-less parent is a
+no-op; priority-less children are left untouched (down-only does not
+invent priorities)."
(let ((parent (tc--heading-priority-letter)))
(when parent
(let ((parent-heading (org-get-heading t t t t)))
@@ -325,7 +347,7 @@ are left untouched (down-only does not invent priorities)."
(let ((child (tc--heading-priority-letter)))
(when (and child
(tc--priority-lower-p child parent)
- (not (tc--heading-has-no-sync-tag-p)))
+ (not (tc--ancestor-or-self-has-no-sync-tag-p)))
(let ((child-heading (org-get-heading t t t t))
(child-line (line-number-at-pos)))
(cl-incf tc-bumped)
diff --git a/.ai/workflows/task-review.org b/.ai/workflows/task-review.org
index f11db2a..98d26e1 100644
--- a/.ai/workflows/task-review.org
+++ b/.ai/workflows/task-review.org
@@ -105,7 +105,7 @@ These tasks may already be completed — can you confirm?
- "Sweetwater order arriving" — expected Feb 7, now Feb 12
#+end_example
-For each task Craig confirms as done:
+For each task Craig confirms as done — applies to top-level (=**=) entries, which is what this workflow's list contains. Sub-tasks (=***+=) follow the dated-rewrite rule in [[file:../../claude-rules/todo-format.md][todo-format.md → Completion]] instead:
1. Add =CLOSED: [YYYY-MM-DD Day]= timestamp (use the =date= output from Phase A).
2. Change status from =TODO= to =DONE=.
3. Add a brief completion note (when/how it was resolved).