diff options
| -rw-r--r-- | .ai/scripts/cj-remove-block.py | 101 | ||||
| -rw-r--r-- | .ai/scripts/cj-scan.py | 162 | ||||
| -rw-r--r-- | .ai/scripts/inbox-send.py | 262 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_cj_remove_block.py | 157 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_cj_scan.py | 250 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_inbox_send.py | 329 | ||||
| -rw-r--r-- | .ai/scripts/todo-cleanup.el | 46 | ||||
| -rw-r--r-- | .ai/workflows/task-review.org | 2 |
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). |
