diff options
Diffstat (limited to 'claude-templates/.ai/scripts')
26 files changed, 3064 insertions, 115 deletions
diff --git a/claude-templates/.ai/scripts/agent-lock b/claude-templates/.ai/scripts/agent-lock new file mode 100755 index 0000000..634412c --- /dev/null +++ b/claude-templates/.ai/scripts/agent-lock @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# agent-lock — a mkdir-atomic advisory lock for agent workflows. +# +# Why not flock: every Bash call an agent makes is its own short-lived shell, +# so an flock taken in one /loop turn is gone by the next. This helper persists +# the lock on disk between calls (an atomic mkdir is the acquire), and a crashed +# holder's lock self-clears via age-based staleness reclaim instead of wedging +# every later acquire. +# +# Serves both of sentry's locks (the single-runner lock and the roam-write +# lock); callers pass a name, never a path — the helper owns the path scheme. +# +# Usage: +# agent-lock acquire <name> [--ttl=SECONDS] [--wait[=SECONDS]] +# Atomic acquire. exit 0 on win (fresh, or reclaimed from a stale holder); +# exit 1 when a live lock already holds <name> (deferred — a note names the +# holder on stderr). --wait polls up to SECONDS (default 30) before +# deferring; without it, acquire is single-shot win-or-lose. --ttl records +# the staleness horizon in the lock's metadata (default below). +# agent-lock refresh <name> +# Heartbeat: re-touch a held lock's mtime so it stays young. A runner +# refreshes its own lock between passes, so a live run's lock is never older +# than one pass and the TTL sizes to the longest single pass. exit 1 if the +# lock is absent (nothing to refresh). +# agent-lock release <name> +# Remove the lock. Idempotent: exit 0 even if already free. +# agent-lock status <name> +# Print "free" | "held ..." | "stale ..." plus metadata. exit 0 (a query +# never fails on lock state). +# agent-lock path <name> +# Print the resolved lock-directory path without creating it. +# +# Lock home (the helper owns this; callers pass names only): +# $AGENT_LOCK_DIR/<name>/ when AGENT_LOCK_DIR is set (tests / advanced) +# $XDG_RUNTIME_DIR/agent-locks/<name>/ the tmpfs runtime dir /run/user/<uid> +# (host-local, out of every repo, +# cleared on reboot). XDG_RUNTIME_DIR is +# the standard handle for it and is set +# in sentry's interactive launch. +# ${XDG_CACHE_HOME:-~/.cache}/agent-locks/<name>/ fallback where no runtime +# dir exists (XDG_RUNTIME_DIR unset or +# unwritable — a headless/container box) +# +# tmpfs residence is deliberate: a lock under ~/org/roam would ride roam-sync's +# `git add -A` to the other machine as a phantom hold. Host-locality is by +# construction, and reboot clears any lock a crash left behind for free. +# +# Staleness is age-based on the metadata file's mtime versus the lock's own +# recorded TTL. Heartbeat re-touches the mtime; a reclaim is always surfaced, +# never silent. + +set -euo pipefail + +DEFAULT_TTL=600 # 10 min: sized to the longest single sentry pass, since a + # live runner heartbeats between passes and stays young. +DEFAULT_WAIT=30 # bounded-wait budget for --wait (capture-guard's shape). +WAIT_INTERVAL=3 # poll cadence while waiting on a busy lock. + +usage() { + echo "usage: agent-lock {acquire|refresh|release|status|path} <name> [--ttl=N] [--wait[=N]]" >&2 + exit 2 +} + +# Resolve the base directory that holds all lock dirs, per the home scheme above. +lock_base() { + if [ -n "${AGENT_LOCK_DIR:-}" ]; then + printf '%s\n' "$AGENT_LOCK_DIR" + elif [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ] && [ -w "$XDG_RUNTIME_DIR" ]; then + printf '%s/agent-locks\n' "$XDG_RUNTIME_DIR" + else + printf '%s/agent-locks\n' "${XDG_CACHE_HOME:-$HOME/.cache}" + fi +} + +# Validate a lock name: non-empty, no path separators (so a name can never +# escape the base dir). +valid_name() { + case "$1" in + ''|*/*|.|..) return 1 ;; + *) return 0 ;; + esac +} + +lock_dir() { printf '%s/%s\n' "$(lock_base)" "$1"; } +meta_path() { printf '%s/meta\n' "$(lock_dir "$1")"; } + +# Read a key from a lock's metadata file; empty if absent. +meta_get() { + local key="$1" file="$2" + [ -f "$file" ] || return 0 + sed -n "s/^${key}=//p" "$file" | head -n1 +} + +# Age of a lock in whole seconds, from the metadata mtime. +lock_age() { + local file="$1" mtime now + mtime=$(stat -c %Y "$file" 2>/dev/null) || return 1 + now=$(date +%s) + printf '%s\n' "$((now - mtime))" +} + +# True when a lock dir exists but its age exceeds its recorded TTL. +is_stale() { + local name="$1" file age ttl + file="$(meta_path "$name")" + [ -f "$file" ] || return 1 + age="$(lock_age "$file")" || return 1 + ttl="$(meta_get ttl "$file")" + [ -n "$ttl" ] || ttl="$DEFAULT_TTL" + [ "$age" -gt "$ttl" ] +} + +# Write the metadata file for a freshly-taken lock. +write_meta() { + local name="$1" ttl="$2" file + file="$(meta_path "$name")" + { + printf 'pid=%s\n' "$$" + printf 'host=%s\n' "$(uname -n)" + printf 'acquired=%s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" + printf 'ttl=%s\n' "$ttl" + } > "$file" +} + +# One-line holder description for surfaced notes. +holder_desc() { + local file="$1" + printf "pid=%s host=%s age=%ss ttl=%ss" \ + "$(meta_get pid "$file")" "$(meta_get host "$file")" \ + "$(lock_age "$file" 2>/dev/null || echo '?')" "$(meta_get ttl "$file")" +} + +# Attempt a single atomic acquire. exit 0 win, 1 busy (live holder). +try_acquire() { + local name="$1" ttl="$2" dir file + dir="$(lock_dir "$name")" + file="$(meta_path "$name")" + mkdir -p "$(lock_base)" + + if mkdir "$dir" 2>/dev/null; then + write_meta "$name" "$ttl" + return 0 + fi + + # Directory exists. Reclaim it if the holder is stale; otherwise it's busy. + if is_stale "$name"; then + # Claim the stale dir atomically before removing it. `mv` of a directory is + # atomic, so when two acquirers both see the lock stale, only one's rename + # of $dir succeeds — the other's fails because $dir is already gone, and it + # falls through to busy. Never `rm -rf $dir` directly: a plain remove lets + # the loser delete the winner's freshly-created lock and double-acquire. + local claimed="$dir.stale.$$" + if mv "$dir" "$claimed" 2>/dev/null; then + echo "agent-lock: reclaimed stale lock '$name' ($(holder_desc "$claimed/meta"))" >&2 + rm -rf "$claimed" + # mkdir stays the sole grant: a concurrent fresh acquirer may win here, + # in which case our mkdir fails and we correctly defer to it. + if mkdir "$dir" 2>/dev/null; then + write_meta "$name" "$ttl" + return 0 + fi + fi + fi + return 1 +} + +cmd_acquire() { + local name="$1"; shift + local ttl="$DEFAULT_TTL" wait_total=0 + while [ $# -gt 0 ]; do + case "$1" in + --ttl=*) ttl="${1#--ttl=}" ;; + --ttl) shift; ttl="${1:-}" ;; + --wait) wait_total="$DEFAULT_WAIT" ;; + --wait=*) wait_total="${1#--wait=}" ;; + *) usage ;; + esac + shift + done + case "$ttl" in ''|*[!0-9]*) usage ;; esac + case "$wait_total" in *[!0-9]*) usage ;; esac + + local elapsed=0 + while :; do + if try_acquire "$name" "$ttl"; then + exit 0 + fi + if [ "$elapsed" -ge "$wait_total" ]; then + echo "agent-lock: '$name' busy ($(holder_desc "$(meta_path "$name")")); deferring" >&2 + exit 1 + fi + local remaining=$((wait_total - elapsed)) step + step=$(( remaining < WAIT_INTERVAL ? remaining : WAIT_INTERVAL )) + sleep "$step" + elapsed=$((elapsed + step)) + done +} + +cmd_refresh() { + local name="$1" file + file="$(meta_path "$name")" + [ -f "$file" ] || exit 1 + # Re-stamp acquired and bump mtime so the age clock restarts. + local ttl; ttl="$(meta_get ttl "$file")"; [ -n "$ttl" ] || ttl="$DEFAULT_TTL" + write_meta "$name" "$ttl" + exit 0 +} + +cmd_release() { + local name="$1" dir + dir="$(lock_dir "$name")" + rm -rf "$dir" + exit 0 +} + +cmd_status() { + local name="$1" dir file + dir="$(lock_dir "$name")" + file="$(meta_path "$name")" + if [ ! -d "$dir" ]; then + echo "free $name" + exit 0 + fi + local state="held" + is_stale "$name" && state="stale" + echo "$state $name pid=$(meta_get pid "$file") host=$(meta_get host "$file") acquired=$(meta_get acquired "$file") ttl=$(meta_get ttl "$file") age=$(lock_age "$file" 2>/dev/null || echo '?')s" + exit 0 +} + +cmd_path() { + lock_dir "$1" + exit 0 +} + +[ $# -ge 1 ] || usage +subcmd="$1"; shift +[ $# -ge 1 ] || usage +name="$1"; shift +valid_name "$name" || usage + +case "$subcmd" in + acquire) cmd_acquire "$name" "$@" ;; + refresh) cmd_refresh "$name" ;; + release) cmd_release "$name" ;; + status) cmd_status "$name" ;; + path) cmd_path "$name" ;; + *) usage ;; +esac diff --git a/claude-templates/.ai/scripts/apkg-to-orgdrill.py b/claude-templates/.ai/scripts/apkg-to-orgdrill.py new file mode 100755 index 0000000..79e24a4 --- /dev/null +++ b/claude-templates/.ai/scripts/apkg-to-orgdrill.py @@ -0,0 +1,251 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Convert an Anki .apkg deck into an org-drill file (inverse of flashcard-to-anki.py). + +The flashcard pipeline is otherwise one-directional (org-drill -> apkg). +Decks curated on the phone, and orphaned apkgs whose .org source was never +saved, can't get back into the org source of truth. This recovers them. + +Reading needs no third-party library: an apkg is a zip holding +collection.anki2 / .anki21 (an Anki sqlite db) plus a media blob, so stdlib +zipfile + sqlite3 suffice. genanki is only needed to write apkgs, not read +them. + +Mapping (mirrors flashcard-to-anki.py's parse/build, inverted): + - Deck name (from the apkg) -> #+TITLE: + - Note Front -> ** <Front> :drill: + - Note Back (HTML) -> entry body (<br> -> newlines, + &/</> unescaped, + <hr id="answer"> stripped) + - Note tag -> * <tag> section grouping + (best-effort: the tag is a slug, + so it won't round-trip to the exact + original section title — a human + retitles) + - A fresh :ID: UUID per card -> so the output is org-drill-valid + +GUIDs in flashcard-to-anki.py are derived from the Front text, not the +:ID:, so a deck regenerated from recovered org still matches existing phone +cards by Front. Only Front/Back (Basic) note types convert; other models +(cloze, etc.) are skipped with a warning rather than silently dropped. + +Usage: + apkg-to-orgdrill.py <input.apkg> # one <deck-slug>.org per deck in cwd + apkg-to-orgdrill.py <input.apkg> --output-dir DIR + apkg-to-orgdrill.py <input.apkg> --deck "Name" --output deck.org +""" +from __future__ import annotations + +import argparse +import json +import re +import sqlite3 +import sys +import tempfile +import uuid +import zipfile +from collections import OrderedDict +from dataclasses import dataclass +from pathlib import Path + +# Collection member names Anki uses, newest schema first. +COLLECTION_NAMES = ("collection.anki21", "collection.anki2") + +_BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE) +_ANSWER_HR_RE = re.compile(r'<hr id="answer">', re.IGNORECASE) +_MEDIA_RE = re.compile(r"<img\b|\[sound:|<audio\b|<video\b", re.IGNORECASE) + + +@dataclass +class Note: + deck: str + front: str + back_html: str + tag: str + + +def html_to_org_body(back_html: str) -> list[str]: + """Invert flashcard-to-anki.py's back-of-card HTML into org body lines. + + <br> (all spellings) and a stray answer <hr> become line breaks; the + entity unescape undoes escape_html, which escaped ``&`` first — so ``&`` + is unescaped last here, or a literally-escaped ``<`` in the source + would wrongly collapse to ``<``. + """ + if not back_html: + return [] + s = _ANSWER_HR_RE.sub("\n", back_html) + s = _BR_RE.sub("\n", s) + s = s.replace("<", "<").replace(">", ">").replace("&", "&") + return s.split("\n") + + +def _slug(title: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") + + +def _read_collection(db_path: Path) -> list[Note]: + con = sqlite3.connect(db_path) + try: + row = con.execute("SELECT decks, models FROM col LIMIT 1").fetchone() + if row is None: + raise ValueError("collection has no col row") + decks_json, models_json = row + decks = {int(k): v["name"] for k, v in json.loads(decks_json).items()} + models = { + int(k): [f["name"] for f in v["flds"]] + for k, v in json.loads(models_json).items() + } + + # A note's deck comes from its card; the Default deck (id 1) carries + # no cards from this pipeline, so it never shows up here. + nid_to_did: dict[int, int] = {} + for nid, did in con.execute("SELECT nid, did FROM cards"): + nid_to_did.setdefault(nid, did) + + notes: list[Note] = [] + for nid, mid, flds, tags in con.execute( + "SELECT id, mid, flds, tags FROM notes" + ): + field_names = models.get(mid) + if not field_names or "Front" not in field_names or "Back" not in field_names: + print( + f"apkg-to-orgdrill: skip note {nid} — model is not a Front/Back " + f"type (fields={field_names})", + file=sys.stderr, + ) + continue + fields = flds.split("\x1f") + fi, bi = field_names.index("Front"), field_names.index("Back") + front = fields[fi] if fi < len(fields) else "" + back_html = fields[bi] if bi < len(fields) else "" + + did = nid_to_did.get(nid) + if did is None: + continue # note with no card — orphan + deck = decks.get(did) + if deck is None: + continue + + tag_list = tags.split() + tag = tag_list[0] if tag_list else "drill" + + if _MEDIA_RE.search(back_html): + print( + f"apkg-to-orgdrill: note {nid} references media; org has no " + f"media path (left inline for a human to resolve)", + file=sys.stderr, + ) + notes.append(Note(deck=deck, front=front, back_html=back_html, tag=tag)) + return notes + finally: + con.close() + + +def read_apkg(path: Path) -> list[Note]: + """Read an .apkg and return its Front/Back notes. Raises on a malformed file.""" + with zipfile.ZipFile(path) as z: # BadZipFile if it isn't a zip + names = set(z.namelist()) + col_name = next((n for n in COLLECTION_NAMES if n in names), None) + if col_name is None: + raise ValueError(f"{path}: no collection.anki2/.anki21 inside the apkg") + with tempfile.TemporaryDirectory() as td: + db_path = Path(td) / col_name + db_path.write_bytes(z.read(col_name)) + return _read_collection(db_path) + + +def notes_to_org(notes: list[Note], deck_name: str, *, new_id=None) -> str: + """Render one deck's notes as an org-drill file in the house shape.""" + if new_id is None: + new_id = lambda: str(uuid.uuid4()) # noqa: E731 + groups: "OrderedDict[str, list[Note]]" = OrderedDict() + for n in notes: + groups.setdefault(n.tag, []).append(n) + + lines: list[str] = [f"#+TITLE: {deck_name}", ""] + for tag, group in groups.items(): + lines.append(f"* {tag}") + for n in group: + lines.append(f"** {n.front} :drill:") + lines.append(":PROPERTIES:") + lines.append(f":ID: {new_id()}") + lines.append(":END:") + lines.extend(html_to_org_body(n.back_html)) + lines.append("") + return "\n".join(lines).rstrip("\n") + "\n" + + +def convert(apkg_path: Path, *, new_id=None) -> "OrderedDict[str, str]": + """apkg -> {deck_name: org_text}, one entry per deck that has Front/Back cards.""" + by_deck: "OrderedDict[str, list[Note]]" = OrderedDict() + for n in read_apkg(apkg_path): + by_deck.setdefault(n.deck, []).append(n) + out: "OrderedDict[str, str]" = OrderedDict() + for deck, deck_notes in by_deck.items(): + out[deck] = notes_to_org(deck_notes, deck, new_id=new_id) + return out + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Convert an Anki .apkg deck into an org-drill file.", + ) + parser.add_argument("input", type=Path, help="Path to the .apkg file.") + parser.add_argument("--deck", help="Only convert the deck with this exact name.") + parser.add_argument( + "--output", + type=Path, + help="Output .org path. Requires a single deck (use --deck to pick one).", + ) + parser.add_argument( + "--output-dir", + type=Path, + help="Directory for per-deck .org files (default: current directory).", + ) + args = parser.parse_args() + + input_path = args.input.expanduser().resolve() + if not input_path.is_file(): + print(f"error: {input_path} not found", file=sys.stderr) + return 1 + + by_deck = convert(input_path) + if args.deck: + by_deck = OrderedDict((k, v) for k, v in by_deck.items() if k == args.deck) + if not by_deck: + print(f"error: no deck named {args.deck!r} in {input_path}", file=sys.stderr) + return 1 + if not by_deck: + print(f"error: no Front/Back cards found in {input_path}", file=sys.stderr) + return 1 + + if args.output: + if len(by_deck) != 1: + print( + f"error: --output needs a single deck; {input_path} has " + f"{len(by_deck)} ({', '.join(by_deck)}). Use --deck or --output-dir.", + file=sys.stderr, + ) + return 1 + out = args.output.expanduser().resolve() + out.parent.mkdir(parents=True, exist_ok=True) + deck, org = next(iter(by_deck.items())) + out.write_text(org, encoding="utf-8") + print(f"wrote {out} ({org.count(':drill:')} cards, deck {deck!r})") + return 0 + + out_dir = (args.output_dir or Path.cwd()).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + for deck, org in by_deck.items(): + out = out_dir / f"{_slug(deck) or 'deck'}.org" + out.write_text(org, encoding="utf-8") + print(f"wrote {out} ({org.count(':drill:')} cards, deck {deck!r})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/claude-templates/.ai/scripts/cj-remove-block.py b/claude-templates/.ai/scripts/cj-remove-block.py index 71c7b3d..d5137a3 100755 --- a/claude-templates/.ai/scripts/cj-remove-block.py +++ b/claude-templates/.ai/scripts/cj-remove-block.py @@ -16,8 +16,12 @@ Companion to the /respond-to-cj-comments skill and to cj-scan.py. from __future__ import annotations import argparse +import os import re +import shutil import sys +import tempfile +from datetime import datetime from pathlib import Path SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:", re.IGNORECASE) @@ -57,12 +61,83 @@ def looks_like_cj_range(lines: list[str], start: int, end: int) -> tuple[bool, s f"Line {end} does not look like a #+end_src closing fence " f"(got: {last[:60]!r})" ) + + # The range must hold exactly ONE block. Checking only the first and last + # lines let a drifted range run from one block's opener to a *later* block's + # closer: validation passed and the removal silently deleted everything + # between, prose and headings included. Drift is the case this check exists + # for, so it has to look inside the range, not just at its ends. + for offset, line in enumerate(lines[start:end - 1], start=start + 1): + if SRC_CLOSE_RE.match(line): + return False, ( + f"Range {start}..{end} covers more than one cj block — " + f"a #+end_src appears at line {offset}, before the range ends. " + f"Re-scan for current line numbers; removing this range would " + f"delete everything between the two blocks." + ) + if SRC_OPEN_RE.match(line): + return False, ( + f"Range {start}..{end} covers more than one cj block — " + f"a second #+begin_src cj: appears at line {offset}. " + f"Re-scan for current line numbers." + ) return True, "" +def _backup(path: Path) -> Path: + """Copy path to /tmp before mutating it, mirroring lint-org.el's convention. + + These are Craig's org files. lint-org.el, the other tool that rewrites them, + leaves a /tmp copy before touching anything; this matches it so a bad edit is + always recoverable without reaching for git (which only reaches the last + commit, losing intra-session work). + """ + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + base = Path(tempfile.gettempdir()) / f"{path.name}.before-cj-remove.{stamp}" + # Never overwrite an earlier backup. The skill removes several annotations + # in quick succession, so a second-resolution stamp collides and the later + # copy would replace the earlier one with already-mutated content — losing + # the pre-session original the backup exists to preserve. + dest = base + n = 2 + while dest.exists(): + dest = base.with_name(f"{base.name}-{n}") + n += 1 + shutil.copy2(path, dest) + return dest + + +def _atomic_write(path: Path, text: str) -> None: + """Write text to path via a temp sibling and os.replace. + + A bare write_text truncates the target on open, so a mid-write failure left + the org file truncated with no complete copy on disk. Writing a temp sibling + and renaming means the file is either its old content or its new content, + never a partial. + """ + # Follow a symlink to the file it names. os.replace would otherwise swap the + # symlink itself for a regular file, leaving the real target holding the old + # content — the edit silently goes nowhere. Resolving also puts the temp + # sibling on the same filesystem as the real file, which os.replace needs. + path = path.resolve() + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + os.close(fd) + tmp_path = Path(tmp) + # Carry the original's permissions across. mkstemp creates 0600, and + # defaulting to the umask instead widened a deliberately-restricted file + # (a 0600 org file came back 0644). + shutil.copymode(path, tmp_path) + try: + tmp_path.write_text(text, encoding="utf-8") + os.replace(tmp_path, path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + + def remove_range(path: Path, start: int, end: int) -> None: """Read path, validate range looks like cj content, remove the range, write back.""" - text = path.read_text() + text = path.read_text(encoding="utf-8") had_trailing_newline = text.endswith("\n") lines = text.splitlines(keepends=False) @@ -77,7 +152,9 @@ def remove_range(path: Path, start: int, end: int) -> None: new_text += "\n" elif not new_lines and had_trailing_newline: new_text = "" - path.write_text(new_text) + + _backup(path) + _atomic_write(path, new_text) def main() -> int: diff --git a/claude-templates/.ai/scripts/flashcard-stats.py b/claude-templates/.ai/scripts/flashcard-stats.py index 1fa5afb..cb580ac 100755 --- a/claude-templates/.ai/scripts/flashcard-stats.py +++ b/claude-templates/.ai/scripts/flashcard-stats.py @@ -35,7 +35,12 @@ import re import sys from pathlib import Path -CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") +# A card is a level-2 heading whose trailing org tag block includes `drill`. +# Group 1 is the front, group 2 the tag block — so a curated card multi-tagged +# :fundamental:drill: still counts (it would silently drop under a :drill:$ +# anchor, undercounting the deck). HEADING_RE bounds a card's body. +CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+(:[A-Za-z0-9_@#%:]+:)\s*$") +HEADING_RE = re.compile(r"^\*{1,2}\s") ANSWER_RE = re.compile(r"^\*\*\*\s+Answer\b") PROP_START_RE = re.compile(r"^\s*:PROPERTIES:\s*$") PROP_END_RE = re.compile(r"^\s*:END:\s*$") @@ -177,7 +182,8 @@ def parse_cards(lines: list[str]) -> tuple[list[dict], int]: n = len(lines) while i < n: m = CARD_RE.match(lines[i]) - if not m: + tags = [t for t in m.group(2).split(":") if t] if m else [] + if not (m and "drill" in tags): i += 1 continue heading = m.group(1).strip() @@ -188,7 +194,7 @@ def parse_cards(lines: list[str]) -> tuple[list[dict], int]: body_lines: list[str] = [] while i < n: line = lines[i] - if line.startswith("* ") or CARD_RE.match(line): + if HEADING_RE.match(line): break if PROP_START_RE.match(line): prop_count += 1 diff --git a/claude-templates/.ai/scripts/flashcard-to-anki.py b/claude-templates/.ai/scripts/flashcard-to-anki.py index ca4c70b..e369fd8 100755 --- a/claude-templates/.ai/scripts/flashcard-to-anki.py +++ b/claude-templates/.ai/scripts/flashcard-to-anki.py @@ -10,8 +10,15 @@ Parses org-drill structure: - Top-level "* Section" headings become tags on every card under them. - Each "** Card name :drill:" entry becomes a card. Front = heading - text (sans :drill: tag). Back = entry body with newlines converted + text (sans the tag block). Back = entry body with newlines converted to <br>. + - A card may carry a second org tag ("** Card :fundamental:drill:"). + Any heading whose tag block includes `drill` is a card; the other + tags ride along as Anki tags next to the section tag, so a curated + subset stays grep-able in the source. --tag-filter <tag> emits only + the cards carrying that tag, and a subset deck built that way should + pass --guid-salt so its notes get their own GUID space (Anki dedupes + on GUID, so without it the subset imports empty against the full deck). Deck name defaults to the org #+TITLE: (so the phone deck reads as the curated title), falling back to the input basename when the source has @@ -27,6 +34,8 @@ Usage: flashcard-to-anki.py <input.org> flashcard-to-anki.py <input.org> --deck "My Deck Name" flashcard-to-anki.py <input.org> --output /path/to/deck.apkg + flashcard-to-anki.py <input.org> --tag-filter fundamental \ + --deck "DeepSat Fundamentals" --guid-salt fundamentals Requires genanki, which uv resolves automatically via the PEP 723 script metadata above. No venv or system install needed. @@ -47,6 +56,15 @@ import genanki ID_BASE = 1_500_000_000 ID_RANGE = 500_000_000 +# A card is any level-2 heading whose trailing org tag block includes `drill`. +# Group 1 is the front text, group 2 the colon-delimited tag block (e.g. +# ":fundamental:drill:") — so a curated subset can carry a second org tag +# (:fundamental:) and stay grep-able in the source without dropping from the +# full deck. HEADING_RE bounds a card's body at the next L1/L2 heading. +CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+(:[A-Za-z0-9_@#%:]+:)\s*$") +HEADING_RE = re.compile(r"^\*{1,2}\s") +SECTION_RE = re.compile(r"^\*\s+(.+?)\s*$") + def stable_id(name: str, salt: str) -> int: """Derive a deterministic 32-bit id from `name` and a `salt`. @@ -120,33 +138,40 @@ def strip_org_metadata(body_lines: list[str]) -> list[str]: return cleaned -def parse(org_text: str) -> list[tuple[str, str, str]]: - """Return [(front, back_html, tag), ...] for every :drill: card.""" - cards: list[tuple[str, str, str]] = [] - current_section: str | None = None +def parse( + org_text: str, tag_filter: str | None = None +) -> list[tuple[str, str, list[str]]]: + """Return [(front, back_html, anki_tags), ...] for every :drill: card. - section_re = re.compile(r"^\*\s+(.+?)\s*$") - card_re = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") + A card is any level-2 heading whose trailing org tag block includes + `drill`. Non-drill org tags on the heading (e.g. :fundamental:) ride + along as Anki tags next to the section tag, so a curated subset stays + grep-able in the source. When `tag_filter` is set, only cards carrying + that org tag are returned (the subset-deck path). + """ + cards: list[tuple[str, str, list[str]]] = [] + current_section: str | None = None lines = org_text.splitlines() i = 0 while i < len(lines): line = lines[i] - sec = section_re.match(line) + sec = SECTION_RE.match(line) if sec: current_section = sec.group(1).strip() i += 1 continue - card = card_re.match(line) - if card: - front = card.group(1).strip() + m = CARD_RE.match(line) + tags = [t for t in m.group(2).split(":") if t] if m else [] + if m and "drill" in tags: + front = m.group(1).strip() body_lines: list[str] = [] i += 1 while i < len(lines): nxt = lines[i] - if nxt.startswith("* ") or card_re.match(nxt): + if HEADING_RE.match(nxt): break body_lines.append(nxt) i += 1 @@ -156,8 +181,17 @@ def parse(org_text: str) -> list[tuple[str, str, str]]: while body_lines and not body_lines[-1].strip(): body_lines.pop() back_html = "<br>".join(escape_html(ln) for ln in body_lines) - tag = section_to_tag(current_section) if current_section else "drill" - cards.append((front, back_html, tag)) + + org_tags = [t for t in tags if t != "drill"] + if tag_filter and tag_filter not in org_tags: + continue + anki_tags: list[str] = [] + if current_section: + anki_tags.append(section_to_tag(current_section)) + anki_tags.extend(org_tags) + if not anki_tags: + anki_tags = ["drill"] + cards.append((front, back_html, anki_tags)) continue i += 1 @@ -165,15 +199,28 @@ def parse(org_text: str) -> list[tuple[str, str, str]]: return cards -def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck: +def card_guid(front: str, guid_salt: str | None) -> str: + """GUID for a card's front. A salt gives a derived subset deck its own + GUID space so its notes don't collide with the full deck's (Anki dedupes + on GUID, which would otherwise import the subset empty). No salt is the + original behavior, so an unsalted deck's GUIDs and SRS state are untouched. + """ + return genanki.guid_for(guid_salt, front) if guid_salt else genanki.guid_for(front) + + +def build( + cards: list[tuple[str, str, list[str]]], + deck_name: str, + guid_salt: str | None = None, +) -> genanki.Deck: deck = genanki.Deck(stable_id(deck_name, "deck"), deck_name) model = make_model(deck_name) - for front, back, tag in cards: + for front, back, tags in cards: note = genanki.Note( model=model, fields=[front, back], - tags=[tag], - guid=genanki.guid_for(front), + tags=tags, + guid=card_guid(front, guid_salt), ) deck.add_note(note) return deck @@ -219,6 +266,16 @@ def main() -> int: help="Output .apkg path. Defaults to " "~/sync/phone/anki/<input-basename>.apkg.", ) + parser.add_argument( + "--tag-filter", + help="Emit only cards carrying this org tag (e.g. --tag-filter " + "fundamental for a curated subset deck).", + ) + parser.add_argument( + "--guid-salt", + help="Salt note GUIDs so a subset deck gets its own GUID space and " + "imports non-empty without disturbing the full deck's SRS state.", + ) args = parser.parse_args() input_path: Path = args.input.expanduser().resolve() @@ -231,12 +288,18 @@ def main() -> int: output_path: Path = (args.output or default_output_path(input_path)).expanduser().resolve() output_path.parent.mkdir(parents=True, exist_ok=True) - cards = parse(org_text) + cards = parse(org_text, tag_filter=args.tag_filter) if not cards: - print(f"error: no :drill: cards found in {input_path}", file=sys.stderr) + if args.tag_filter: + print( + f"error: no :drill: cards tagged :{args.tag_filter}: in {input_path}", + file=sys.stderr, + ) + else: + print(f"error: no :drill: cards found in {input_path}", file=sys.stderr) return 1 - deck = build(cards, deck_name) + deck = build(cards, deck_name, guid_salt=args.guid_salt) genanki.Package(deck).write_to_file(str(output_path)) print(f"wrote {output_path} ({len(cards)} cards, deck '{deck_name}')") return 0 diff --git a/claude-templates/.ai/scripts/inbox-send.py b/claude-templates/.ai/scripts/inbox-send.py index 1ebb636..663efcb 100755 --- a/claude-templates/.ai/scripts/inbox-send.py +++ b/claude-templates/.ai/scripts/inbox-send.py @@ -31,6 +31,7 @@ import os import re import shutil import sys +import tempfile from datetime import datetime from pathlib import Path @@ -48,7 +49,7 @@ def resolve_roots() -> list[Path]: config = Path.home() / ".claude" / "inbox-roots.txt" if config.is_file(): paths: list[Path] = [] - for line in config.read_text().splitlines(): + for line in config.read_text(encoding="utf-8").splitlines(): line = line.strip() if line and not line.startswith("#"): paths.append(Path(line).expanduser()) @@ -69,17 +70,28 @@ def discover_projects(roots: list[Path]) -> list[Path]: a specific project root (included directly if it qualifies). """ projects: list[Path] = [] + seen: set[Path] = set() + + def _add(p: Path) -> None: + # Dedupe on the resolved path: a roots config naming both a parent and + # one of its children would otherwise list the child project twice, at + # two different indices. + key = p.resolve() + if key not in seen: + seen.add(key) + projects.append(p) + for root in roots: if not root.is_dir(): continue if _is_project(root): - projects.append(root) + _add(root) continue for child in sorted(root.iterdir()): if not child.is_dir(): continue if _is_project(child): - projects.append(child) + _add(child) return projects @@ -194,6 +206,39 @@ def uniquify(dest: Path) -> Path: n += 1 +def _atomic_write(dest: Path, writer) -> None: + """Write to a temp file in dest's directory, then rename it into place. + + dest is another project's inbox/, and a direct write truncates the target + on open, so any mid-write failure (a full disk, an encoding error, an + interrupted process) leaves a zero-byte .org there. inbox-status counts + that phantom as a pending handoff and blocks a turn in the receiving + project over a file with no content and no sender (2026-07-23). Writing to + a temp sibling and os.replace-ing means the inbox only ever sees a complete + file. os.replace is atomic within one filesystem, and the temp sits in the + same directory as dest, so it is. + + `writer` receives the open temp path and fills it. On any failure the temp + is removed and the error re-raised, so a caught error never leaves debris. + """ + fd, tmp = tempfile.mkstemp( + dir=dest.parent, prefix=".inbox-send-", suffix=dest.suffix + ) + os.close(fd) + tmp_path = Path(tmp) + # mkstemp creates the temp 0600; give the delivered file the umask-default + # mode the old direct write produced, so inbox files stay readable as before. + umask = os.umask(0) + os.umask(umask) + os.chmod(tmp_path, 0o666 & ~umask) + try: + writer(tmp_path) + os.replace(tmp_path, dest) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + + def send_text( target_inbox: Path, message: str, @@ -209,7 +254,8 @@ def send_text( raise ValueError(f"could not derive a slug from text: {message!r}") filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org" dest = uniquify(target_inbox / filename) - dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT))) + body = build_text_org(message, source_name, now.strftime(TS_DOC_FMT)) + _atomic_write(dest, lambda p: p.write_text(body, encoding="utf-8")) return dest @@ -229,7 +275,7 @@ def send_file( ext = src_path.suffix filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}" dest = uniquify(target_inbox / filename) - shutil.copy2(src_path, dest) + _atomic_write(dest, lambda p: shutil.copyfile(src_path, p)) return dest @@ -310,7 +356,10 @@ def main() -> int: else: assert args.file is not None dest = send_file(target_inbox, args.file, source_name, args.name, now) - except (ValueError, FileNotFoundError) as exc: + except (ValueError, OSError) as exc: + # OSError covers FileNotFoundError (missing source), PermissionError + # (unreadable source), and any atomic-write failure — all should + # surface as the clean "inbox-send: <message>" error, never a traceback. print(f"inbox-send: {exc}", file=sys.stderr) return 1 diff --git a/claude-templates/.ai/scripts/inbox-status b/claude-templates/.ai/scripts/inbox-status index b917144..17031af 100755 --- a/claude-templates/.ai/scripts/inbox-status +++ b/claude-templates/.ai/scripts/inbox-status @@ -35,6 +35,7 @@ mapfile -t pending < <(find inbox -maxdepth 1 -type f \ ! -name '.gitkeep' \ ! -name 'lint-followups.org' \ ! -name 'PROCESSED-*' \ + ! -name '.inbox-send-*' \ -printf '%f\n' 2>/dev/null | sort) n=${#pending[@]} diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el index 90b1b1d..33dc52f 100644 --- a/claude-templates/.ai/scripts/lint-org.el +++ b/claude-templates/.ai/scripts/lint-org.el @@ -2,16 +2,19 @@ ;; ;; Usage: ;; emacs --batch -q -l lint-org.el FILE.org [FILE.org ...] +;; report only (the default) — categorize without modifying the file. +;; A linter reports, it doesn't write; mutation requires --fix. +;; --check is accepted as an explicit alias of this default. +;; +;; emacs --batch -q -l lint-org.el --fix FILE.org [FILE.org ...] ;; apply mechanical fixes in place, emit judgment items on stdout for the ;; command layer to walk ;; -;; emacs --batch -q -l lint-org.el --check FILE.org [FILE.org ...] -;; report only — categorize without modifying the file -;; -;; emacs --batch -q -l lint-org.el --followups-file=PATH FILE.org +;; emacs --batch -q -l lint-org.el --fix --followups-file=PATH FILE.org ;; apply mechanical fixes; if any judgment items remain, append them to ;; PATH as an org section dated today. Used by wrap-it-up to defer the ;; judgment walk to the next morning's review without blocking the wrap. +;; (--followups-file only writes in --fix mode.) ;; ;; Mechanical categories (auto-fixed): ;; item-number add [@N] directive to drifted bullets @@ -35,7 +38,9 @@ ;; empty-heading bare stars with no title ;; malformed-priority-cookie [#x]-shaped token org rejected ;; level2-done-without-closed completed level-2 task with no CLOSED +;; task-missing-last-reviewed open level-2 task with no :LAST_REVIEWED: ;; subtask-done-not-dated level-3+ done sub-task still a DONE keyword +;; dated-log-heading-active-timestamp dated-log heading with a live SCHEDULED/DEADLINE ;; (anything else) surfaced as judgment with checker name ;; ;; Output format on stdout: @@ -66,9 +71,23 @@ Each plist has :kind (mechanical-fixed | judgment), :line, :checker, :msg. Mechanical entries from --check mode also carry :preview t.") (defvar lo-check-only nil - "Non-nil means run in report-only mode — no buffer writes.") + "Non-nil means run in report-only mode — no buffer writes. +The CLI defaults this to t (a linter reports, it doesn't write); +`--fix' is what enables writes on a command-line run.") (defvar lo-current-file nil "Path of the file currently being processed.") + +(defun lo--spec-file-p () + "Non-nil when the current file lives under a docs/specs/ directory. +The four todo-format-family checkers encode todo.org completion conventions +and misfire on a spec: a spec's Decisions section legitimately carries a +level-2 DONE with no CLOSED cookie, and its review-history section carries +level-2 dated headings. docs/specs/ is the canonical spec home per the +docs-lifecycle rule, so a path segment match is the scope test. Link, +table, and structural checks still run on specs — only the todo-format +family is scoped out." + (and lo-current-file + (string-match-p "/docs/specs/" (expand-file-name lo-current-file)))) (defvar lo-followups-file nil "When non-nil, after a non-check run any judgment items are appended to this path as an org section dated today. The file is created if missing.") @@ -287,6 +306,52 @@ Craig-specific annotation marker rather than Babel src-block syntax." (lo--goto-line line) (looking-at-p "^[ \t]*#\\+begin_src[ \t]+cj:"))) +(defvar-local lo--matched-blocks-cache nil + "Cons of (TICK . REGIONS) memoizing `lo--matched-block-regions'. +TICK is the `buffer-chars-modified-tick' the regions were computed at, so a +fix applied mid-pass invalidates them.") + +(defun lo--matched-block-regions () + "Return ((BEGIN-LINE . END-LINE) ...) for every correctly paired block. +Scans lines directly rather than asking org, because org's own parser is what +mis-reads these blocks: a heading-shaped line inside a verbatim body reads as a +structural break and loses the open block. The scan applies org's real rule — +once a block is open, only its own `#+end_TYPE' closes it, so a nested +`#+begin_' or a foreign `#+end_' in the body is just text." + (let ((tick (buffer-chars-modified-tick))) + (if (eql (car lo--matched-blocks-cache) tick) + (cdr lo--matched-blocks-cache) + (let ((case-fold-search t) + (regions nil) (open-type nil) (open-line nil) (line 0)) + (save-excursion + (goto-char (point-min)) + (while (not (eobp)) + (setq line (1+ line)) + (let ((text (buffer-substring-no-properties + (line-beginning-position) (line-end-position)))) + (cond + (open-type + (when (string-match + (format "\\`[ \t]*#\\+end_%s[ \t]*\\'" + (regexp-quote open-type)) + text) + (push (cons open-line line) regions) + (setq open-type nil open-line nil))) + ((string-match "\\`[ \t]*#\\+begin_\\([^ \t\n]+\\)" text) + (setq open-type (match-string 1 text) + open-line line)))) + (forward-line 1))) + (setq lo--matched-blocks-cache (cons tick (nreverse regions))) + (cdr lo--matched-blocks-cache))))) + +(defun lo--in-matched-block-p (line) + "Non-nil when LINE sits within a correctly paired block, delimiters included. +org-lint reports `invalid-block' at the delimiter lines themselves, so the +range has to be inclusive for the suppression to reach them." + (cl-some (lambda (region) + (and (>= line (car region)) (<= line (cdr region)))) + (lo--matched-block-regions))) + (defun lo--handle-item (item) (let ((name (lo--checker-name item)) (line (lo--line item)) @@ -299,6 +364,13 @@ Craig-specific annotation marker rather than Babel src-block syntax." wrong-header-argument)) (lo--cj-comment-block-opener-p line)) nil) + ;; `invalid-block' on a block that is in fact correctly paired — the + ;; checker is org-lint's own, so this filters its output rather than + ;; fixing a local checker. A genuinely unterminated block isn't in any + ;; matched region, so it still reports. + ((and (eq name 'invalid-block) + (lo--in-matched-block-p line)) + nil) ((eq name 'item-number) (lo--apply-or-preview name line msg #'lo-fix-item-number)) ((eq name 'missing-language-in-src-block) @@ -355,24 +427,40 @@ logical row, matching wrap-org-table.el's grouping." (defun lo--check-tables () "Scan the current buffer for org tables violating the table standard. -Emits one judgment item per violating table." +Emits one judgment item per violating table. Pipe-led lines inside +#+begin_/#+end_ blocks are content (ASCII art, shell pipes), not tables, +and are skipped — the same block rule `wot-process-file' applies." (save-excursion (goto-char (point-min)) - (while (re-search-forward "^[ \t]*|" nil t) - (let ((start-line (line-number-at-pos)) - (lines nil)) - (beginning-of-line) - (while (and (not (eobp)) (looking-at "[ \t]*|")) - (push (buffer-substring-no-properties (line-beginning-position) - (line-end-position)) - lines) + (let ((in-block nil)) ; the open block's type, e.g. "example" — nil outside + (while (not (eobp)) + (cond + ;; Type-matched close only: literal #+end_src quoted inside an + ;; example block must not clear the flag (see wot-process-file). + ((and (not in-block) + (looking-at "^[ \t]*#\\+begin_\\([^ \t\n]+\\)")) + (setq in-block (downcase (match-string 1))) (forward-line 1)) - (let ((violations (lo--table-violations (nreverse lines)))) - (when violations - (lo--emit-judgment - 'org-table-standard start-line - (format "table violates the org-table standard: %s — wrap-org-table.el reflows it" - (string-join violations "; "))))))))) + ((and in-block + (looking-at-p (format "^[ \t]*#\\+end_%s\\([ \t]\\|$\\)" + (regexp-quote in-block)))) + (setq in-block nil) + (forward-line 1)) + ((and (not in-block) (looking-at-p "^[ \t]*|")) + (let ((start-line (line-number-at-pos)) + (lines nil)) + (while (and (not (eobp)) (looking-at "[ \t]*|")) + (push (buffer-substring-no-properties (line-beginning-position) + (line-end-position)) + lines) + (forward-line 1)) + (let ((violations (lo--table-violations (nreverse lines)))) + (when violations + (lo--emit-judgment + 'org-table-standard start-line + (format "table violates the org-table standard: %s — wrap-org-table.el reflows it" + (string-join violations "; "))))))) + (t (forward-line 1))))))) ;;; --------------------------------------------------------------------------- ;;; level-2 dated-header check (claude-rules/todo-format.md) @@ -504,6 +592,42 @@ the live file on the next `task-sorted'." "level-2 DONE/CANCELLED has no CLOSED date — add CLOSED: [YYYY-MM-DD Day]; task-sorted's aging step archives an undated completed task immediately")))))))) ;;; --------------------------------------------------------------------------- +;;; task-missing-last-reviewed check (claude-rules/todo-format.md) +;; +;; A task is stamped `:LAST_REVIEWED:' when it is *created*, not a review cycle +;; later. An agent filing a task has just written its body and graded its +;; priority, which is a review by any honest reading — so a fresh task that +;; carries no stamp reads as "never reviewed" and lands at the top of the next +;; staleness batch, where re-reviewing it is pure ceremony. Every task filed +;; during the 2026-07-23 sweep hit exactly that, which is what prompted the rule. +;; +;; Judgment-only, deliberately. The stamp's whole value is that its date is +;; true, and nothing here can know when an unstamped task was actually last +;; looked at. Auto-stamping today's date would convert a "nobody has reviewed +;; this" signal into a false "reviewed today" one — worse than the gap it +;; closes. Flag it; a human or the filing workflow supplies the honest date. +;; +;; Scope matches `task-review-staleness.sh' exactly (level-2, open keyword, +;; priority cookie), so the checker and the staleness count never disagree +;; about which headings are in the review pool. + +(defun lo--check-task-missing-last-reviewed () + "Flag an open level-2 task with a priority cookie and no `:LAST_REVIEWED:'." + (save-excursion + (goto-char (point-min)) + (let ((case-fold-search nil)) + (while (re-search-forward "^\\*\\* \\(TODO\\|DOING\\|VERIFY\\) \\[#[A-D]\\]" nil t) + (let ((hline (line-number-at-pos)) + (entry-end (save-excursion (outline-next-heading) (point)))) + (save-excursion + (forward-line 1) + (unless (re-search-forward "^[ \t]*:LAST_REVIEWED:[ \t]*[[0-9]" + entry-end t) + (lo--emit-judgment + 'task-missing-last-reviewed hline + "task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed")))))))) + +;;; --------------------------------------------------------------------------- ;;; level-3+ dated-header check (claude-rules/todo-format.md) ;; ;; The inverse of the level-2 check above. A completed sub-task — a heading at @@ -530,6 +654,41 @@ Emits one judgment item per offending heading (checker "level-3+ done sub-task should be a dated event-log entry (todo-format.md): run todo-cleanup.el --convert-subtasks to rewrite it"))))) ;;; --------------------------------------------------------------------------- +;;; dated-log heading with a stale active planning timestamp (todo-format.md) +;; +;; The mechanical backstop for the planning-line-strip rule. A dated event-log +;; heading (`<stars> YYYY-MM-DD Day @ ...', no TODO keyword) records completed +;; work — its date lives in the heading. An active `<...>' SCHEDULED or DEADLINE +;; left on it pins the entry to the agenda forever: org renders any headline with +;; an active planning timestamp, keyword or not, so a stale SCHEDULED shows as +;; weeks-overdue long after the work is done. Invisible to a keyword scan (no +;; TODO) and it survives --archive-done, so nothing else catches it. The +;; completion rewrite and todo-cleanup --convert-subtasks now strip the planning +;; line; this flags any that slipped through before that landed, the same way +;; subtask-done-not-dated backstops the depth rule. Judgment-only. + +(defun lo--check-dated-log-active-timestamp () + "Flag a dated event-log heading that still carries an active SCHEDULED/DEADLINE. +The heading matches `<stars> YYYY-MM-DD Day @ ...' with no TODO keyword; an +active `<...>' planning timestamp in its entry is the defect. An inactive +`[...]' timestamp is ignored (org doesn't render it on the agenda). Emits one +judgment item per offending heading (checker `dated-log-heading-active-timestamp')." + (save-excursion + (goto-char (point-min)) + (let ((case-fold-search nil)) + (while (re-search-forward + "^\\*+ [0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]+ @ " nil t) + (let ((hline (line-number-at-pos)) + (entry-end (save-excursion (outline-next-heading) (point)))) + (save-excursion + (forward-line 1) + (when (re-search-forward + "^[ \t]*\\(?:SCHEDULED\\|DEADLINE\\):[ \t]*<" entry-end t) + (lo--emit-judgment + 'dated-log-heading-active-timestamp hline + "dated-log heading carries an active SCHEDULED/DEADLINE — org renders any active planning timestamp (keyword or not), so it stays on the agenda as weeks-overdue; delete the planning line (todo-format.md)")))))))) + +;;; --------------------------------------------------------------------------- ;;; File processing (defun lo--backup (file) @@ -563,14 +722,22 @@ left unmodified and mechanical entries are recorded with :preview t." ;; After org-lint items: the custom table-standard scan. Runs on the ;; post-fix buffer; judgment-only, so order doesn't perturb fixes. (lo--check-tables) - ;; Same shape: flag level-2 dated headers (completion defects). - (lo--check-level2-dated-headers) - ;; Structural heading defects org-lint doesn't cover. + ;; Structural heading defects org-lint doesn't cover. These run on + ;; every org file, specs included. (lo--check-indented-headings) (lo--check-empty-headings) (lo--check-malformed-priority-cookies) - (lo--check-level2-done-without-closed) - (lo--check-subtask-done-not-dated) + ;; The todo-format family encodes todo.org completion conventions and + ;; misfires on a spec (a Decisions section's undated DONE, a + ;; review-history dated heading, a phases task with no LAST_REVIEWED). + ;; Scope them out of docs/specs/; link, table, and structural checks + ;; above still run there. + (unless (lo--spec-file-p) + (lo--check-level2-dated-headers) + (lo--check-level2-done-without-closed) + (lo--check-task-missing-last-reviewed) + (lo--check-subtask-done-not-dated) + (lo--check-dated-log-active-timestamp)) (when (and (not lo-check-only) (buffer-modified-p)) (save-buffer))) (with-current-buffer buf (set-buffer-modified-p nil)) @@ -677,6 +844,13 @@ After printing, also append judgments to `lo-followups-file' when set." ;;; CLI (defun lo-main () + ;; Report-only is the CLI default; --fix is the only way a command-line run + ;; writes to disk. The old mutate-by-default reformatted five files in one + ;; pass before anyone confirmed anything (work project, 2026-07-09). + (setq lo-check-only t) + (when (member "--fix" command-line-args-left) + (setq lo-check-only nil) + (setq command-line-args-left (delete "--fix" command-line-args-left))) (when (member "--check" command-line-args-left) (setq lo-check-only t) (setq command-line-args-left (delete "--check" command-line-args-left))) @@ -688,7 +862,7 @@ After printing, also append judgments to `lo-followups-file' when set." (setq command-line-args-left (delete followups command-line-args-left)))) (if (null command-line-args-left) (progn - (princ "Usage: emacs --batch -q -l lint-org.el [--check] [--followups-file=PATH] FILE.org ...\n") + (princ "Usage: emacs --batch -q -l lint-org.el [--fix] [--check] [--followups-file=PATH] FILE.org ...\n") (kill-emacs 1)) (let ((files command-line-args-left)) (setq command-line-args-left nil) @@ -707,7 +881,7 @@ this file without firing the CLI dispatch — under `ert-run-tests-batch-and-exi the trailing args are things like `-f ert-run-tests-batch-and-exit'." (and command-line-args-left (cl-every (lambda (a) - (cond ((member a '("--check")) t) + (cond ((member a '("--check" "--fix")) t) ((string-prefix-p "--followups-file=" a) t) ((string-prefix-p "-" a) nil) (t (file-readable-p a)))) diff --git a/claude-templates/.ai/scripts/route_recommend.py b/claude-templates/.ai/scripts/route_recommend.py index 7b36405..12ab132 100644 --- a/claude-templates/.ai/scripts/route_recommend.py +++ b/claude-templates/.ai/scripts/route_recommend.py @@ -71,6 +71,15 @@ def recommend(item: str, projects: list[str]) -> tuple[str | None, str]: if not projects: return (None, "none") + # Collapse identical names first. Projects are addressed by bare basename, so + # two projects sharing one across roots (~/code/notes, ~/projects/notes) arrive + # twice; both literal-match, and the tie test below then read that as ambiguity + # and downgraded a correct strong match to weak. Deduping here rather than in + # discover_destination_names protects every caller of the pure core, not just + # the CLI path. Order-preserving, and it collapses only identical names — two + # *different* projects matching is real ambiguity and still downgrades. + projects = list(dict.fromkeys(projects)) + item_lower = item.lower() item_tokens = _tokens(item) diff --git a/claude-templates/.ai/scripts/tests/agent-lock.bats b/claude-templates/.ai/scripts/tests/agent-lock.bats new file mode 100644 index 0000000..dbcffe1 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/agent-lock.bats @@ -0,0 +1,214 @@ +#!/usr/bin/env bats +# +# Tests for claude-templates/.ai/scripts/agent-lock — a mkdir-atomic advisory +# lock helper for agent workflows (sentry's single-runner and roam-write +# locks). flock can't span an agent's tool calls: every Bash call is its own +# short-lived shell, so a flock dies with the call that took it. This helper +# persists the lock on disk between calls and self-clears after a crash via +# age-based staleness reclaim. +# +# Contract under test: +# agent-lock acquire <name> [--ttl=SECONDS] [--wait[=SECONDS]] +# exit 0 → acquired (fresh, or reclaimed from a stale prior holder). +# exit 1 → busy: a live lock holds <name>; deferred (note on stderr). +# exit 2 → usage error (bad/absent name, unknown subcommand). +# agent-lock refresh <name> → re-touch a held lock (heartbeat); exit 1 if absent. +# agent-lock release <name> → remove the lock; idempotent (exit 0 if already free). +# agent-lock status <name> → print free|held|stale + metadata; exit 0 (query). +# agent-lock path <name> → print the resolved lock dir path; does not create it. +# +# Staleness is age-based on the metadata file's mtime versus the lock's own +# recorded TTL, so a crashed holder's lock expires instead of wedging every +# later acquire. Heartbeat (refresh) re-touches the mtime, keeping a live +# holder's lock young. Every reclaim surfaces a note (never silent). +# +# Lock home: /run/user/<uid>/agent-locks/<name>/ (tmpfs: host-local, out of +# every repo, cleared on reboot), with ~/.cache/agent-locks/ as the fallback +# where no runtime dir exists. AGENT_LOCK_DIR overrides the base for tests and +# advanced callers; the helper otherwise owns the path scheme and callers pass +# only names. +# +# Strategy: AGENT_LOCK_DIR points every lock at a temp base, so tests never +# touch a real runtime dir. Staleness is exercised by aging the metadata +# file's mtime with `touch` rather than sleeping. + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/agent-lock" +BASH_BIN="$(command -v bash)" + +setup() { + TEST_DIR="$(mktemp -d -t agent-lock-bats.XXXXXX)" + LOCK_BASE="$TEST_DIR/locks" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +lock() { + run env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" "$@" +} + +# meta-file path for a lock name, for direct inspection / aging. +meta_of() { + printf '%s/%s/meta\n' "$LOCK_BASE" "$1" +} + +# ---- acquire: fresh win + metadata -------------------------------------- + +@test "acquire: fresh name wins (exit 0) and writes pid/host/timestamp/ttl" { + lock acquire job + [ "$status" -eq 0 ] + local meta; meta="$(meta_of job)" + [ -f "$meta" ] + grep -q "^pid=$$\|^pid=[0-9][0-9]*$" "$meta" + grep -q "^host=$(uname -n)$" "$meta" + grep -qE "^acquired=[0-9]{4}-[0-9]{2}-[0-9]{2}T" "$meta" + grep -qE "^ttl=[0-9]+$" "$meta" +} + +@test "acquire: honors an explicit --ttl in the metadata" { + lock acquire job --ttl=45 + [ "$status" -eq 0 ] + grep -q "^ttl=45$" "$(meta_of job)" +} + +# ---- acquire: contention (one winner) ----------------------------------- + +@test "acquire: a second acquire of a live lock defers (exit 1, note)" { + lock acquire job + [ "$status" -eq 0 ] + lock acquire job + [ "$status" -eq 1 ] + [[ "$output" == *job* ]] +} + +@test "acquire: two racing acquires yield exactly one winner" { + # Fire both without releasing; exactly one mkdir wins. + env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" acquire race & p1=$! + env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" acquire race & p2=$! + local r1=0 r2=0 + wait $p1 || r1=$? + wait $p2 || r2=$? + # One exits 0 (won), one exits 1 (deferred). + [ "$((r1 + r2))" -eq 1 ] +} + +# ---- release: frees the lock -------------------------------------------- + +@test "release: frees a held lock so the next acquire wins" { + lock acquire job + [ "$status" -eq 0 ] + lock release job + [ "$status" -eq 0 ] + [ ! -d "$LOCK_BASE/job" ] + lock acquire job + [ "$status" -eq 0 ] +} + +@test "release: is idempotent on an already-free lock (exit 0)" { + lock release never-held + [ "$status" -eq 0 ] +} + +# ---- staleness reclaim (surfaced, never silent) ------------------------- + +@test "acquire: reclaims a stale lock and surfaces the reclaim note" { + lock acquire job --ttl=1 + [ "$status" -eq 0 ] + # Age the metadata mtime well past the 1s TTL. + touch -d '1 hour ago' "$(meta_of job)" + lock acquire job --ttl=1 + [ "$status" -eq 0 ] + [[ "$output" == *reclaim* ]] + [[ "$output" == *job* ]] + # The reclaim installed fresh metadata (young again), not the aged holder's. + lock status job + [[ "$output" == *held* ]] + [[ "$output" != *stale* ]] +} + +@test "acquire: a lock inside its TTL is not stale (stays deferred)" { + lock acquire job --ttl=3600 + [ "$status" -eq 0 ] + lock acquire job --ttl=3600 + [ "$status" -eq 1 ] +} + +# ---- heartbeat (refresh keeps a live lock young) ------------------------ + +@test "refresh: re-touches a held lock so it is no longer stale" { + lock acquire job --ttl=1 + [ "$status" -eq 0 ] + touch -d '1 hour ago' "$(meta_of job)" + lock status job + [[ "$output" == *stale* ]] + lock refresh job + [ "$status" -eq 0 ] + lock status job + [[ "$output" == *held* ]] + [[ "$output" != *stale* ]] +} + +@test "refresh: an absent lock cannot be refreshed (exit 1)" { + lock refresh nothing + [ "$status" -eq 1 ] +} + +# ---- status query ------------------------------------------------------- + +@test "status: reports free for an unheld lock (exit 0)" { + lock status job + [ "$status" -eq 0 ] + [[ "$output" == *free* ]] +} + +@test "status: reports held with metadata for a live lock" { + lock acquire job --ttl=3600 + lock status job + [ "$status" -eq 0 ] + [[ "$output" == *held* ]] + [[ "$output" == *"host=$(uname -n)"* ]] +} + +# ---- path resolution: runtime dir home with cache fallback -------------- + +@test "path: resolves under AGENT_LOCK_DIR when set" { + lock path job + [ "$status" -eq 0 ] + [ "$output" = "$LOCK_BASE/job" ] + [ ! -d "$LOCK_BASE/job" ] # path does not create the lock +} + +@test "path: prefers the runtime dir home when no override is set" { + local rt="$TEST_DIR/run" + mkdir -p "$rt" + run env -u AGENT_LOCK_DIR XDG_RUNTIME_DIR="$rt" "$BASH_BIN" "$SCRIPT" path job + [ "$status" -eq 0 ] + [ "$output" = "$rt/agent-locks/job" ] +} + +@test "path: falls back to the cache home when no runtime dir exists" { + local home="$TEST_DIR/home" + mkdir -p "$home" + run env -u AGENT_LOCK_DIR -u XDG_RUNTIME_DIR -u XDG_CACHE_HOME \ + HOME="$home" "$BASH_BIN" "$SCRIPT" path job + [ "$status" -eq 0 ] + [ "$output" = "$home/.cache/agent-locks/job" ] +} + +# ---- usage errors ------------------------------------------------------- + +@test "usage: a missing name is a usage error (exit 2)" { + lock acquire + [ "$status" -eq 2 ] +} + +@test "usage: a name with a slash is rejected (exit 2)" { + lock acquire bad/name + [ "$status" -eq 2 ] +} + +@test "usage: an unknown subcommand is a usage error (exit 2)" { + lock frobnicate job + [ "$status" -eq 2 ] +} diff --git a/claude-templates/.ai/scripts/tests/flashcard-sync.bats b/claude-templates/.ai/scripts/tests/flashcard-sync.bats index 608a280..e6ffc21 100644 --- a/claude-templates/.ai/scripts/tests/flashcard-sync.bats +++ b/claude-templates/.ai/scripts/tests/flashcard-sync.bats @@ -6,6 +6,7 @@ setup() { SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" SYNC="$SCRIPT_DIR/flashcard-sync" + STATS="$SCRIPT_DIR/flashcard-stats.py" TMP="$(mktemp -d)" } @@ -36,3 +37,27 @@ EOF [ "$status" -eq 1 ] [ ! -f "$HOME/sync/phone/anki/dirty.apkg" ] } + +@test "flashcard-stats: a multi-tagged :fundamental:drill: card still counts" { + # Regression guard: a curated card carrying a second org tag must not drop + # from the count. A :drill:$ anchor would have counted only one card here. + cat > "$TMP/multitag.org" <<'EOF' +#+TITLE: Multitag Test + +* Orbital Regimes +** What is LEO? :fundamental:drill: +:PROPERTIES: +:ID: c1 +:END: +Low Earth Orbit is the region below about 2000 kilometers. +** What is GEO? :drill: +:PROPERTIES: +:ID: c2 +:END: +Geostationary orbit sits at roughly 35786 kilometers of altitude. +EOF + run python3 "$STATS" "$TMP/multitag.org" + [ "$status" -eq 0 ] + [[ "$output" == *"Cards: 2"* ]] + [[ "$output" == *clean* ]] +} diff --git a/claude-templates/.ai/scripts/tests/inbox-status.bats b/claude-templates/.ai/scripts/tests/inbox-status.bats index bc8a734..27a497e 100644 --- a/claude-templates/.ai/scripts/tests/inbox-status.bats +++ b/claude-templates/.ai/scripts/tests/inbox-status.bats @@ -45,6 +45,18 @@ teardown() { [[ "$output" == *"0 pending"* ]] } +@test "inbox-status: ignores an in-flight .inbox-send-* temp file" { + mkdir "$TMP/inbox" + # inbox-send writes to a .inbox-send-* temp then renames it into place; + # during that window the temp must not read as a pending handoff, or a + # concurrent boundary check blocks on a file that's about to become real. + touch "$TMP/inbox/.inbox-send-abc123.org" + cd "$TMP" + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"0 pending"* ]] +} + @test "inbox-status: -q suppresses the per-item lines" { mkdir "$TMP/inbox" echo body > "$TMP/inbox/handoff.org" diff --git a/claude-templates/.ai/scripts/tests/lint-org-cli.bats b/claude-templates/.ai/scripts/tests/lint-org-cli.bats index d457696..b9faef6 100644 --- a/claude-templates/.ai/scripts/tests/lint-org-cli.bats +++ b/claude-templates/.ai/scripts/tests/lint-org-cli.bats @@ -20,6 +20,24 @@ teardown() { [[ "$output" == *"lint-org: file="* ]] } +@test "lint-org.el default invocation is report-only — file untouched" { + # bare #+begin_src is a mechanical fix (→ #+begin_example) that the old + # default applied on disk; a linter reports, it doesn't write + printf '* H\n\n#+begin_src\nx\n#+end_src\n' > "$TMPFILE" + before="$(cat "$TMPFILE")" + run emacs --batch -q -l "$SCRIPTS_DIR/lint-org.el" "$TMPFILE" + [ "$status" -eq 0 ] + [[ "$output" == *"would-fix"* ]] + [ "$(cat "$TMPFILE")" = "$before" ] +} + +@test "lint-org.el --fix applies mechanical fixes on disk" { + printf '* H\n\n#+begin_src\nx\n#+end_src\n' > "$TMPFILE" + run emacs --batch -q -l "$SCRIPTS_DIR/lint-org.el" --fix "$TMPFILE" + [ "$status" -eq 0 ] + grep -q '#+begin_example' "$TMPFILE" +} + @test "wrap-org-table.el loads and runs without -L on the load path" { run emacs --batch -q -l "$SCRIPTS_DIR/wrap-org-table.el" --width=120 "$TMPFILE" [ "$status" -eq 0 ] diff --git a/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el index d14879f..ceee209 100644 --- a/claude-templates/.ai/scripts/tests/test-lint-org.el +++ b/claude-templates/.ai/scripts/tests/test-lint-org.el @@ -193,6 +193,65 @@ real suspicious-language warning here #+end_src ") +;; invalid-block, false-positive case — a correctly paired example block whose +;; body holds a heading-shaped line. org's parser reads the `** ' inside the +;; verbatim body as a structural break, loses the open block, and flags BOTH +;; delimiters as "Possible incomplete block". +(defconst lo-test--verbatim-heading-block "\ +* Heading + +#+begin_example +** Feature Name or Topic +Body line. +#+end_example + +Trailing prose. +") + +;; invalid-block, literal-delimiter case — a paired src block whose body holds +;; a literal `#+end_example' plus a heading-shaped line. Only `#+end_src' +;; closes a src block, so all three findings here are false. +(defconst lo-test--literal-end-in-src "\ +* Heading + +#+begin_src text +#+end_example +** heading shaped +#+end_src +") + +;; invalid-block, uppercase-delimiter case — org accepts #+BEGIN_/#+END_ in +;; either case, and the pre-fix script flagged both delimiters here too. +(defconst lo-test--uppercase-verbatim-block "\ +* Heading + +#+BEGIN_EXAMPLE +** heading shaped +#+END_EXAMPLE +") + +;; invalid-block, genuine case — a block that really is never closed. The +;; suppression must not reach this one. +(defconst lo-test--unterminated-block "\ +* Heading + +#+begin_example +truly unterminated block body +") + +;; A genuinely unterminated block *after* a correctly paired one — verifies the +;; suppression is scoped per block rather than per file. +(defconst lo-test--paired-then-unterminated "\ +* Heading + +#+begin_example +** heading shaped +#+end_example + +#+begin_example +never closed +") + ;; Mixed fixture — each category once. (defconst lo-test--mixed "\ * Mixed @@ -392,6 +451,55 @@ suspicious-language judgment." (should (= 1 suspicious)))) ;;; --------------------------------------------------------------------------- +;;; invalid-block — false positives on correctly paired verbatim blocks + +(ert-deftest lo-verbatim-heading-block-emits-no-invalid-block () + "Normal: a paired example block containing a heading-shaped body line emits +no invalid-block judgment. Both delimiters are flagged by org-lint because the +parser treats the `** ' inside the verbatim body as a structural break." + (let* ((out (lo-test--run lo-test--verbatim-heading-block)) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + ;; File untouched, no fixes applied — suppression only, never a rewrite. + (should (equal lo-test--verbatim-heading-block res)) + (should (= 0 (plist-get out :fixes))) + (should-not (member 'invalid-block (lo-test--checkers judgments))))) + +(ert-deftest lo-literal-end-delimiter-in-src-emits-no-invalid-block () + "Boundary: a paired src block whose body holds a literal `#+end_example' and +a heading-shaped line emits no invalid-block judgment. Only `#+end_src' closes +a src block, so the interior delimiter is body text." + (let* ((out (lo-test--run lo-test--literal-end-in-src)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'invalid-block (lo-test--checkers judgments))))) + +(ert-deftest lo-uppercase-verbatim-block-emits-no-invalid-block () + "Boundary: block delimiters are case-insensitive in org, so an uppercase +`#+BEGIN_EXAMPLE' pair is suppressed the same as a lowercase one." + (let* ((out (lo-test--run lo-test--uppercase-verbatim-block)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'invalid-block (lo-test--checkers judgments))))) + +(ert-deftest lo-unterminated-block-still-emits-invalid-block () + "Error: a block that is never closed still emits its invalid-block judgment. +This is the finding the checker exists for — the suppression must not mask it." + (let* ((out (lo-test--run lo-test--unterminated-block)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (member 'invalid-block (lo-test--checkers judgments))))) + +(ert-deftest lo-invalid-block-suppression-is-scoped-per-block () + "Boundary: a paired block and an unterminated block in the same file — the +paired one is suppressed and the unterminated one still reports. Exactly one +invalid-block judgment, and it points at the unterminated opener (line 7)." + (let* ((out (lo-test--run lo-test--paired-then-unterminated)) + (judgments (lo-test--judgments (plist-get out :issues))) + (invalid (cl-remove-if-not + (lambda (i) (eq (plist-get i :checker) 'invalid-block)) + judgments))) + (should (= 1 (length invalid))) + (should (= 7 (plist-get (car invalid) :line))))) + +;;; --------------------------------------------------------------------------- ;;; --check mode (ert-deftest lo-check-mode-does-not-modify-file () @@ -620,6 +728,29 @@ followups file on the next run." ;;; --------------------------------------------------------------------------- ;;; org-table-standard check (width budget + rules between rows) +(ert-deftest lo-table-inside-example-block-not-flagged () + "Pipe-led ASCII art inside an example block is not a table; no judgment." + (let* ((run (lo-test--run + "* H\n\n#+begin_example\n| client |----->| server |\n| box | | box |\n#+end_example\n" + 1 t)) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) + +(ert-deftest lo-table-inside-src-block-not-flagged () + "Shell pipes inside a src block are not a table; no judgment." + (let* ((run (lo-test--run + "* H\n\n#+begin_src sh\n| sort\n| uniq -c\n#+end_src\n" 1 t)) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) + +(ert-deftest lo-real-table-after-block-still-flagged () + "Block safety must not mask a genuine violation later in the file." + (let* ((run (lo-test--run + "* H\n\n#+begin_example\n| art |\n#+end_example\n\n| a | b |\n| 1 | 2 |\n" + 1 t)) + (judgments (lo-test--judgments (plist-get run :issues)))) + (should (memq 'org-table-standard (lo-test--checkers judgments))))) + (ert-deftest lo-table-over-budget-emits-judgment () "A table line rendering wider than 120 surfaces as an org-table-standard judgment." (let* ((wide (make-string 130 ?x)) @@ -716,6 +847,48 @@ missing-rules violation." (judgments (lo-test--judgments (plist-get out :issues)))) (should-not (member 'subtask-done-not-dated (lo-test--checkers judgments))))) +;;; dated-log-heading-active-timestamp check (stale SCHEDULED/DEADLINE on a +;;; completed dated-log entry — the home 2026-07-17 agenda-pollution bug) + +(ert-deftest lo-dated-log-active-scheduled-is-flagged () + "A dated-log entry still carrying an active SCHEDULED is flagged: org renders +it on the agenda forever despite the missing keyword." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 trip booked\nSCHEDULED: <2026-06-18 Thu>\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed + (should (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments))))) + +(ert-deftest lo-dated-log-active-deadline-is-flagged () + "An active DEADLINE on a dated-log entry is flagged too." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 shipped\nDEADLINE: <2026-06-25 Thu>\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments))))) + +(ert-deftest lo-dated-log-clean-entry-not-flagged () + "A dated-log entry with no active planning timestamp is correct — not flagged." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 done cleanly\nBody only.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments))))) + +(ert-deftest lo-dated-log-inactive-timestamp-not-flagged () + "An inactive [..] timestamp doesn't render on the agenda, so it isn't flagged — +only active <..> planning timestamps are the defect." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 recorded\nSCHEDULED: [2026-06-18 Thu]\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments))))) + +(ert-deftest lo-dated-log-active-scheduled-on-live-todo-not-flagged () + "A live TODO (keyword present) that legitimately carries an active SCHEDULED is +not a dated-log heading, so this checker leaves it alone." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent\n*** TODO [#C] real upcoming task\nSCHEDULED: <2026-06-18 Thu>\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments))))) + ;;; --------------------------------------------------------------------------- ;;; structural heading checks (org-lint gaps) @@ -794,3 +967,134 @@ heading, so it is not flagged — only two-or-more indented stars are." (provide 'test-lint-org) ;;; test-lint-org.el ends here + +;;; --------------------------------------------------------------------------- +;;; task-missing-last-reviewed (claude-rules/todo-format.md) + +(ert-deftest lo-task-without-last-reviewed-is-judgment () + "An open level-2 task with no :LAST_REVIEWED: is flagged." + (let* ((out (lo-test--run "* Open Work\n** TODO [#B] A task :feature:\nBody.\n")) + (js (lo-test--judgments (plist-get out :issues)))) + (should (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +(ert-deftest lo-task-with-last-reviewed-is-clean () + "A task carrying the property is not flagged." + (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n" + ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n" + "Body.\n"))) + (js (lo-test--judgments (plist-get out :issues)))) + (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +(ert-deftest lo-task-last-reviewed-accepts-org-timestamp () + "The org-native [YYYY-MM-DD Day] form counts, matching the staleness script." + (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n" + ":PROPERTIES:\n:LAST_REVIEWED: [2026-07-23 Thu]\n:END:\n"))) + (js (lo-test--judgments (plist-get out :issues)))) + (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +(ert-deftest lo-done-task-without-last-reviewed-is-clean () + "Completed tasks leave the review pool, so they are never flagged." + (let* ((out (lo-test--run (concat "* Open Work\n** DONE [#B] A task :feature:\n" + "CLOSED: [2026-07-23 Thu]\n"))) + (js (lo-test--judgments (plist-get out :issues)))) + (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +(ert-deftest lo-subtask-without-last-reviewed-is-clean () + "Only level-2 tasks are in the review pool; deeper headings are not." + (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] Parent :feature:\n" + ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n" + "*** TODO A sub-task\n"))) + (js (lo-test--judgments (plist-get out :issues)))) + (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +(ert-deftest lo-cookieless-task-without-last-reviewed-is-clean () + "The staleness script selects on a priority cookie, so match that scope." + (let* ((out (lo-test--run "* Open Work\n** TODO Manual testing and validation\n")) + (js (lo-test--judgments (plist-get out :issues)))) + (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +(ert-deftest lo-verify-task-without-last-reviewed-is-judgment () + "VERIFY is in the review pool too." + (let* ((out (lo-test--run "* Open Work\n** VERIFY [#B] Waiting on Craig\n")) + (js (lo-test--judgments (plist-get out :issues)))) + (should (memq 'task-missing-last-reviewed (lo-test--checkers js))))) + +;;; --------------------------------------------------------------------------- +;;; todo-format checkers skip docs/specs/ files (claude-rules/todo-format.md) +;; +;; The four todo-format-family checkers encode todo.org completion conventions. +;; A spec legitimately uses ** DONE <decision> with no CLOSED cookie and +;; ** <dated> — <who> review-history headings, so those checkers misfire on +;; every spec. They must skip any file under a docs/specs/ path segment. + +(defun lo-test--run-at (relpath content) + "Write CONTENT to <tmpdir>/RELPATH, run lint on it, return :issues. +RELPATH is a relative path (may contain slashes) so a docs/specs/ segment +can be exercised — the checkers key on the file's path, not just its name." + (let* ((root (make-temp-file "lo-test-root-" t)) + (file (expand-file-name relpath root))) + (make-directory (file-name-directory file) t) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (lo-test--reset) + (lo-process-file file) + (prog1 (list :issues lo-issues) + (lo-test--drop-buffer file))) + (delete-directory root t)))) + +(defconst lo-test--spec-decisions + "* Decisions [1/1]\n** DONE Some decision\n- Context: x\n" + "A spec Decisions section: a level-2 DONE with no CLOSED cookie.") + +(defconst lo-test--spec-history + "* Review history\n** 2026-07-14 Tue @ 02:03:28 -0500 — Claude — responder\n- What: x\n" + "A spec review-history section: a level-2 dated header.") + +(ert-deftest lo-todo-checkers-fire-on-a-normal-org-file () + "Baseline: the checkers DO fire on a non-spec path (the bug is scope, not silence)." + (let* ((out (lo-test--run-at "todo.org" lo-test--spec-decisions)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should (memq 'level2-done-without-closed cs)))) + +(ert-deftest lo-level2-done-without-closed-skips-specs () + (let* ((out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" lo-test--spec-decisions)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'level2-done-without-closed cs)))) + +(ert-deftest lo-level2-dated-header-skips-specs () + (let* ((out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" lo-test--spec-history)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'level-2-dated-header cs)))) + +(ert-deftest lo-dated-log-active-timestamp-skips-specs () + (let* ((c "* History\n** 2026-07-14 Tue @ 02:03:28 -0500 — did a thing\nSCHEDULED: <2026-07-20 Mon>\n") + (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'dated-log-heading-active-timestamp cs)))) + +(ert-deftest lo-subtask-done-not-dated-skips-specs () + (let* ((c "* Work\n** TODO Parent\n*** DONE A sub-decision\n") + (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'subtask-done-not-dated cs)))) + +(ert-deftest lo-link-checks-still-fire-on-specs () + "Only the todo-format family is scoped out; a broken link in a spec still flags." + (let* ((c "* X\n[[file:does-not-exist-xyz.org][link]]\n") + (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should (memq 'link-to-local-file cs)))) + +(ert-deftest lo-task-missing-last-reviewed-skips-specs () + "The fifth todo-format checker (added 2026-07-23) skips specs too — a spec's +phases section may carry ** TODO [#x] items that aren't backlog tasks." + (let* ((c "* Implementation phases\n** TODO [#B] Phase one\nBody.\n") + (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should-not (memq 'task-missing-last-reviewed cs))) + ;; And still fires on a normal file. + (let* ((c "* Work\n** TODO [#B] Real backlog task\nBody.\n") + (out (lo-test--run-at "todo.org" c)) + (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues))))) + (should (memq 'task-missing-last-reviewed cs)))) diff --git a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el index ffbf2fb..1e964b3 100644 --- a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el +++ b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el @@ -31,6 +31,7 @@ (defun tc-test--reset (&optional check) (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil + tc-sealed 0 tc-seal nil tc-convert-subtasks nil tc-check-only (and check t) tc-archive-done t tc-sync-child-priority nil tc-current-file nil @@ -40,6 +41,7 @@ (defun tc-test--reset-sync (&optional check) (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil + tc-sealed 0 tc-seal nil tc-check-only (and check t) tc-archive-done nil tc-sync-child-priority t tc-current-file nil @@ -514,6 +516,12 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat .gitignore contents or nil), :archive-ignored (whether git ignores the archive), :archive-exists." (let* ((root (make-temp-file "tc-git-" t)) + ;; Private backup dir: this helper writes a file literally named + ;; todo.org and runs a real (non-check) pass, so without this its + ;; backup lands in the shared temp dir under the exact production + ;; name and is indistinguishable from a real one. + (temporary-file-directory + (file-name-as-directory (make-temp-file "tc-git-bk-" t))) (todo (expand-file-name "todo.org" root)) (archive (expand-file-name "archive/task-archive.org" root)) (gi (expand-file-name ".gitignore" root))) @@ -534,7 +542,8 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat :archive-ignored (eq 0 (call-process "git" nil nil nil "check-ignore" "-q" archive)) :archive-exists (file-readable-p archive))) - (delete-directory root t)))) + (delete-directory root t) + (delete-directory temporary-file-directory t)))) (ert-deftest tc-age-self-protect-gitignores-archive-when-todo-ignored () "When the todo file is gitignored, the aged-out archive is added to .gitignore @@ -578,6 +587,95 @@ entry is added for it." (should (> (plist-get out :archived) 0))))) ;;; --------------------------------------------------------------------------- +;;; --archive-done retention default + +(ert-deftest tc-archive-retain-default-is-one-month () + "The shipped retention default is one month (31 days), not the legacy 7. +The defvar initializes from this defconst; the live var itself is mutated by +other tests, so the immutable defconst is the stable contract to pin." + (should (= 31 tc-archive-retain-days-default))) + +;;; --------------------------------------------------------------------------- +;;; --seal: rename the working archive to resolved-YYYY-MM-DD.org + +(defun tc-test--seal (&optional opts) + "Run `--seal' against a temp todo file with a temp archive dir. +OPTS is a plist: :archive-content (seed task-archive.org with this; nil = no +working archive), :ref (YEAR MONTH DAY seal date; default (2026 7 18)), +:check, :presealed (also create resolved-<ref>.org first, to test collision). +Returns a plist: :sealed count, :issues, :working-exists, :sealed-exists, +:sealed-name, :report." + (let* ((ref (or (plist-get opts :ref) '(2026 7 18))) + (check (plist-get opts :check)) + (archive-content (plist-get opts :archive-content)) + (todo (make-temp-file "tc-seal-todo-" nil ".org")) + (adir (make-temp-file "tc-seal-arch-" t)) + (afile (expand-file-name "task-archive.org" adir)) + (sealed-name (format "resolved-%04d-%02d-%02d.org" + (nth 0 ref) (nth 1 ref) (nth 2 ref))) + (sealed (expand-file-name sealed-name adir))) + (unwind-protect + (progn + (with-temp-file todo (insert "* Open Work\n** TODO [#A] live\n")) + (when archive-content (with-temp-file afile (insert archive-content))) + (when (plist-get opts :presealed) + (with-temp-file sealed (insert "pre-existing seal\n"))) + (tc-test--reset check) + ;; Set every mode flag explicitly: tc-test--reset leaves + ;; tc-convert-subtasks untouched, so a convert test running earlier in + ;; the suite would otherwise still own the dispatch and run convert. + (setq tc-archive-done nil tc-sync-child-priority nil + tc-convert-subtasks nil tc-seal t tc-sealed 0 + tc-archive-reference-date ref + tc-archive-file afile) + (let ((report (with-output-to-string (tc-process-file todo) (tc-emit-report)))) + (tc-test--drop-buffer todo) + (list :sealed tc-sealed + :issues tc-issues + :working-exists (file-readable-p afile) + :sealed-exists (file-readable-p sealed) + :sealed-name sealed-name + :report report))) + (tc-test--drop-buffer todo) + (delete-file todo) + (delete-directory adir t)))) + +(ert-deftest tc-seal-renames-working-archive-to-dated-file () + "Normal: --seal renames task-archive.org to resolved-<seal-date>.org." + (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n** DONE old\n" + :ref (2026 7 18))))) + (should (= 1 (plist-get out :sealed))) + (should-not (plist-get out :working-exists)) + (should (plist-get out :sealed-exists)) + (should (equal "resolved-2026-07-18.org" (plist-get out :sealed-name))) + (should (tc-test--has (plist-get out :report) "sealed task-archive.org → resolved-2026-07-18.org")))) + +(ert-deftest tc-seal-nothing-to-seal-is-a-reported-noop () + "Boundary: no working archive present — reported no-op, nothing created." + (let ((out (tc-test--seal '(:ref (2026 7 18))))) + (should (= 0 (plist-get out :sealed))) + (should-not (plist-get out :sealed-exists)) + (should (tc-test--has (plist-get out :report) "no working archive to seal")))) + +(ert-deftest tc-seal-check-mode-previews-without-renaming () + "Boundary: --check reports the seal but leaves the working archive in place." + (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n" + :ref (2026 7 18) :check t)))) + (should (= 1 (plist-get out :sealed))) + (should (plist-get out :working-exists)) + (should-not (plist-get out :sealed-exists)) + (should (tc-test--has (plist-get out :report) "would seal")))) + +(ert-deftest tc-seal-refuses-to-clobber-existing-sealed-file () + "Error: resolved-<today>.org already exists — refuse, leave both files intact." + (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n" + :ref (2026 7 18) :presealed t)))) + (should (= 0 (plist-get out :sealed))) + (should (plist-get out :working-exists)) + (should (plist-get out :sealed-exists)) + (should (tc-test--has (plist-get out :report) "already exists")))) + +;;; --------------------------------------------------------------------------- ;;; Sync-child-priority harness + fixtures (defun tc-test--sync (content &optional runs check) @@ -773,7 +871,7 @@ in ISSUES, in document order." (defun tc-test--reset-convert (&optional check) (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-converted 0 tc-archived-to-file 0 - tc-issues nil + tc-issues nil tc-sealed 0 tc-seal nil tc-check-only (and check t) tc-archive-done nil tc-sync-child-priority nil tc-convert-subtasks t tc-current-file nil @@ -927,8 +1025,9 @@ CLOSED: [2026-06-27 Sat 12:50] DEADLINE: <2026-06-30 Tue> Body line. ") -(ert-deftest tc-convert-preserves-deadline-on-shared-planning-line-boundary () - "Boundary: removing the CLOSED cookie keeps a DEADLINE sharing its planning line." +(ert-deftest tc-convert-strips-deadline-sharing-the-planning-line-boundary () + "Boundary: a DEADLINE sharing the CLOSED planning line goes too — a dated-log +entry carries no active planning timestamp (todo-format.md). Body survives." (let* ((out (tc-test--convert tc-test--convert-closed-with-deadline)) (res (plist-get out :result))) (should (= 1 (plist-get out :converted))) @@ -936,8 +1035,142 @@ Body line. "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Ship the panel$" res)) (should-not (string-match-p "CLOSED:" res)) - (should (string-match-p "^DEADLINE: <2026-06-30 Tue>$" res)) + (should-not (string-match-p "DEADLINE:" res)) + (should (string-match-p "^Body line\\.$" res)))) + +(defconst tc-test--convert-closed-and-scheduled-separate-lines + "* Project Open Work +** TODO [#B] Parent task +*** DONE [#C] Book the venue :feature: +CLOSED: [2026-06-27 Sat 12:50] +SCHEDULED: <2026-06-20 Sat> +Body line. +") + +(ert-deftest tc-convert-strips-scheduled-on-its-own-line () + "Normal (the home bug): a SCHEDULED planning line on its own — the completion +rewrite dropped keyword/priority/tags but left the SCHEDULED, pinning the dated +entry to the agenda as weeks-overdue. Both planning lines go; body survives." + (let* ((out (tc-test--convert tc-test--convert-closed-and-scheduled-separate-lines)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :converted))) + (should (string-match-p + "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Book the venue$" + res)) + (should-not (string-match-p "CLOSED:" res)) + (should-not (string-match-p "SCHEDULED:" res)) (should (string-match-p "^Body line\\.$" res)))) +(defconst tc-test--convert-scheduled-in-body-prose + "* Project Open Work +** TODO [#B] Parent task +*** DONE [#C] Note the mechanism :feature: +CLOSED: [2026-06-27 Sat 12:50] +An active SCHEDULED: <2026-06-20 Sat> in prose must survive. +") + +(ert-deftest tc-convert-leaves-planning-shaped-body-prose-alone () + "Boundary: a planning-shaped token inside body prose (not a canonical planning +line) is left untouched — the strip stops at the first non-planning line." + (let* ((out (tc-test--convert tc-test--convert-scheduled-in-body-prose)) + (res (plist-get out :result))) + (should (= 1 (plist-get out :converted))) + (should-not (string-match-p "CLOSED:" res)) + (should (string-match-p "An active SCHEDULED: <2026-06-20 Sat> in prose must survive\\." res)))) + (provide 'test-todo-cleanup) ;;; test-todo-cleanup.el ends here + +;;; --------------------------------------------------------------------------- +;;; Backup before mutating (parity with lint-org.el / wrap-org-table.el) +;; +;; todo-cleanup rewrites todo.org in place and left no copy behind, while both +;; sibling org-mutators back up to /tmp first. It is also the one that runs most +;; often (every wrap, every sentry cycle). Emacs's own backup does not fire under +;; --batch -q, so there was genuinely no undo short of git. + +(ert-deftest tc-backup-written-before-a-real-mutation () + "A real (non-check) run leaves a copy holding the pre-edit content. + +`temporary-file-directory' is rebound to a private dir for the duration: the +backup name derives from the *file's* basename, and the real todo.org shares +that basename, so a live sentry run writing /tmp/todo.org.before-todo-cleanup.* +would otherwise be indistinguishable from this test's own artifact. The first +version of this test globbed the shared /tmp and passed only until a real run +created one (2026-07-24)." + (let* ((dir (make-temp-file "tc-backup-" t)) + (bdir (file-name-as-directory (make-temp-file "tc-bk-" t))) + (file (expand-file-name "todo.org" dir)) + (before "* P Open Work\n** TODO [#B] parent\n*** DONE a subtask\nCLOSED: [2026-07-01 Tue]\n")) + (unwind-protect + (progn + (with-temp-file file (insert before)) + (let ((tc-check-only nil) + (tc-convert-subtasks t) + (temporary-file-directory bdir)) + (tc-process-file file)) + (let ((backups (file-expand-wildcards + (concat bdir "todo.org.before-todo-cleanup.*")))) + (should backups) + (should (string-match-p + "a subtask" + (with-temp-buffer (insert-file-contents (car backups)) + (buffer-string)))))) + (delete-directory dir t) + (delete-directory bdir t)))) + +(ert-deftest tc-no-backup-in-check-mode () + "--check writes nothing, so it must not leave a backup either. +Uses a private `temporary-file-directory' for the same isolation reason." + (let* ((dir (make-temp-file "tc-backup-" t)) + (bdir (file-name-as-directory (make-temp-file "tc-bk-" t))) + (file (expand-file-name "todo.org" dir))) + (unwind-protect + (progn + (with-temp-file file + (insert "* P Open Work\n** TODO [#B] parent\n*** DONE sub\nCLOSED: [2026-07-01 Tue]\n")) + (let ((tc-check-only t) + (tc-convert-subtasks t) + (temporary-file-directory bdir)) + (tc-process-file file)) + (should-not (file-expand-wildcards + (concat bdir "todo.org.before-todo-cleanup.*")))) + (delete-directory dir t) + (delete-directory bdir t)))) + +(ert-deftest tc-backup-never-overwrites-an-earlier-one () + "Two invocations in the same second must not collapse to one backup. + +open-tasks.org runs --convert-subtasks then --archive-done back to back, each +a sub-second batch run. With a second-resolution stamp and copy-file's +OK-IF-ALREADY-EXISTS, the second invocation overwrote the first's backup with +already-mutated content, so the true pre-session original was unrecoverable — +the exact state the backup exists to preserve (found 2026-07-24 in review)." + (let* ((dir (make-temp-file "tc-collide-" t)) + (bdir (file-name-as-directory (make-temp-file "tc-cbk-" t))) + (file (expand-file-name "todo.org" dir)) + (original (concat "* P Open Work\n** TODO [#B] parent\n*** DONE sub\n" + "CLOSED: [2026-07-01 Tue]\n" + "* P Resolved\n** DONE [#C] old\nCLOSED: [2025-01-01 Wed]\n"))) + (unwind-protect + (progn + (with-temp-file file (insert original)) + ;; Two back-to-back invocations, as the shipped workflow does. + (let ((temporary-file-directory bdir)) + (let ((tc-check-only nil) (tc-convert-subtasks t)) + (tc-process-file file)) + (let ((tc-check-only nil) (tc-convert-subtasks nil) (tc-archive-done t) + (tc-archive-retain-days nil)) + (tc-process-file file))) + (let ((backups (file-expand-wildcards + (concat bdir "todo.org.before-todo-cleanup.*")))) + ;; Both invocations kept their own backup. + (should (= (length backups) 2)) + ;; And one of them still holds the true original. + (should (cl-some (lambda (b) + (string= original + (with-temp-buffer (insert-file-contents b) + (buffer-string)))) + backups)))) + (delete-directory dir t) + (delete-directory bdir t)))) diff --git a/claude-templates/.ai/scripts/tests/test-wrap-org-table.el b/claude-templates/.ai/scripts/tests/test-wrap-org-table.el index 8d1ecb6..0b3b375 100644 --- a/claude-templates/.ai/scripts/tests/test-wrap-org-table.el +++ b/claude-templates/.ai/scripts/tests/test-wrap-org-table.el @@ -186,3 +186,45 @@ (should (string-match-p "Prose before\\." content)) (should (string-match-p "Prose after\\." content)))) (delete-file file)))) + +;;; --------------------------------------------------------------------------- +;;; block safety — pipe lines inside #+begin_/#+end_ blocks are never tables + +(defconst wot-test--block-content + "#+begin_example +| client |----->| server | +| box | | box | +#+end_example +" + "An example block whose ASCII-art lines start with pipes.") + +(defun wot-test--process-content (content budget) + "Write CONTENT to a temp file, run `wot-process-file' at BUDGET, return result." + (let ((file (make-temp-file "wot-test" nil ".org"))) + (unwind-protect + (progn + (with-temp-file file (insert content)) + (wot-process-file file budget) + (with-temp-buffer (insert-file-contents file) (buffer-string))) + (delete-file file)))) + +(ert-deftest wot-process-file-leaves-example-block-byte-identical () + (let ((content (concat "* Diagram\n\n" wot-test--block-content))) + (should (equal (wot-test--process-content content 120) content)))) + +(ert-deftest wot-process-file-reformats-table-but-not-block () + (let* ((content (concat "* Doc\n\n" wot-test--block-content "\n" + wot-test--wide-input)) + (result (wot-test--process-content content 40))) + (should (string-match-p (regexp-quote wot-test--block-content) result)) + (should (string-match-p (regexp-quote wot-test--wide-expected) result)))) + +(ert-deftest wot-process-file-skips-pipes-in-src-block () + (let ((content "* Pipeline\n\n#+begin_src sh\n| sort\n| uniq -c\n#+end_src\n")) + (should (equal (wot-test--process-content content 120) content)))) + +(ert-deftest wot-process-file-literal-inner-end-marker-stays-in-block () + "A literal #+end_src quoted inside an example block must not close it." + (let ((content (concat "* Doc\n\n#+begin_example\n#+begin_src sh\nx\n" + "#+end_src\n| art |----| art |\n#+end_example\n"))) + (should (equal (wot-test--process-content content 120) content)))) diff --git a/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py b/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py new file mode 100644 index 0000000..6a95ea4 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py @@ -0,0 +1,301 @@ +"""Tests for apkg-to-orgdrill.py — the inverse of flashcard-to-anki.py. + +The converter reads an Anki .apkg (a zip holding collection.anki2 / .anki21 +sqlite) and emits an org-drill .org in the house canonical shape. It is +stdlib-only (zipfile + sqlite3), so it imports directly — no genanki stub. + +The apkg schema these tests build by hand mirrors what genanki actually +writes, confirmed against a real apkg generated from flashcard-to-anki.py: + - col.decks : JSON {did: {"name": ...}}, always including id-1 "Default" + - col.models : JSON {mid: {"name": ..., "flds": [{"name": "Front"}, ...]}} + - notes.flds : fields joined by \x1f; tags space-padded (" tag ") + - cards : nid -> did (the Default deck carries no cards) + +The round-trip test closes the loop through flashcard-to-anki.py's own +parse(): original org -> forward parse tuples -> apkg fixture -> converter +-> recovered org -> forward parse -> assert the (front, back, tag) tuples +match. Only the apkg materialization is hand-built (the genanki boundary); +everything else is the real code on both sides. +""" +from __future__ import annotations + +import importlib.util +import json +import sqlite3 +import sys +import types +import zipfile +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +CONVERTER = SCRIPTS / "apkg-to-orgdrill.py" +FORWARD = SCRIPTS / "flashcard-to-anki.py" + + +def _load(path: Path, name: str, stub_genanki: bool = False): + if stub_genanki: + sys.modules.setdefault("genanki", types.ModuleType("genanki")) + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + # Register before exec: @dataclass resolves cls.__module__ via sys.modules + # (Python 3.14), which is None for an unregistered importlib module. + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def conv(): + return _load(CONVERTER, "apkg_to_orgdrill") + + +@pytest.fixture(scope="module") +def forward(): + return _load(FORWARD, "flashcard_to_anki", stub_genanki=True) + + +# --- fixture builder: write a genanki-shaped apkg by hand ------------------ + +def _make_apkg( + path: Path, + decks: dict[int, str], + models: dict[int, list[str]], + notes: list[tuple[int, int, list[str], str]], # (nid, mid, fields, tag) + cards: list[tuple[int, int]], # (nid, did) + *, + media: str = "{}", +) -> None: + """Materialize a minimal apkg matching genanki's collection.anki2 shape.""" + col_dir = path.parent / f"{path.stem}-build" + col_dir.mkdir(parents=True, exist_ok=True) + db = col_dir / "collection.anki2" + if db.exists(): + db.unlink() + con = sqlite3.connect(db) + con.execute("CREATE TABLE col (id INTEGER, decks TEXT, models TEXT)") + decks_json = {"1": {"name": "Default"}} + decks_json.update({str(did): {"name": name} for did, name in decks.items()}) + models_json = { + str(mid): {"name": f"{decks.get(list(decks)[0], 'M')} model", + "flds": [{"name": n, "ord": i} for i, n in enumerate(flds)]} + for mid, flds in models.items() + } + con.execute("INSERT INTO col (id, decks, models) VALUES (1, ?, ?)", + (json.dumps(decks_json), json.dumps(models_json))) + con.execute("CREATE TABLE notes (id INTEGER, mid INTEGER, flds TEXT, tags TEXT)") + for nid, mid, fields, tag in notes: + con.execute("INSERT INTO notes (id, mid, flds, tags) VALUES (?, ?, ?, ?)", + (nid, mid, "\x1f".join(fields), f" {tag} " if tag else " ")) + con.execute("CREATE TABLE cards (id INTEGER, nid INTEGER, did INTEGER)") + for i, (nid, did) in enumerate(cards): + con.execute("INSERT INTO cards (id, nid, did) VALUES (?, ?, ?)", (1000 + i, nid, did)) + con.commit() + con.close() + with zipfile.ZipFile(path, "w") as z: + z.write(db, "collection.anki2") + z.writestr("media", media) + + +# --- html_to_org_body ------------------------------------------------------ + +def test_html_to_org_splits_br_into_lines(conv): + assert conv.html_to_org_body("one<br>two<br>three") == ["one", "two", "three"] + + +def test_html_to_org_handles_br_variants(conv): + assert conv.html_to_org_body("a<br/>b<br />c<BR>d") == ["a", "b", "c", "d"] + + +def test_html_to_org_unescapes_entities_amp_last(conv): + # Inverts escape_html (which escapes & first): < > & -> < > &. + assert conv.html_to_org_body("x <tag> & y") == ["x <tag> & y"] + + +def test_html_to_org_preserves_a_literal_escaped_entity(conv): + # Forward-escaping the literal "<" yields "&lt;"; the inverse must + # recover "<", not "<". + assert conv.html_to_org_body("&lt;") == ["<"] + + +def test_html_to_org_strips_answer_hr(conv): + assert conv.html_to_org_body('front<hr id="answer">back') == ["front", "back"] + + +def test_html_to_org_empty_back_is_empty(conv): + assert conv.html_to_org_body("") == [] + + +# --- read_apkg ------------------------------------------------------------- + +def test_read_apkg_single_deck_recovers_front_back_tag_deck(conv, tmp_path): + apkg = tmp_path / "d.apkg" + _make_apkg( + apkg, + decks={20: "My Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["Q1?", "A1.<br>line2"], "sec-one")], + cards=[(100, 20)], + ) + recovered = conv.read_apkg(apkg) + assert len(recovered) == 1 + note = recovered[0] + assert note.deck == "My Deck" + assert note.front == "Q1?" + assert note.back_html == "A1.<br>line2" + assert note.tag == "sec-one" + + +def test_read_apkg_multiple_decks_grouped(conv, tmp_path): + apkg = tmp_path / "multi.apkg" + _make_apkg( + apkg, + decks={20: "Deck A", 21: "Deck B"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["QA?", "AA"], "ta"), (101, 9, ["QB?", "AB"], "tb")], + cards=[(100, 20), (101, 21)], + ) + decks = {n.deck for n in conv.read_apkg(apkg)} + assert decks == {"Deck A", "Deck B"} + + +def test_read_apkg_skips_default_deck_without_cards(conv, tmp_path): + apkg = tmp_path / "def.apkg" + _make_apkg( + apkg, + decks={20: "Real Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["Q?", "A"], "t")], + cards=[(100, 20)], + ) + assert {n.deck for n in conv.read_apkg(apkg)} == {"Real Deck"} + + +def test_read_apkg_warns_and_skips_non_basic_model(conv, tmp_path, capsys): + apkg = tmp_path / "cloze.apkg" + _make_apkg( + apkg, + decks={20: "Cloze Deck"}, + models={9: ["Text", "Extra"]}, # not Front/Back + notes=[(100, 9, ["some {{c1::text}}", "extra"], "t")], + cards=[(100, 20)], + ) + recovered = conv.read_apkg(apkg) + assert recovered == [] + assert "skip" in capsys.readouterr().err.lower() + + +def test_read_apkg_reads_anki21_collection_name(conv, tmp_path): + # A .anki21 collection filename must be read the same as .anki2. + apkg = tmp_path / "new.apkg" + _make_apkg( + apkg, + decks={20: "Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["Q?", "A"], "t")], + cards=[(100, 20)], + ) + # Rewrite the zip renaming the collection member to .anki21. + with zipfile.ZipFile(apkg) as z: + data = z.read("collection.anki2") + media = z.read("media") + with zipfile.ZipFile(apkg, "w") as z: + z.writestr("collection.anki21", data) + z.writestr("media", media) + assert conv.read_apkg(apkg)[0].front == "Q?" + + +def test_read_apkg_flags_media_reference(conv, tmp_path, capsys): + apkg = tmp_path / "media.apkg" + _make_apkg( + apkg, + decks={20: "Deck"}, + models={9: ["Front", "Back"]}, + notes=[(100, 9, ["Q?", 'see <img src="x.png">'], "t")], + cards=[(100, 20)], + ) + conv.read_apkg(apkg) + assert "media" in capsys.readouterr().err.lower() + + +# --- notes_to_org ---------------------------------------------------------- + +def test_notes_to_org_emits_canonical_shape(conv): + Note = conv.Note + notes = [ + Note(deck="My Deck", front="Q1?", back_html="A1.", tag="alpha"), + Note(deck="My Deck", front="Q2?", back_html="A2.", tag="alpha"), + ] + ids = iter(["id-1", "id-2"]) + org = conv.notes_to_org(notes, "My Deck", new_id=lambda: next(ids)) + assert "#+TITLE: My Deck" in org + assert "* alpha" in org + assert "** Q1? :drill:" in org + assert ":ID: id-1" in org + assert ":ID: id-2" in org + assert org.count("* alpha") == 1 # both cards share one section + + +def test_notes_to_org_distinct_tags_get_distinct_sections(conv): + Note = conv.Note + notes = [ + Note(deck="D", front="Qa?", back_html="a", tag="alpha"), + Note(deck="D", front="Qb?", back_html="b", tag="beta"), + ] + org = conv.notes_to_org(notes, "D", new_id=lambda: "x") + assert "* alpha" in org and "* beta" in org + + +# --- round-trip through the real forward parse() --------------------------- + +def test_round_trip_matches_forward_parse_tuples(conv, forward, tmp_path): + original = ( + "#+TITLE: RT Deck\n" + "\n" + "* First Section\n" + "** What is 2+2? :drill:\n" + ":PROPERTIES:\n:ID: aaaa\n:END:\n" + "Four.\n" + "Second line with <angle> & amp.\n" + "\n" + "* Second Section\n" + "** Capital of France? :drill:\n" + "Paris.\n" + ) + tuples = forward.parse(original) # [(front, back_html, anki_tags), ...] + assert len(tuples) == 2 + + apkg = tmp_path / "rt.apkg" + _make_apkg( + apkg, + decks={20: "RT Deck"}, + models={9: ["Front", "Back"]}, + # anki_tags is a list; the apkg tags field is space-joined. + notes=[(100 + i, 9, [f, b], " ".join(tags)) + for i, (f, b, tags) in enumerate(tuples)], + cards=[(100 + i, 20) for i in range(len(tuples))], + ) + + by_deck = conv.convert(apkg) + assert set(by_deck) == {"RT Deck"} + recovered_tuples = forward.parse(by_deck["RT Deck"]) + assert recovered_tuples == tuples + + +# --- errors ---------------------------------------------------------------- + +def test_read_apkg_missing_collection_errors(conv, tmp_path): + bad = tmp_path / "bad.apkg" + with zipfile.ZipFile(bad, "w") as z: + z.writestr("media", "{}") + with pytest.raises(Exception): + conv.read_apkg(bad) + + +def test_read_apkg_not_a_zip_errors(conv, tmp_path): + notzip = tmp_path / "plain.apkg" + notzip.write_text("not a zip") + with pytest.raises(Exception): + conv.read_apkg(notzip) diff --git a/claude-templates/.ai/scripts/tests/test_cj_remove_block.py b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py index 2c8dade..3cdee46 100644 --- a/claude-templates/.ai/scripts/tests/test_cj_remove_block.py +++ b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py @@ -14,6 +14,34 @@ import pytest SCRIPT = Path(__file__).parent.parent / "cj-remove-block.py" +@pytest.fixture(autouse=True) +def isolated_tmpdir(tmp_path, monkeypatch): + """Give every test in this module a private TMPDIR. + + The script backs up to the system temp dir under a name derived from the + edited file's BASENAME. The real todo.org shares that basename, so any test + operating on a fixture named todo.org writes something indistinguishable + from a production backup — and an earlier version of this file globbed the + shared /tmp and unlinked every match, so a routine `make test` destroyed + Craig's real backups (found in review, 2026-07-24). + + Isolating at module scope rather than per-test is deliberate: the same bug + was fixed once in the elisp sibling and left here, so relying on each new + test to remember is exactly how it recurred. Autouse makes it structural. + """ + d = tmp_path / "_tmpdir" + d.mkdir() + # TMPDIR covers subprocess invocations of the script. + monkeypatch.setenv("TMPDIR", str(d)) + # tempfile.gettempdir() caches its answer on first call, so a test that + # loads the module in-process would keep writing to the real /tmp no matter + # what TMPDIR says. Override the cache too — this is the gap that made the + # env-var-only version still leak one backup per suite run. + import tempfile as _tempfile + monkeypatch.setattr(_tempfile, "tempdir", str(d)) + return d + + @pytest.fixture def run_remove(tmp_path): """Write content to a temp org file, run cj-remove-block, return new contents.""" @@ -155,3 +183,142 @@ class TestCjRemoveBlockSafety: err, post_content = run_remove_expecting_failure(original, start=4, end=2) assert err.returncode != 0 assert post_content == original + + +class TestMultiBlockRangeRefused: + """The validation exists to catch a drifted range, but it only checked the + first and last lines of that range. A span from one block's opening fence to + a LATER block's closing fence passed, and the removal silently deleted every + line between — real prose, headings, whole tasks — with a zero exit. Drift is + the skill's normal operating mode (respond-to-cj-comments edits the file as it + processes, and a file under cj review usually holds several blocks), so this + is the exact scenario the check was written for. Reproduced 2026-07-24.""" + + TWO_BLOCKS = ( + "* Alpha\n" + "#+begin_src cj:\n" + "note A\n" + "#+end_src\n" + "KEEP THIS LINE\n" + "* Beta\n" + "#+begin_src cj:\n" + "note B\n" + "#+end_src\n" + ) + + def test_range_spanning_two_blocks_is_refused(self, run_remove_expecting_failure): + # Lines 2..9: block one's opener through block two's closer. + err, content = run_remove_expecting_failure(self.TWO_BLOCKS, 2, 9) + assert err.returncode == 1 + assert "KEEP THIS LINE" in content, "content between the blocks was destroyed" + assert "* Beta" in content, "a heading between the blocks was destroyed" + + def test_refusal_names_the_reason(self, run_remove_expecting_failure): + err, _ = run_remove_expecting_failure(self.TWO_BLOCKS, 2, 9) + assert "more than one" in err.stderr.decode().lower() + + def test_a_correct_single_block_range_still_removes(self, run_remove): + # The fix must not over-tighten: the legitimate range still works. + out = run_remove(self.TWO_BLOCKS, 2, 4) + assert "note A" not in out + assert "KEEP THIS LINE" in out + assert "note B" in out, "the second block must be untouched" + + def test_a_nested_end_src_inside_the_range_is_refused(self, run_remove_expecting_failure): + # Any #+end_src before the final line means the range covers >1 block. + content = ( + "#+begin_src cj:\n" + "a\n" + "#+end_src\n" + "middle\n" + "#+begin_src cj:\n" + "b\n" + "#+end_src\n" + ) + err, after = run_remove_expecting_failure(content, 1, 7) + assert err.returncode == 1 + assert "middle" in after + + +class TestSafeMutation: + """The script rewrites Craig's org files (todo.org, notes.org). It wrote with + a bare write_text, which truncates the target on open, and took no backup — + so a mid-write failure left the file truncated with no copy to recover from. + lint-org.el, the other tool that mutates these files, backs up to a temp dir + first. Match that, and make the write atomic. + + Every test here redirects TMPDIR to a private directory. The backup name + derives from the file's basename, and the real todo.org shares it, so a test + globbing the shared temp dir cannot tell its own artifact from a genuine + backup — and an earlier version of this class globbed /tmp and unlinked every + match, so a routine `make test` destroyed real backups (found in review, + 2026-07-24). Never glob or delete across the shared temp dir.""" + + ONE_BLOCK = "* T\n#+begin_src cj:\nnote\n#+end_src\nkeep\n" + + def test_a_backup_is_written_before_mutating(self, tmp_path): + import subprocess, glob, os + bdir = tmp_path / "bk" + bdir.mkdir() + f = tmp_path / "todo.org" + f.write_text(self.ONE_BLOCK) + subprocess.run( + ["python3", str(SCRIPT), "--file", str(f), "--start", "2", "--end", "4"], + check=True, capture_output=True, + env={**os.environ, "TMPDIR": str(bdir)}, + ) + backups = glob.glob(str(bdir / "todo.org.before-cj-remove.*")) + assert backups, "no backup was written before mutating the org file" + assert "note" in Path(max(backups)).read_text() + + def test_no_partial_file_when_the_write_fails(self, tmp_path, monkeypatch): + import importlib.util + spec = importlib.util.spec_from_file_location("crb", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + bdir = tmp_path / "bk" + bdir.mkdir() + monkeypatch.setenv("TMPDIR", str(bdir)) + f = tmp_path / "todo.org" + f.write_text(self.ONE_BLOCK) + def boom(*a, **k): + raise OSError("disk full") + monkeypatch.setattr(mod.os, "replace", boom) + with pytest.raises(OSError): + mod.remove_range(f, 2, 4) + # The original survives intact — no truncation, no partial. + assert f.read_text() == self.ONE_BLOCK + + +class TestBackupNeverOverwrites: + """Same defect class as todo-cleanup's, and more reachable here: the + respond-to-cj-comments skill removes several annotations in quick + succession, so a second-resolution stamp collides and the later backup + overwrote the earlier one with already-mutated content.""" + + TWO_BLOCKS = ( + "* A\n#+begin_src cj:\nfirst\n#+end_src\n" + "* B\n#+begin_src cj:\nsecond\n#+end_src\n" + ) + + def test_consecutive_removals_each_keep_a_backup(self, tmp_path, monkeypatch): + import subprocess, glob + bdir = tmp_path / "bk" + bdir.mkdir() + monkeypatch.setenv("TMPDIR", str(bdir)) + f = tmp_path / "todo.org" + f.write_text(self.TWO_BLOCKS) + original = f.read_text() + # Remove the second block, then the first — back to back, same second. + subprocess.run(["python3", str(SCRIPT), "--file", str(f), + "--start", "6", "--end", "8"], + check=True, capture_output=True, + env={**__import__("os").environ, "TMPDIR": str(bdir)}) + subprocess.run(["python3", str(SCRIPT), "--file", str(f), + "--start", "2", "--end", "4"], + check=True, capture_output=True, + env={**__import__("os").environ, "TMPDIR": str(bdir)}) + backups = glob.glob(str(bdir / "todo.org.before-cj-remove.*")) + assert len(backups) == 2, f"expected 2 backups, got {len(backups)}" + contents = [Path(b).read_text() for b in backups] + assert original in contents, "no backup holds the true original" diff --git a/claude-templates/.ai/scripts/tests/test_flashcard_stats.py b/claude-templates/.ai/scripts/tests/test_flashcard_stats.py index 606f7c1..46deccc 100644 --- a/claude-templates/.ai/scripts/tests/test_flashcard_stats.py +++ b/claude-templates/.ai/scripts/tests/test_flashcard_stats.py @@ -217,6 +217,31 @@ def test_parse_cards_captures_body_without_drawer_planning_or_answer_header(stat assert c["body"] == "the real answer" +def test_parse_cards_counts_a_multitag_heading_as_a_card(stats): + """A card multi-tagged :fundamental:drill: still counts; the front is clean.""" + text = "* Sec\n** Q multi? :fundamental:drill:\nthe answer\n" + cards, _ = stats.parse_cards(text.splitlines()) + assert len(cards) == 1 + assert cards[0]["heading"] == "Q multi?" + assert cards[0]["body"] == "the answer" + + +def test_parse_cards_ignores_a_tagged_heading_without_drill(stats): + """A tagged heading missing :drill: is not a drill card.""" + text = "* Sec\n** Just a note :note:\nbody\n" + cards, _ = stats.parse_cards(text.splitlines()) + assert cards == [] + + +def test_parse_cards_body_stops_at_next_multitag_card(stats): + """The body scan ends at the next L2 card even when it is multi-tagged.""" + text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n" + cards, _ = stats.parse_cards(text.splitlines()) + assert len(cards) == 2 + assert cards[0]["body"] == "body1" + assert cards[1]["body"] == "body2" + + def test_find_duplicate_fronts_matches_normalized_headings(stats): cards = [ {"heading": "What is LEO?"}, diff --git a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py index 87008a8..fa38b64 100644 --- a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py +++ b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py @@ -158,17 +158,18 @@ Geostationary Earth Orbit. def test_parse_returns_front_back_tag_per_card(drill): cards = drill.parse(SECTIONED) assert len(cards) == 2 - assert cards[0] == ("What is LEO?", "Low Earth Orbit.", "orbital-regimes") + # The section becomes the sole Anki tag (as a one-element list). + assert cards[0] == ("What is LEO?", "Low Earth Orbit.", ["orbital-regimes"]) assert cards[1][0] == "What is GEO?" def test_parse_card_without_a_section_gets_the_drill_tag(drill): - assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", "drill")] + assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", ["drill"])] def test_parse_strips_properties_drawer_from_back(drill): text = "** Q? :drill:\n:PROPERTIES:\n:ID: abc\n:END:\nThe answer.\n" - assert drill.parse(text) == [("Q?", "The answer.", "drill")] + assert drill.parse(text) == [("Q?", "The answer.", ["drill"])] def test_parse_trims_leading_and_trailing_blank_body_lines(drill): @@ -178,7 +179,59 @@ def test_parse_trims_leading_and_trailing_blank_body_lines(drill): def test_parse_card_with_only_a_drawer_has_empty_back(drill): text = "** Q? :drill:\n:PROPERTIES:\n:ID: x\n:END:\n" - assert drill.parse(text) == [("Q?", "", "drill")] + assert drill.parse(text) == [("Q?", "", ["drill"])] + + +# --- multi-tag headings, --tag-filter, --guid-salt ------------------------- + +MULTITAG = """* Fundamentals +** What is LEO? :fundamental:drill: +Low Earth Orbit. +** What is GEO? :drill: +Geostationary Earth Orbit. +""" + + +def test_parse_multitag_heading_is_a_card_when_drill_is_present(drill): + """A heading with a second org tag still parses when drill is among them.""" + cards = drill.parse(MULTITAG) + assert len(cards) == 2 + assert cards[0][0] == "What is LEO?" + + +def test_parse_multitag_tags_ride_along_next_to_the_section_tag(drill): + """Non-drill org tags become Anki tags alongside the section tag.""" + cards = drill.parse(MULTITAG) + assert cards[0][2] == ["fundamentals", "fundamental"] # section slug + org tag + assert cards[1][2] == ["fundamentals"] # drill-only -> section only + + +def test_parse_heading_without_drill_tag_is_not_a_card(drill): + """A tagged heading missing :drill: is not a card (e.g. :note:).""" + assert drill.parse("* S\n** Just a note :note:\nbody\n") == [] + + +def test_parse_tag_filter_returns_only_cards_with_that_org_tag(drill): + """--tag-filter narrows to cards carrying the given org tag.""" + cards = drill.parse(MULTITAG, tag_filter="fundamental") + assert len(cards) == 1 + assert cards[0][0] == "What is LEO?" + + +def test_parse_body_bounded_by_any_l1_or_l2_heading(drill): + """A card body stops at the next L1/L2 heading, multi-tagged or not.""" + text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n" + cards = drill.parse(text) + assert cards[0][1] == "body1" + assert cards[1][1] == "body2" + + +def test_card_guid_salt_changes_the_guid(drill, monkeypatch): + """--guid-salt gives a subset deck its own GUID space; no salt is unchanged.""" + monkeypatch.setattr(drill.genanki, "guid_for", lambda *a: ":".join(a), raising=False) + assert drill.card_guid("front", None) == "front" + assert drill.card_guid("front", "fundamentals") == "fundamentals:front" + assert drill.card_guid("front", None) != drill.card_guid("front", "fundamentals") def test_parse_joins_multiline_body_with_br(drill): diff --git a/claude-templates/.ai/scripts/tests/test_inbox_send.py b/claude-templates/.ai/scripts/tests/test_inbox_send.py index f75d7a1..9b0a8c6 100644 --- a/claude-templates/.ai/scripts/tests/test_inbox_send.py +++ b/claude-templates/.ai/scripts/tests/test_inbox_send.py @@ -476,3 +476,117 @@ class TestFilenameCollisions: assert len(files) == 2 bodies = "".join(f.read_text() for f in files) assert "message one" in bodies and "message two" in bodies + + +class TestAtomicWrite: + """A send wrote straight to the destination path in another project's + inbox/, and write_text truncates on open, so any mid-write failure left a + zero-byte .org there. inbox-status counts that phantom as a pending + handoff, blocking a turn in the receiving project over a file with no + content (2026-07-23). The write must be atomic: the inbox sees a complete + file or nothing.""" + + def test_send_text_writes_utf8(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 23, 4, 36, 0) + # An em dash and an accented char — both non-ASCII. + dest = mod.send_text(inbox, "accent café and dash — here", "src", None, now) + # Reading as utf-8 must round-trip; a locale-encoded write would raise + # under a C locale, and reading back proves the bytes are utf-8. + assert "—" in dest.read_text(encoding="utf-8") + + def test_send_text_no_partial_on_write_failure(self, tmp_path, monkeypatch): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 23, 4, 36, 0) + # Force the atomic finalize to fail after the temp file is written. + def boom(*a, **k): + raise OSError("disk full") + monkeypatch.setattr(mod.os, "replace", boom) + with pytest.raises(OSError): + mod.send_text(inbox, "a message that should never half-land", "src", None, now) + # No phantom, no leftover temp: the inbox is empty. + assert list(inbox.iterdir()) == [] + + def test_send_text_leaves_no_temp_on_success(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 23, 4, 36, 0) + dest = mod.send_text(inbox, "clean send", "src", None, now) + assert list(inbox.iterdir()) == [dest] + + def test_send_file_no_partial_on_write_failure(self, tmp_path, monkeypatch): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + src = tmp_path / "note.org" + src.write_text("body") + now = datetime(2026, 7, 23, 4, 36, 0) + def boom(*a, **k): + raise OSError("disk full") + monkeypatch.setattr(mod.os, "replace", boom) + with pytest.raises(OSError): + mod.send_file(inbox, src, "src", None, now) + assert list(inbox.iterdir()) == [] + + def test_send_file_leaves_no_temp_on_success(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + src = tmp_path / "note.org" + src.write_text("payload") + now = datetime(2026, 7, 23, 4, 36, 0) + dest = mod.send_file(inbox, src, "src", None, now) + assert list(inbox.iterdir()) == [dest] + assert dest.read_text() == "payload" + + +class TestSmallerDefects: + """Two low-severity defects found reading inbox-send during the 2026-07-23 + sweep: an unreadable source raised an uncaught traceback instead of the + clean error every other failure path produces, and a roots config naming + both a parent and one of its children listed the same project twice.""" + + def test_unreadable_source_gives_clean_error_not_traceback( + self, project_root, run_script, tmp_path + ): + project_root("sender") + project_root("receiver") + roots = [tmp_path / "projects"] + src = tmp_path / "secret.bin" + src.write_text("x") + src.chmod(0o000) + try: + result = run_script( + ["receiver", "--file", str(src)], + cwd=tmp_path / "projects" / "sender", + roots=roots, + expect_failure=True, + ) + finally: + src.chmod(0o644) + assert result.returncode == 1 + # The clean "inbox-send: <message>" shape, not a Python traceback. + assert result.stderr.startswith("inbox-send:") + assert "Traceback" not in result.stderr + + def test_discover_projects_dedupes_parent_and_child_root(self, tmp_path): + mod = _load_module() + # A project directory, reachable both as a child of its parent root and + # as a root in its own right. + parent = tmp_path / "projects" + proj = parent / "app" + (proj / ".ai").mkdir(parents=True) + (proj / "inbox").mkdir() + found = mod.discover_projects([parent, proj]) + resolved = [p.resolve() for p in found] + assert resolved.count(proj.resolve()) == 1 diff --git a/claude-templates/.ai/scripts/tests/test_route_recommend.py b/claude-templates/.ai/scripts/tests/test_route_recommend.py index acc4755..2ec900a 100644 --- a/claude-templates/.ai/scripts/tests/test_route_recommend.py +++ b/claude-templates/.ai/scripts/tests/test_route_recommend.py @@ -122,3 +122,31 @@ def test_cli_exclude_drops_current_project(tmp_path): r = _run(["--exclude", "foo"], roots=[tmp_path / "projects"], item="fix the foo widget") assert r.returncode == 0 assert r.stdout.strip() == "none" + + +# ---------------------------------------------------------------------- +# Duplicate candidate names +# +# Projects are collapsed to bare basenames, so two projects sharing a basename +# across roots (~/code/notes and ~/projects/notes) appear twice in the candidate +# list. Both literal-match, recommend read len(strong) > 1 as an ambiguous tie, +# and a correct strong match was downgraded to weak. Latent when discovered +# 2026-07-24 (27 projects, 27 distinct basenames) but real. +# ---------------------------------------------------------------------- + +def test_duplicate_candidate_name_keeps_strong_confidence(): + assert rr.recommend("fix the notes thing", ["notes", "other"]) == ("notes", "strong") + # The same name twice must not read as a tie. + assert rr.recommend("fix the notes thing", ["notes", "notes", "other"]) == ("notes", "strong") + + +def test_genuine_ambiguity_still_downgrades(): + # Two DIFFERENT projects both matching is a real tie and stays weak — the + # dedupe must collapse identical names only, never real ambiguity. + dest, conf = rr.recommend("notes and other both", ["notes", "other"]) + assert conf == "weak" + + +def test_duplicates_do_not_change_the_chosen_destination(): + dest, _ = rr.recommend("fix the notes thing", ["notes", "notes"]) + assert dest == "notes" diff --git a/claude-templates/.ai/scripts/tests/test_upcoming_birthdays.py b/claude-templates/.ai/scripts/tests/test_upcoming_birthdays.py new file mode 100644 index 0000000..1e15183 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_upcoming_birthdays.py @@ -0,0 +1,168 @@ +"""Tests for upcoming_birthdays.py — the daily-prep upcoming-birthdays block. + +Pure core: + parse_birthdays(text) -> [Birthday(name, month, day, year|None), ...] + upcoming(birthdays, today, window=30) -> [Upcoming(name, date, days_away, age|None), ...] + format_block(items, window, callout_days=7) -> str + +Birth year 1900 is the placeholder org-contacts uses when the real year is +unknown; those entries carry year=None and render date-only (no age). +""" + +import datetime as dt +import subprocess +import sys +from pathlib import Path + +SCRIPTS = Path(__file__).parent.parent +SCRIPT = SCRIPTS / "upcoming_birthdays.py" +sys.path.insert(0, str(SCRIPTS)) + +import upcoming_birthdays as ub # noqa: E402 + + +# --- parse_birthdays -------------------------------------------------------- + +def test_parse_reads_name_month_day_year(): + text = "** Jane Doe\n:PROPERTIES:\n:BIRTHDAY: 1970-08-05\n:END:\n" + bdays = ub.parse_birthdays(text) + assert bdays == [ub.Birthday("Jane Doe", 8, 5, 1970)] + + +def test_parse_placeholder_year_1900_becomes_none(): + text = "** John Smith\n:PROPERTIES:\n:BIRTHDAY: 1900-07-14\n:END:\n" + bdays = ub.parse_birthdays(text) + assert bdays == [ub.Birthday("John Smith", 7, 14, None)] + + +def test_parse_skips_contacts_without_birthday(): + text = ( + "** No Birthday\n:PROPERTIES:\n:PHONE: 555\n:END:\n" + "** Has Birthday\n:PROPERTIES:\n:BIRTHDAY: 1990-03-02\n:END:\n" + ) + bdays = ub.parse_birthdays(text) + assert [b.name for b in bdays] == ["Has Birthday"] + + +def test_parse_strips_heading_stars_and_tags(): + text = "*** Bob Jones :friend:\n:PROPERTIES:\n:BIRTHDAY: 1980-01-01\n:END:\n" + bdays = ub.parse_birthdays(text) + assert bdays[0].name == "Bob Jones" + + +def test_parse_ignores_malformed_birthday_lines(): + text = "** Bad Date\n:PROPERTIES:\n:BIRTHDAY: not-a-date\n:END:\n" + assert ub.parse_birthdays(text) == [] + + +# --- upcoming --------------------------------------------------------------- + +TODAY = dt.date(2026, 7, 18) + + +def test_upcoming_birthday_today_is_zero_days(): + bdays = [ub.Birthday("Today Person", 7, 18, 1990)] + got = ub.upcoming(bdays, TODAY) + assert got[0].days_away == 0 + assert got[0].date == dt.date(2026, 7, 18) + + +def test_upcoming_includes_within_window(): + bdays = [ub.Birthday("Soon", 7, 23, 1990)] + got = ub.upcoming(bdays, TODAY, window=30) + assert got[0].days_away == 5 + + +def test_upcoming_excludes_beyond_window(): + bdays = [ub.Birthday("Far", 9, 1, 1990)] # 45 days out + assert ub.upcoming(bdays, TODAY, window=30) == [] + + +def test_upcoming_boundary_day_30_included_day_31_excluded(): + on = [ub.Birthday("On", 8, 17, 1990)] # exactly 30 days + off = [ub.Birthday("Off", 8, 18, 1990)] # 31 days + assert ub.upcoming(on, TODAY, window=30)[0].days_away == 30 + assert ub.upcoming(off, TODAY, window=30) == [] + + +def test_upcoming_uses_next_year_when_this_years_passed(): + # today is 2026-07-18; a Jan 5 birthday recurs on 2027-01-05 + today = dt.date(2026, 12, 27) + bdays = [ub.Birthday("New Year", 1, 5, 1990)] + got = ub.upcoming(bdays, today, window=30) + assert got[0].date == dt.date(2027, 1, 5) + assert got[0].days_away == 9 + + +def test_upcoming_age_is_occurrence_year_minus_birth_year(): + bdays = [ub.Birthday("Ager", 7, 23, 1970)] + got = ub.upcoming(bdays, TODAY) + assert got[0].age == 56 # 2026 - 1970 + + +def test_upcoming_age_none_for_placeholder(): + bdays = [ub.Birthday("Placeholder", 7, 23, None)] + got = ub.upcoming(bdays, TODAY) + assert got[0].age is None + + +def test_upcoming_sorted_by_days_away(): + bdays = [ + ub.Birthday("Later", 8, 10, 1990), + ub.Birthday("Sooner", 7, 20, 1990), + ] + got = ub.upcoming(bdays, TODAY) + assert [u.name for u in got] == ["Sooner", "Later"] + + +def test_upcoming_leap_day_maps_to_feb_28_in_non_leap_year(): + today = dt.date(2027, 2, 1) # 2027 is not a leap year + bdays = [ub.Birthday("Leapling", 2, 29, 2000)] + got = ub.upcoming(bdays, today, window=30) + assert got[0].date == dt.date(2027, 2, 28) + + +# --- format_block ----------------------------------------------------------- + +def test_format_block_empty_reports_none(): + out = ub.format_block([], window=30) + assert "No birthdays" in out + + +def test_format_block_callout_marks_within_seven_days(): + items = [ub.Upcoming("Soon", dt.date(2026, 7, 22), 4, 40)] + out = ub.format_block(items, window=30, callout_days=7) + assert "⚠" in out + assert "Soon" in out + assert "40" in out # age shown + + +def test_format_block_beyond_callout_is_not_flagged(): + items = [ub.Upcoming("Later", dt.date(2026, 8, 10), 23, 30)] + out = ub.format_block(items, window=30, callout_days=7) + assert "⚠" not in out + + +def test_format_block_placeholder_shows_date_only_no_age(): + items = [ub.Upcoming("NoYear", dt.date(2026, 7, 25), 7, None)] + out = ub.format_block(items, window=30) + assert "NoYear" in out + assert "turns" not in out + + +# --- CLI -------------------------------------------------------------------- + +def test_cli_runs_against_a_fixture_file(tmp_path): + contacts = tmp_path / "contacts.org" + contacts.write_text( + "** Alice\n:PROPERTIES:\n:BIRTHDAY: 1990-07-20\n:END:\n" + "** Bob\n:PROPERTIES:\n:BIRTHDAY: 1900-12-01\n:END:\n" + ) + res = subprocess.run( + [sys.executable, str(SCRIPT), "--file", str(contacts), + "--today", "2026-07-18", "--window", "30"], + capture_output=True, text=True, + ) + assert res.returncode == 0 + assert "Alice" in res.stdout + assert "Bob" not in res.stdout # Dec 1 is outside the 30-day window diff --git a/claude-templates/.ai/scripts/todo-cleanup.el b/claude-templates/.ai/scripts/todo-cleanup.el index bd8166d..516e9b1 100644 --- a/claude-templates/.ai/scripts/todo-cleanup.el +++ b/claude-templates/.ai/scripts/todo-cleanup.el @@ -5,6 +5,8 @@ ;; emacs --batch -q -l todo-cleanup.el --check todo.org # hygiene report only ;; emacs --batch -q -l todo-cleanup.el --archive-done todo.org # archive completed subtrees ;; emacs --batch -q -l todo-cleanup.el --archive-done --check todo.org # preview the archive +;; emacs --batch -q -l todo-cleanup.el --seal todo.org # seal the working archive to resolved-YYYY-MM-DD.org +;; emacs --batch -q -l todo-cleanup.el --seal --check todo.org # preview the seal ;; emacs --batch -q -l todo-cleanup.el --convert-subtasks todo.org # dated-rewrite done level-3+ sub-tasks ;; emacs --batch -q -l todo-cleanup.el --convert-subtasks --check todo.org # preview the conversion ;; emacs --batch -q -l todo-cleanup.el --sync-child-priority todo.org # bump children whose priority drifted below the parent's @@ -37,23 +39,37 @@ ;; a message. Only direct level-2 children move — a DONE entry nested under ;; an open parent stays put. ;; -;; 2. Ages the "Resolved" section: a level-2 DONE/CANCELLED subtree whose -;; CLOSED date is older than `tc-archive-retain-days' (default 7) is moved +;; 2. Ages the "Resolved" section: a level-2 DONE/CANCELLED subtree is moved ;; out to `tc-archive-file' (default `archive/task-archive.org' beside the -;; todo file), keeping only the last week of closed tasks in the file -;; itself. Only subtrees closed within the window stay; older ones, and -;; those with no parseable CLOSED date, are moved out. Set -;; `tc-archive-retain-days' to nil to disable this step (legacy in-file-only -;; behavior). The aging date is `tc-archive-reference-date' when set -;; (tests), otherwise the real current date. The archive inherits the todo -;; file's gitignore status: when the todo file is gitignored, the archive -;; path is added to .gitignore before the first write, so private task -;; history never lands in a tracked path (see +;; todo file) when its CLOSED date is older than `tc-archive-retain-days' +;; (default 31 — one month) OR its CLOSED date can't be parsed. The last +;; month of closed tasks stays browsable in the file itself; older ones age +;; out. The unparseable-CLOSED case archives too, deliberately: a +;; keyword-complete task with no readable close date is cruft, not live +;; work. Set `tc-archive-retain-days' to nil to disable this step (legacy +;; in-file-only behavior). The aging date is `tc-archive-reference-date' +;; when set (tests), otherwise the real current date. The archive inherits +;; the todo file's gitignore status: when the todo file is gitignored, the +;; archive path is added to .gitignore before the first write, so private +;; task history never lands in a tracked path (see ;; `tc--ensure-archive-gitignored'). ;; ;; Archiving is consequential, so it's never run by default; it does *not* ;; also run the hygiene passes. ;; +;; * --seal (opt-in). Renames the working archive file (`tc-archive-file', +;; default `archive/task-archive.org') to `resolved-YYYY-MM-DD.org' beside it, +;; dated by the seal run, and leaves the next `--archive-done' to recreate a +;; fresh working file. The dated file means "everything sealed as of that +;; date" — not a calendar quarter — so a task closed late in a quarter and +;; archived after the boundary is never mislabeled; cadence (e.g. quarterly) +;; becomes independent of correctness and any slip is harmless. The sealed +;; file inherits the todo file's gitignore status the same way the working +;; archive does. A no-op (reported) when there's no working archive to seal; +;; refuses to clobber an existing `resolved-<today>.org'. Honors `--check'. +;; The seal date is `tc-archive-reference-date' when set (tests), otherwise the +;; real current date. +;; ;; * --convert-subtasks (opt-in). Rewrites every level-3-and-deeper heading whose ;; TODO state is DONE/CANCELLED/FAILED into a dated event-log entry ;; (`<stars> YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <text>'), dropping the keyword, @@ -84,6 +100,12 @@ ;; --check-child-priority is the report-only alias for --sync-child-priority ;; --check. +;; Before any modification a backup is copied to +;; /tmp/<basename>.before-todo-cleanup.<YYYYMMDD-HHMMSS> +;; matching lint-org.el and wrap-org-table.el. Skipped under --check, which +;; writes nothing. +;; + (require 'org) (require 'cl-lib) (require 'calendar) @@ -102,6 +124,17 @@ sub-task is terminal too and belongs in the parent's dated history.") (defconst tc--priority-cookie-regexp "\\[#\\([A-Z]\\)\\]" "Regexp matching an org priority cookie. Match group 1 is the letter.") +(defconst tc--planning-cookie-regexp + "\\(?:CLOSED\\|DEADLINE\\|SCHEDULED\\):[ \t]*[[<][^]>\n]*[]>]" + "One org planning cookie: a CLOSED/DEADLINE/SCHEDULED keyword followed by a +bracketed (inactive) or angled (active) timestamp.") + +(defconst tc--planning-line-regexp + (concat "\\`[ \t]*\\(?:" tc--planning-cookie-regexp "[ \t]*\\)+\\'") + "A whole org planning line: nothing but planning cookies and whitespace. +Anchored to a single line's contents so a line mixing a cookie with real body +text is never matched.") + (defconst tc-no-sync-tag "no-sync" "Org tag that opts a heading and all its descendants out of `--sync-child-priority'. Inherits down: a tag on an ancestor counts for @@ -112,20 +145,30 @@ every heading below it.") (defvar tc-bumped 0) (defvar tc-converted 0) (defvar tc-issues nil) +(defvar tc-sealed 0) (defvar tc-check-only nil) (defvar tc-archive-done nil) (defvar tc-sync-child-priority nil) (defvar tc-convert-subtasks nil) +(defvar tc-seal nil) (defvar tc-current-file nil) (defvar tc-current-dir nil) (defvar tc-archived-to-file 0) -(defvar tc-archive-retain-days 7 +(defconst tc-archive-retain-days-default 31 + "Default retention window (days) for the `--archive-done' file-aging step — +one month. A closed Resolved subtree stays in-file for this long before it ages +out to `tc-archive-file'; the last month of resolved work stays browsable in the +todo file itself. Named so the \"one month\" contract is explicit and testable.") + +(defvar tc-archive-retain-days tc-archive-retain-days-default "Retention window for the `--archive-done' file-aging step. A closed Resolved subtree whose CLOSED date is within this many days of the reference date stays in the in-file Resolved section; an older one is moved out to `tc-archive-file'. -A subtree with no parseable CLOSED date stays. nil disables the aging step -entirely, leaving the legacy in-file-only behavior.") +A subtree with no parseable CLOSED date is aged out too (a keyword-complete task +with no readable close date is cruft, not live work). nil disables the aging +step entirely, leaving the legacy in-file-only behavior. Defaults to +`tc-archive-retain-days-default' (one month).") (defvar tc-archive-reference-date nil "(YEAR MONTH DAY) treated as \"today\" when aging Resolved subtrees out to a @@ -479,6 +522,51 @@ step. Honors `tc-check-only' (report only)." tc-issues)))))))))) ;;; --------------------------------------------------------------------------- +;;; --seal mode: rename the working archive to a dated resolved-YYYY-MM-DD.org + +(defun tc--seal-date-string () + "YYYY-MM-DD for the seal — `tc-archive-reference-date' when set (tests), +otherwise the real current date." + (if tc-archive-reference-date + (pcase-let ((`(,y ,m ,d) tc-archive-reference-date)) + (format "%04d-%02d-%02d" y m d)) + (format-time-string "%Y-%m-%d"))) + +(defun tc-seal-archive-file () + "Rename the working archive file to `resolved-YYYY-MM-DD.org' beside it. +The next `--archive-done' run recreates a fresh working file. No-op (reported) +when there is no working archive to seal; refuses to clobber an existing +`resolved-<today>.org'. Ensures the sealed file inherits the todo file's +gitignore status. Honors `tc-check-only'." + (let ((path (tc--archive-file-path))) + (cond + ((or (null path) (not (file-readable-p path))) + (push (list :kind 'seal-nothing :file tc-current-file) tc-issues)) + (t + (let* ((dir (file-name-directory path)) + (sealed (expand-file-name + (format "resolved-%s.org" (tc--seal-date-string)) dir))) + (cond + ((file-exists-p sealed) + (push (list :kind 'seal-collision :file tc-current-file + :detail (file-name-nondirectory sealed)) + tc-issues)) + (tc-check-only + (cl-incf tc-sealed) + (push (list :kind 'seal-would :file tc-current-file + :detail (file-name-nondirectory sealed)) + tc-issues)) + (t + ;; Ignore the sealed name before the rename so its history stays as + ;; private as the working archive it derives from. + (tc--ensure-archive-gitignored sealed) + (rename-file path sealed) + (cl-incf tc-sealed) + (push (list :kind 'seal-done :file tc-current-file + :detail (file-name-nondirectory sealed)) + tc-issues)))))))) + +;;; --------------------------------------------------------------------------- ;;; --sync-child-priority mode (defun tc--heading-priority-letter () @@ -617,6 +705,14 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas ;; as written). Idempotent: an already-dated heading has no done keyword, so it ;; is skipped. A done sub-task with no parseable CLOSED cookie can't be dated, so ;; it is flagged and left alone rather than stamped with a fabricated date. +;; +;; The planning line goes entirely. A dated-log entry carries its date in the +;; heading, so CLOSED is redundant and an active DEADLINE/SCHEDULED is wrong: org +;; renders any headline with an active planning timestamp — keyword or not — so a +;; SCHEDULED left on a dated-log heading pins it to the agenda as weeks-overdue +;; long after the work is done. The conversion deletes the whole planning line, +;; not just the CLOSED cookie (todo-format.md; lint checker +;; `dated-log-heading-active-timestamp' backstops any that slip through). (defun tc--closed-parts-in-entry () "Return a plist (:year :month :day :dow :hour :minute) from the CLOSED cookie @@ -676,6 +772,27 @@ in-progress `org-map-entries' walk; markers track their headings across edits." nil 'file) (nreverse targets))) +(defun tc--strip-planning-lines-in-entry () + "Delete the canonical planning line(s) directly under the heading at point. +A planning line is one composed solely of CLOSED/DEADLINE/SCHEDULED cookies and +whitespace. Walks the lines immediately after the heading and stops at the first +non-planning line, so a planning-shaped line deeper in the body (e.g. in a code +block) is never touched. Returns the count of lines removed." + (save-excursion + (org-back-to-heading t) + (forward-line 1) + (let ((removed 0) (continue t)) + (while (and continue (not (eobp))) + (let ((line (buffer-substring-no-properties + (line-beginning-position) (line-end-position)))) + (if (string-match-p tc--planning-line-regexp line) + (progn + (delete-region (line-beginning-position) + (min (1+ (line-end-position)) (point-max))) + (cl-incf removed)) + (setq continue nil)))) + removed))) + (defun tc--convert-one-subtask (marker) "Convert the done sub-task heading at MARKER to a dated event-log entry. Under `tc-check-only' the conversion is reported but not performed." @@ -698,27 +815,13 @@ Under `tc-check-only' the conversion is reported but not performed." (push (list :kind 'convert-would :file tc-current-file :line line :heading title :new new) tc-issues) - ;; Replace the heading line, then drop the now-redundant CLOSED - ;; cookie from the entry (its date now lives in the header). Only - ;; the cookie goes: a planning line can also carry DEADLINE: or - ;; SCHEDULED: beside it, and those survive on their line. A line - ;; left blank by the removal is deleted whole. + ;; Replace the heading line, then drop the whole planning line. The + ;; date now lives in the header, so CLOSED is redundant and an active + ;; DEADLINE/SCHEDULED would wrongly pin this completed entry to the + ;; agenda (todo-format.md). Both go, not just the CLOSED cookie. (delete-region (line-beginning-position) (line-end-position)) (insert new) - (let ((end (save-excursion - (or (outline-next-heading) (goto-char (point-max))) - (point)))) - (save-excursion - (when (re-search-forward "CLOSED:[ \t]*\\[[^]]*\\][ \t]*" end t) - (replace-match "") - (let ((bol (line-beginning-position)) - (eol (line-end-position))) - (if (string-match-p "\\`[ \t]*\\'" - (buffer-substring bol eol)) - (delete-region bol (min (1+ eol) (point-max))) - (goto-char bol) - (when (looking-at "[ \t]+") - (replace-match ""))))))) + (tc--strip-planning-lines-in-entry) (push (list :kind 'convert-done :file tc-current-file :line line :heading title :new new) tc-issues))))))) @@ -735,9 +838,37 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors ;;; --------------------------------------------------------------------------- ;;; Driver + reporting +(defun tc--backup (file) + "Copy FILE to /tmp before any modification. Skipped in --check mode. + +Matches `lint-org.el' and `wrap-org-table.el', the other tools that rewrite +these org files. todo-cleanup runs the most often of the three (every wrap, +every sentry cycle), and Emacs's own backup does not fire under --batch -q, so +without this a mechanical rewrite has no undo short of git — which recovers +only to the last commit and loses intra-session work." + (let* ((base (format "%s%s.before-todo-cleanup.%s" + temporary-file-directory + (file-name-nondirectory file) + (format-time-string "%Y%m%d-%H%M%S"))) + (backup base) + (n 2)) + ;; Never overwrite an earlier backup. A second-resolution stamp collides + ;; when two invocations run back to back, which the shipped workflow does + ;; (open-tasks.org runs --convert-subtasks then --archive-done, each a + ;; sub-second batch run). Overwriting there replaces the true pre-session + ;; original with already-mutated content — losing exactly what the backup + ;; exists to preserve. Suffix instead, so every invocation keeps its own. + (while (file-exists-p backup) + (setq backup (format "%s-%d" base n)) + (setq n (1+ n))) + (copy-file file backup nil) + backup)) + (defun tc-process-file (file) (setq tc-current-file (file-name-nondirectory file)) (setq tc-current-dir (file-name-directory (expand-file-name file))) + (unless tc-check-only + (tc--backup file)) (with-current-buffer (find-file-noselect file) (org-mode) (cond @@ -747,6 +878,8 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors (tc-sync-child-priority-in-file)) (tc-convert-subtasks (tc-convert-subtasks-in-file)) + (tc-seal + (tc-seal-archive-file)) (t ;; Pass 1: auto-fix bogus state logs (or report under --check). (org-map-entries #'tc-fix-bogus-state-log-in-entry nil 'file) @@ -865,10 +998,26 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors (plist-get i :file) (plist-get i :line) (plist-get i :heading) (plist-get i :detail))))))))) +(defun tc--emit-seal-report () + (dolist (i (reverse tc-issues)) + (pcase (plist-get i :kind) + ('seal-done + (princ (format "todo-cleanup --seal: sealed task-archive.org → %s\n" + (plist-get i :detail)))) + ('seal-would + (princ (format "todo-cleanup --seal: would seal task-archive.org → %s — CHECK MODE (no writes)\n" + (plist-get i :detail)))) + ('seal-collision + (princ (format "todo-cleanup --seal: %s already exists — not sealing (already sealed today?)\n" + (plist-get i :detail)))) + ('seal-nothing + (princ "todo-cleanup --seal: no working archive to seal\n"))))) + (defun tc-emit-report () (cond (tc-archive-done (tc--emit-archive-report)) (tc-sync-child-priority (tc--emit-sync-report)) (tc-convert-subtasks (tc--emit-convert-report)) + (tc-seal (tc--emit-seal-report)) (t (tc--emit-hygiene-report)))) (defun tc-main () @@ -886,6 +1035,9 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors (when (member "--convert-subtasks" command-line-args-left) (setq tc-convert-subtasks t) (setq command-line-args-left (delete "--convert-subtasks" command-line-args-left))) + (when (member "--seal" command-line-args-left) + (setq tc-seal t) + (setq command-line-args-left (delete "--seal" command-line-args-left))) ;; --check-child-priority is the report-only alias for ;; `--sync-child-priority --check'. (when (member "--check-child-priority" command-line-args-left) @@ -893,7 +1045,7 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors (setq command-line-args-left (delete "--check-child-priority" command-line-args-left))) (if (null command-line-args-left) (progn - (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done | --convert-subtasks | --sync-child-priority | --check-child-priority] FILE...\n") + (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done | --seal | --convert-subtasks | --sync-child-priority | --check-child-priority] FILE...\n") (kill-emacs 1)) (let ((files command-line-args-left)) (setq command-line-args-left nil) @@ -912,6 +1064,7 @@ ert-run-tests-batch-and-exit'." (cl-every (lambda (a) (cond ((member a '("--check" "--archive-done" + "--seal" "--convert-subtasks" "--sync-child-priority" "--check-child-priority")) diff --git a/claude-templates/.ai/scripts/upcoming_birthdays.py b/claude-templates/.ai/scripts/upcoming_birthdays.py new file mode 100755 index 0000000..d3f30c0 --- /dev/null +++ b/claude-templates/.ai/scripts/upcoming_birthdays.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Upcoming-birthdays block for daily prep. + +Reads an org-contacts file for ``:BIRTHDAY: YYYY-MM-DD`` properties, finds the +ones whose next occurrence falls within a window (default 30 days) from today, +and prints a daily-prep block: name, date, days-away, and — when the birth year +is real — the age the person is turning. Many contacts use ``1900`` as a +placeholder year when the real one is unknown; those render date-only, no age. +Anything within the callout window (default 7 days) is flagged so a gift or +plan gets prompted. + +Pure core: + parse_birthdays(text) -> [Birthday(name, month, day, year|None), ...] + upcoming(birthdays, today, window=30) -> [Upcoming(name, date, days_away, age|None), ...] + format_block(items, window, callout_days=7) -> str + +CLI: + upcoming_birthdays.py [--file PATH] [--today YYYY-MM-DD] [--window N] [--callout N] +prints the block on stdout. Default --file is ~/sync/org/contacts.org. +""" + +import argparse +import datetime as dt +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +PLACEHOLDER_YEAR = 1900 +DEFAULT_CONTACTS = Path.home() / "sync" / "org" / "contacts.org" + +_HEADING_RE = re.compile(r"^\*+\s+(.*?)\s*$") +_TAGS_RE = re.compile(r"\s+:[A-Za-z0-9_@#%:]+:$") +_BIRTHDAY_RE = re.compile(r"^\s*:BIRTHDAY:\s*(\d{4})-(\d{2})-(\d{2})\s*$") + + +@dataclass(frozen=True) +class Birthday: + name: str + month: int + day: int + year: int | None # None when the source used the 1900 placeholder + + +@dataclass(frozen=True) +class Upcoming: + name: str + date: dt.date + days_away: int + age: int | None # None when the birth year is unknown + + +def _clean_name(heading_text: str) -> str: + """Strip a trailing org tag cluster from a heading's text.""" + return _TAGS_RE.sub("", heading_text).strip() + + +def parse_birthdays(text: str) -> list[Birthday]: + """Extract (name, month, day, year|None) for every contact with a BIRTHDAY. + + The name is the nearest preceding org heading. A birth year of 1900 is the + placeholder org-contacts uses for an unknown year and is returned as None. + Malformed birthday lines are ignored. + """ + birthdays: list[Birthday] = [] + current_name: str | None = None + for line in text.splitlines(): + heading = _HEADING_RE.match(line) + if heading: + current_name = _clean_name(heading.group(1)) + continue + bday = _BIRTHDAY_RE.match(line) + if bday and current_name: + year, month, day = (int(g) for g in bday.groups()) + # Guard against a nonsense month/day that regex width still admits. + try: + dt.date(2000, month, day) + except ValueError: + continue + birthdays.append( + Birthday( + current_name, + month, + day, + None if year == PLACEHOLDER_YEAR else year, + ) + ) + return birthdays + + +def _next_occurrence(month: int, day: int, today: dt.date) -> dt.date: + """First date on/after ``today`` landing on this month/day. + + A Feb 29 birthday maps to Feb 28 in a non-leap year. + """ + def on(year: int) -> dt.date: + try: + return dt.date(year, month, day) + except ValueError: + # Only Feb 29 can fail here; fall back to Feb 28. + return dt.date(year, 2, 28) + + candidate = on(today.year) + if candidate < today: + candidate = on(today.year + 1) + return candidate + + +def upcoming( + birthdays: list[Birthday], today: dt.date, window: int = 30 +) -> list[Upcoming]: + """Birthdays whose next occurrence is within ``window`` days, soonest first.""" + items: list[Upcoming] = [] + for b in birthdays: + occ = _next_occurrence(b.month, b.day, today) + days = (occ - today).days + if 0 <= days <= window: + age = None if b.year is None else occ.year - b.year + items.append(Upcoming(b.name, occ, days, age)) + items.sort(key=lambda u: (u.days_away, u.name)) + return items + + +def _days_phrase(days: int) -> str: + if days == 0: + return "today" + if days == 1: + return "tomorrow" + return f"in {days} days" + + +def format_block(items: list[Upcoming], window: int, callout_days: int = 7) -> str: + """Render the daily-prep block. Callout entries (within ``callout_days``) are + flagged with a marker and a plan-a-gift nudge.""" + if not items: + return f"No birthdays in the next {window} days." + + lines = [f"Upcoming birthdays (next {window} days):"] + for u in items: + callout = u.days_away <= callout_days + marker = "⚠" if callout else "·" + date_str = u.date.strftime("%a %b %d") + piece = f" {marker} {date_str} — {u.name}" + if u.age is not None: + piece += f" turns {u.age}" + piece += f" ({_days_phrase(u.days_away)})" + if callout: + piece += " — plan a gift/card" + lines.append(piece) + return "\n".join(lines) + + +def _parse_today(value: str | None) -> dt.date: + if value is None: + return dt.date.today() + return dt.date.fromisoformat(value) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Print the upcoming-birthdays daily-prep block.") + parser.add_argument("--file", type=Path, default=DEFAULT_CONTACTS, + help="org-contacts file (default: ~/sync/org/contacts.org)") + parser.add_argument("--today", default=None, + help="override today's date (YYYY-MM-DD), for testing") + parser.add_argument("--window", type=int, default=30, + help="look-ahead window in days (default: 30)") + parser.add_argument("--callout", type=int, default=7, + help="flag birthdays within this many days (default: 7)") + args = parser.parse_args(argv) + + if not args.file.exists(): + print(f"contacts file not found: {args.file}", file=sys.stderr) + return 1 + + text = args.file.read_text(encoding="utf-8") + items = upcoming(parse_birthdays(text), _parse_today(args.today), args.window) + print(format_block(items, args.window, args.callout)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/claude-templates/.ai/scripts/wrap-org-table.el b/claude-templates/.ai/scripts/wrap-org-table.el index ddbea65..173e44d 100644 --- a/claude-templates/.ai/scripts/wrap-org-table.el +++ b/claude-templates/.ai/scripts/wrap-org-table.el @@ -228,22 +228,40 @@ continuation lines merge back into their logical row before re-wrapping." ;;; file layer (defun wot-process-file (file &optional budget) - "Reformat every org table in FILE in place to BUDGET width." + "Reformat every org table in FILE in place to BUDGET width. +Pipe-led lines inside #+begin_/#+end_ blocks (example, src, quote, …) are +content, not tables — ASCII art in an example block once got mangled into a +bordered table — so block regions are skipped verbatim." (with-temp-buffer (insert-file-contents file) (goto-char (point-min)) - (while (re-search-forward "^[ \t]*|" nil t) - (let ((start (line-beginning-position))) - (while (and (not (eobp)) - (save-excursion (beginning-of-line) - (looking-at "[ \t]*|"))) + (let ((in-block nil)) ; the open block's type, e.g. "example" — nil outside + (while (not (eobp)) + (cond + ;; Only the matching #+end_<type> closes a block: an example block + ;; often quotes literal #+begin_src/#+end_src lines, and a boolean + ;; flag would let that inner literal end-marker re-expose the rest + ;; of the block to reformatting. + ((and (not in-block) + (looking-at "^[ \t]*#\\+begin_\\([^ \t\n]+\\)")) + (setq in-block (downcase (match-string 1))) (forward-line 1)) - (let* ((end (point)) - (table (buffer-substring-no-properties start end)) - (reformatted (wot-reformat-table-string table budget))) - (delete-region start end) - (goto-char start) - (insert reformatted)))) + ((and in-block + (looking-at-p (format "^[ \t]*#\\+end_%s\\([ \t]\\|$\\)" + (regexp-quote in-block)))) + (setq in-block nil) + (forward-line 1)) + ((and (not in-block) (looking-at-p "^[ \t]*|")) + (let ((start (point))) + (while (and (not (eobp)) (looking-at-p "^[ \t]*|")) + (forward-line 1)) + (let* ((end (point)) + (table (buffer-substring-no-properties start end)) + (reformatted (wot-reformat-table-string table budget))) + (delete-region start end) + (goto-char start) + (insert reformatted)))) + (t (forward-line 1))))) (write-region (point-min) (point-max) file))) ;;; --------------------------------------------------------------------------- @@ -289,7 +307,21 @@ so the ERT suite can `require' this file without firing the CLI dispatch." (t (file-readable-p a)))) command-line-args-left))) -(when (and noninteractive (wot--cli-invocation-p)) +(defun wot--entry-script-p () + "Non-nil when wrap-org-table.el itself was named on the command line. +lint-org.el `require's this file, and a load-triggered dispatch would run +the table reformatter over lint-org's file arguments — that's how a lint +invocation once reformatted the files it was only supposed to report on. +Only dispatch when a -l/--load argument names this very file." + (and load-file-name + (cl-loop for (flag arg) on command-line-args + thereis (and (member flag '("-l" "--load")) + (stringp arg) + (file-exists-p arg) + (string= (file-truename (expand-file-name arg)) + (file-truename load-file-name)))))) + +(when (and noninteractive (wot--entry-script-p) (wot--cli-invocation-p)) (wot-main)) (provide 'wrap-org-table) |
