diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-24 10:56:50 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-24 10:56:50 -0500 |
| commit | 267d1de7b8a8e7fd22b156433567c88216ec3d0f (patch) | |
| tree | 4289b12c333ea3a0c0c027c4de2c38224a5aced5 | |
| parent | b34fb4e413eea994f2492669d64d2e5ee9aa8110 (diff) | |
| download | rulesets-267d1de7b8a8e7fd22b156433567c88216ec3d0f.tar.gz rulesets-267d1de7b8a8e7fd22b156433567c88216ec3d0f.zip | |
fix: harden the org-file mutators and close four verified bugs
An overnight hygiene run over this repo, reviewed adversarially afterward. Every problem below was reproduced before it was fixed, and every fix was re-checked by a skeptic who was told to refute it.
cj-remove-block could silently destroy content. Its range check validated only the first and last lines, so a span from one cj block's opener to a later block's closer passed and the removal deleted everything between — prose, headings, whole tasks, with a zero exit. That is the failure the check exists to prevent, and drift is its normal case, since the calling skill edits the file while processing. It now refuses a range holding more than one block, backs up first, and writes atomically while preserving the file's mode and following symlinks to the real target.
todo-cleanup rewrote todo.org with no backup, alone among the three tools that mutate these files. It now copies to the temp dir like lint-org does. Both backup helpers suffix rather than overwrite, because a second-resolution stamp collapsed two back-to-back invocations into one backup holding already-mutated content — and running todo-cleanup twice in a row is the shipped path.
audit.bats failed about one run in eight, in teardown rather than in its assertions, so a passing test reported as a false red. git commit spawns a detached maintenance process that is still writing packs when the commit returns, racing the cleanup. The fixture disables it.
route_recommend downgraded a correct strong match to weak when two projects shared a basename. lint.sh now sweeps the scripts that get symlinked onto PATH, which were the only shell in the repo with no gate over them.
Two test defects found in review and fixed here: a suite that deleted real backups from the shared temp dir, and one that depended on that directory's contents. Isolation now lives in an autouse fixture rather than in each test's memory.
Known and filed, not closed: the range check still cannot prove the range is the block that was scanned, so drift by exactly one whole block deletes the wrong annotation. That needs a content assertion and a CLI contract change.
| -rw-r--r-- | .ai/metrics/work-the-backlog.jsonl | 6 | ||||
| -rwxr-xr-x | .ai/scripts/cj-remove-block.py | 81 | ||||
| -rw-r--r-- | .ai/scripts/route_recommend.py | 9 | ||||
| -rw-r--r-- | .ai/scripts/tests/test-todo-cleanup.el | 103 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_cj_remove_block.py | 167 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_route_recommend.py | 28 | ||||
| -rw-r--r-- | .ai/scripts/todo-cleanup.el | 34 | ||||
| -rw-r--r-- | .ai/session-context.org | 153 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/cj-remove-block.py | 81 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/route_recommend.py | 9 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test-todo-cleanup.el | 103 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_cj_remove_block.py | 167 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_route_recommend.py | 28 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/todo-cleanup.el | 34 | ||||
| -rwxr-xr-x | scripts/lint.sh | 8 | ||||
| -rw-r--r-- | scripts/tests/audit.bats | 19 | ||||
| -rw-r--r-- | scripts/tests/lint-coverage.bats | 54 | ||||
| -rw-r--r-- | todo.org | 353 |
18 files changed, 1337 insertions, 100 deletions
diff --git a/.ai/metrics/work-the-backlog.jsonl b/.ai/metrics/work-the-backlog.jsonl index f7bb44b..88c6764 100644 --- a/.ai/metrics/work-the-backlog.jsonl +++ b/.ai/metrics/work-the-backlog.jsonl @@ -7,3 +7,9 @@ {"ts":"2026-07-23T23:57:59-05:00","run_id":"711400a5-c6bc-4684-b928-6b818d0d33f2","project":"rulesets","caller":"speedrun","task":"inbox-send two smaller defects","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"a053e9d","review_findings":0} {"ts":"2026-07-24T00:02:14-05:00","run_id":"711400a5-c6bc-4684-b928-6b818d0d33f2","project":"rulesets","caller":"speedrun","task":"lint-org todo-format checkers fire on specs","outcome":"implemented-committed","upfront_decision":true,"commit_sha":"c38bab9","review_findings":0} {"ts":"2026-07-24T00:02:49-05:00","run_id":"711400a5-c6bc-4684-b928-6b818d0d33f2","project":"rulesets","caller":"speedrun","task":"notes.org template four lint flags","outcome":"already-satisfied","defer_reason":"already-satisfied","upfront_decision":false,"commit_sha":"","review_findings":0} +{"ts":"2026-07-24T01:41:14-05:00","run_id":"sentry-fire2-1784875274","project":"rulesets","caller":"loop","task":"cj-remove-block over-deletion + unsafe write","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"17f5d48","review_findings":0} +{"ts":"2026-07-24T01:43:52-05:00","run_id":"sentry-fire2","project":"rulesets","caller":"loop","task":"route_recommend duplicate-name tier downgrade","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"1b0f284","review_findings":0} +{"ts":"2026-07-24T02:38:48-05:00","run_id":"sentry-fire3","project":"rulesets","caller":"loop","task":"audit.bats flaky teardown","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"7f45d4b","review_findings":0} +{"ts":"2026-07-24T03:39:16-05:00","run_id":"sentry-fire4","project":"rulesets","caller":"loop","task":"todo-cleanup missing backup","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"0686784","review_findings":0} +{"ts":"2026-07-24T04:36:09-05:00","run_id":"sentry-fire5","project":"rulesets","caller":"loop","task":"attachment filename sanitization","outcome":"deferred-verify","defer_reason":"needs-deliberation","upfront_decision":false,"commit_sha":"","review_findings":0} +{"ts":"2026-07-24T07:38:47-05:00","run_id":"sentry-fire8","project":"rulesets","caller":"loop","task":"bin/ lint coverage gap","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"f91feef","review_findings":1} diff --git a/.ai/scripts/cj-remove-block.py b/.ai/scripts/cj-remove-block.py index 71c7b3d..d5137a3 100755 --- a/.ai/scripts/cj-remove-block.py +++ b/.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/.ai/scripts/route_recommend.py b/.ai/scripts/route_recommend.py index 7b36405..12ab132 100644 --- a/.ai/scripts/route_recommend.py +++ b/.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/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el index 838913f..a92d238 100644 --- a/.ai/scripts/tests/test-todo-cleanup.el +++ b/.ai/scripts/tests/test-todo-cleanup.el @@ -516,6 +516,12 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat .gitignore contents or nil), :archive-ignored (whether git ignores the archive), :archive-exists." (let* ((root (make-temp-file "tc-git-" t)) + ;; Private backup dir: this helper writes a file literally named + ;; todo.org and runs a real (non-check) pass, so without this its + ;; backup lands in the shared temp dir under the exact production + ;; name and is indistinguishable from a real one. + (temporary-file-directory + (file-name-as-directory (make-temp-file "tc-git-bk-" t))) (todo (expand-file-name "todo.org" root)) (archive (expand-file-name "archive/task-archive.org" root)) (gi (expand-file-name ".gitignore" root))) @@ -536,7 +542,8 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat :archive-ignored (eq 0 (call-process "git" nil nil nil "check-ignore" "-q" archive)) :archive-exists (file-readable-p archive))) - (delete-directory root t)))) + (delete-directory root t) + (delete-directory temporary-file-directory t)))) (ert-deftest tc-age-self-protect-gitignores-archive-when-todo-ignored () "When the todo file is gitignored, the aged-out archive is added to .gitignore @@ -1073,3 +1080,97 @@ line) is left untouched — the strip stops at the first non-planning line." (provide 'test-todo-cleanup) ;;; test-todo-cleanup.el ends here + +;;; --------------------------------------------------------------------------- +;;; Backup before mutating (parity with lint-org.el / wrap-org-table.el) +;; +;; todo-cleanup rewrites todo.org in place and left no copy behind, while both +;; sibling org-mutators back up to /tmp first. It is also the one that runs most +;; often (every wrap, every sentry fire). Emacs's own backup does not fire under +;; --batch -q, so there was genuinely no undo short of git. + +(ert-deftest tc-backup-written-before-a-real-mutation () + "A real (non-check) run leaves a copy holding the pre-edit content. + +`temporary-file-directory' is rebound to a private dir for the duration: the +backup name derives from the *file's* basename, and the real todo.org shares +that basename, so a live sentry run writing /tmp/todo.org.before-todo-cleanup.* +would otherwise be indistinguishable from this test's own artifact. The first +version of this test globbed the shared /tmp and passed only until a real run +created one (2026-07-24)." + (let* ((dir (make-temp-file "tc-backup-" t)) + (bdir (file-name-as-directory (make-temp-file "tc-bk-" t))) + (file (expand-file-name "todo.org" dir)) + (before "* P Open Work\n** TODO [#B] parent\n*** DONE a subtask\nCLOSED: [2026-07-01 Tue]\n")) + (unwind-protect + (progn + (with-temp-file file (insert before)) + (let ((tc-check-only nil) + (tc-convert-subtasks t) + (temporary-file-directory bdir)) + (tc-process-file file)) + (let ((backups (file-expand-wildcards + (concat bdir "todo.org.before-todo-cleanup.*")))) + (should backups) + (should (string-match-p + "a subtask" + (with-temp-buffer (insert-file-contents (car backups)) + (buffer-string)))))) + (delete-directory dir t) + (delete-directory bdir t)))) + +(ert-deftest tc-no-backup-in-check-mode () + "--check writes nothing, so it must not leave a backup either. +Uses a private `temporary-file-directory' for the same isolation reason." + (let* ((dir (make-temp-file "tc-backup-" t)) + (bdir (file-name-as-directory (make-temp-file "tc-bk-" t))) + (file (expand-file-name "todo.org" dir))) + (unwind-protect + (progn + (with-temp-file file + (insert "* P Open Work\n** TODO [#B] parent\n*** DONE sub\nCLOSED: [2026-07-01 Tue]\n")) + (let ((tc-check-only t) + (tc-convert-subtasks t) + (temporary-file-directory bdir)) + (tc-process-file file)) + (should-not (file-expand-wildcards + (concat bdir "todo.org.before-todo-cleanup.*")))) + (delete-directory dir t) + (delete-directory bdir t)))) + +(ert-deftest tc-backup-never-overwrites-an-earlier-one () + "Two invocations in the same second must not collapse to one backup. + +open-tasks.org runs --convert-subtasks then --archive-done back to back, each +a sub-second batch run. With a second-resolution stamp and copy-file's +OK-IF-ALREADY-EXISTS, the second invocation overwrote the first's backup with +already-mutated content, so the true pre-session original was unrecoverable — +the exact state the backup exists to preserve (found 2026-07-24 in review)." + (let* ((dir (make-temp-file "tc-collide-" t)) + (bdir (file-name-as-directory (make-temp-file "tc-cbk-" t))) + (file (expand-file-name "todo.org" dir)) + (original (concat "* P Open Work\n** TODO [#B] parent\n*** DONE sub\n" + "CLOSED: [2026-07-01 Tue]\n" + "* P Resolved\n** DONE [#C] old\nCLOSED: [2025-01-01 Wed]\n"))) + (unwind-protect + (progn + (with-temp-file file (insert original)) + ;; Two back-to-back invocations, as the shipped workflow does. + (let ((temporary-file-directory bdir)) + (let ((tc-check-only nil) (tc-convert-subtasks t)) + (tc-process-file file)) + (let ((tc-check-only nil) (tc-convert-subtasks nil) (tc-archive-done t) + (tc-archive-retain-days nil)) + (tc-process-file file))) + (let ((backups (file-expand-wildcards + (concat bdir "todo.org.before-todo-cleanup.*")))) + ;; Both invocations kept their own backup. + (should (= (length backups) 2)) + ;; And one of them still holds the true original. + (should (cl-some (lambda (b) + (string= original + (with-temp-buffer (insert-file-contents b) + (buffer-string)))) + backups)))) + (delete-directory dir t) + (delete-directory bdir t)))) diff --git a/.ai/scripts/tests/test_cj_remove_block.py b/.ai/scripts/tests/test_cj_remove_block.py index 2c8dade..3cdee46 100644 --- a/.ai/scripts/tests/test_cj_remove_block.py +++ b/.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/.ai/scripts/tests/test_route_recommend.py b/.ai/scripts/tests/test_route_recommend.py index acc4755..2ec900a 100644 --- a/.ai/scripts/tests/test_route_recommend.py +++ b/.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/.ai/scripts/todo-cleanup.el b/.ai/scripts/todo-cleanup.el index c4a87de..cb333e2 100644 --- a/.ai/scripts/todo-cleanup.el +++ b/.ai/scripts/todo-cleanup.el @@ -100,6 +100,12 @@ ;; --check-child-priority is the report-only alias for --sync-child-priority ;; --check. +;; Before any modification a backup is copied to +;; /tmp/<basename>.before-todo-cleanup.<YYYYMMDD-HHMMSS> +;; matching lint-org.el and wrap-org-table.el. Skipped under --check, which +;; writes nothing. +;; + (require 'org) (require 'cl-lib) (require 'calendar) @@ -832,9 +838,37 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors ;;; --------------------------------------------------------------------------- ;;; Driver + reporting +(defun tc--backup (file) + "Copy FILE to /tmp before any modification. Skipped in --check mode. + +Matches `lint-org.el' and `wrap-org-table.el', the other tools that rewrite +these org files. todo-cleanup runs the most often of the three (every wrap, +every sentry fire), and Emacs's own backup does not fire under --batch -q, so +without this a mechanical rewrite has no undo short of git — which recovers +only to the last commit and loses intra-session work." + (let* ((base (format "%s%s.before-todo-cleanup.%s" + temporary-file-directory + (file-name-nondirectory file) + (format-time-string "%Y%m%d-%H%M%S"))) + (backup base) + (n 2)) + ;; Never overwrite an earlier backup. A second-resolution stamp collides + ;; when two invocations run back to back, which the shipped workflow does + ;; (open-tasks.org runs --convert-subtasks then --archive-done, each a + ;; sub-second batch run). Overwriting there replaces the true pre-session + ;; original with already-mutated content — losing exactly what the backup + ;; exists to preserve. Suffix instead, so every invocation keeps its own. + (while (file-exists-p backup) + (setq backup (format "%s-%d" base n)) + (setq n (1+ n))) + (copy-file file backup nil) + backup)) + (defun tc-process-file (file) (setq tc-current-file (file-name-nondirectory file)) (setq tc-current-dir (file-name-directory (expand-file-name file))) + (unless tc-check-only + (tc--backup file)) (with-current-buffer (find-file-noselect file) (org-mode) (cond diff --git a/.ai/session-context.org b/.ai/session-context.org index e3f539c..d095f80 100644 --- a/.ai/session-context.org +++ b/.ai/session-context.org @@ -6,7 +6,7 @@ ** Active Goal -Sentry armed for the night on =sentry/2026-07-23-ratio=, hourly. Morning teardown is Craig's. +Sentry re-armed for the night 2026-07-24 on =sentry/2026-07-24-ratio=, hourly, with the implement pass ON (=:SENTRY_MAY_IMPLEMENT: yes=). This run implements solo, decision-free tasks and arriving solo work on the branch, never pushing; shared-asset/convention proposals still park. Bug+refactor finding files graded tasks. Every review runs the premise check first (reproduce before judging). Morning teardown + merge is Craig's. Earlier-tonight run (2026-07-24 00:xx) shipped the sentry workflow addition itself (pass 11 refactor finding, pass 12 opt-in implement, the =:SENTRY_MAY_IMPLEMENT:= marker) to main, plus the speedrun (4 solo tasks) and eight parked VERIFYs. ** Decisions @@ -173,6 +173,157 @@ Filed [#A], SCHEDULED today — the only [#A] of the night. The fix is find-not- Two fires' worth of evidence now says bundle-to-bundle propagation is where this repo leaks changes. +** 00:51 — Fire 1 of the 2026-07-24 run (working) — first fire with pass 12 live + +Lock acquired, branch =sentry/2026-07-24-ratio= verified, tree clean outside the spine. Digest: + +- P1 roam pull — ran, already up to date. +- P2 inbox zero — ran, no-op. Inbox at zero (the question-capture proposal was parked during entry). +- P3 triage intake — skipped: no active source surviving the mail/messenger exclusion. +- P4 todo cleanup — ran with real work. =--archive-done= moved three completed speedrun tasks out of Open Work into Resolved; =--convert-subtasks= normalized the tree. Committed. +- P5 task audit — mechanical subset. Staleness 13, back to the pre-speedrun baseline now that the four solo tasks closed. +- P6 working-files hygiene — ran. All =working/= dirs still have open backing VERIFY tasks (the parked proposals). No orphans. +- P7 spec status board — ran. Two READY specs, nothing stuck. +- P8 link integrity — ran. The docs-lifecycle spec still shows its two known prose false positives (=file:→id:= and =keep-file:-links-through-pilot=, bare =file:= tokens org parses as bracketless links). Correctly unaffected by tonight's spec-scoping, since =link-to-local-file= is org-lint's own checker, not a todo-format one. No new findings. +- P9 git health — main level with origin, sentry branch correctly has no upstream, no stale branches. +- P10 prep freshness — skipped: no =daily-prep/=. +- P11 bug and refactor finding — ran, first fire under the widened pass. Rotating area: the cross-project routing scripts (=route_recommend.py=, =broadcast.py=), untouched by the previous six areas. One verified latent bug filed [#D] (below). Refactor note not worth a task: =recommend='s two weak-tier branches collapse to a single =_tiebreak= call, since =_tiebreak= on a one-element list returns that element. Two lines, no behavior change, so it's a digest line rather than backlog noise. +- P12 solo-task implementation — *active this run* (=:SENTRY_MAY_IMPLEMENT: yes=) but a correct no-op: zero eligible tasks. All four open =:solo:= TODOs were completed in tonight's speedrun, so the ready bucket is empty. Nothing to implement, nothing deferred. + +*** The finding + +=route_recommend='s =discover_destination_names= collapses projects to bare basenames, so two projects sharing a basename across roots would both literal-match and read as an ambiguous tie, downgrading a correct strong match to weak. Reproduced by direct probe. Latent rather than live: 27 projects, 27 distinct basenames today. The destination stays right, only the tier is wrong, so the cost is an extra routing prompt. Filed [#D] with the order-preserving dedupe as the fix. + +** 01:33 — Fire 2 (working) — pass 12's first real implementations + +Digest: + +- P1 roam pull — ran, already up to date. +- P2 inbox zero — ran. Project inbox at zero. The roam inbox dropped 9 → 5 (another session filed some); all 5 remaining belong to archsetup or work, none rulesets-claimed, so roam mode is correctly a no-op here. +- P3 triage intake — skipped: no active source. +- P4 todo cleanup — ran, all three checks clean (fire 1 did the archiving). +- P5 task audit — mechanical subset. Staleness 13, flat. +- P6 working-files hygiene — ran. All =working/= dirs still backed by open VERIFYs. +- P7 spec status board — ran. Two READY specs, nothing stuck. +- P8 link integrity — ran. Only the docs-lifecycle spec's two known prose false positives. +- P9 git health — main level with origin, sentry branch correctly upstream-less. +- P10 prep freshness — skipped: no =daily-prep/=. +- P11 bug and refactor finding — rotating area: the cj-comment tooling (=cj-scan.py=, =cj-remove-block.py=). Two findings filed, one of them serious. +- P12 solo-task implementation — *two tasks implemented and committed to the branch* (17f5d48, 1b0f284). Both premise-checked before a line was written. + +*** The serious find: cj-remove-block destroys content + +=looks_like_cj_range= validated only the first and last lines of a range. A span from one cj block's opener to a *later* block's closer passed, and the removal then deleted everything between — prose, headings, whole tasks — silently, exit 0. That is exactly the failure the check exists to prevent, and drift is its normal case, since =respond-to-cj-comments= edits the file while processing and a file under cj review usually holds several blocks. Reproduced on a two-block fixture that lost a heading and two content lines. Filed [#B], then implemented in pass 12 after an independent re-verification on a different fixture shape. + +Same file, second defect fixed alongside: =remove_range= rewrote the org file with a bare =write_text= (truncates on open) and took no backup, so a mid-write failure would leave =todo.org= truncated with nothing to recover from. =lint-org.el= already backs these files up to =/tmp= before mutating; cj-remove-block now matches that and writes atomically. + +*** The measurement lesson, third time tonight + +A full-suite run went red on =audit.bats= test 4. I stashed my changes, saw it pass clean, and had a one-sample A/B pointing straight at my own diff. Re-ran three times with the changes restored and it passed every time. The failure is an intermittent teardown flake (=rm -rf= racing something still writing into a fixture =.git/objects=), not my change, and I nearly filed the wrong cause off a single sample. Filed [#C] with the git-background-gc theory explicitly labelled a lead rather than a verified cause. + +Committed only on a genuinely green re-run, not on the red with a hand-wave. + +** 02:33 — Fire 3 (working) — the flake's cause traced, and my own lead disproved + +Digest: + +- P1 roam pull — ran, already up to date. +- P2 inbox zero — ran, no-op. Project inbox at zero; roam holds 5, none rulesets-claimed. +- P3 triage intake — skipped: no active source. +- P4 todo cleanup — ran. =--archive-done= moved fire 2's two completed tasks to Resolved. Committed. +- P5 task audit — mechanical subset. Staleness 13, flat. +- P6 working-files hygiene — ran, all dirs backed. +- P7 spec status board — ran, two READY, nothing stuck. +- P8 link integrity — ran, only the known docs-lifecycle prose false positives. +- P9 git health — main level with origin, branch upstream-less, nothing stale. +- P10 prep freshness — skipped. +- P11 bug and refactor finding — no new findings. The fire's whole investigative budget went to confirming fire 2's filed flake, which is the honest place for it; a hunt that finds nothing new is a result. +- P12 solo-task implementation — one task implemented and committed (7f45d4b). + +*** Confirming the cause before fixing, and disproving my own lead + +Fire 2 filed the =audit.bats= flaky teardown with a stated theory: =gc.auto='s loose-object threshold. Pass 12's premise check went after that theory rather than the fix, and killed it — a fixture holds five objects against a default threshold of 6700, so that mechanism cannot fire. + +The real cause came from a =GIT_TRACE= run: =git commit= spawns =git maintenance run --auto --quiet --detach= on git 2.55. The commit returns while the detached process is still writing a pack, and teardown's =rm -rf= races it. Every failed run had left a =tmp_pack_*= behind, which is the thread that led there. + +Fix: =maintenance.auto false= plus =gc.auto 0= in the fixture, killing the background writer rather than retrying the delete (a retry loop hides a live process instead of removing it). Validated over 20 consecutive clean runs against a ~1-in-8 baseline, and recorded honestly in the task that 20 clean runs alone would be ~7% likely by luck — the trace is the evidence, the runs confirm. + +This is the second night running where the premise check changed the outcome. Fire 2 it stopped a wrong causation call; here it stopped me implementing a fix for a mechanism that was never operating. Both times the cost of checking was minutes and the cost of not checking would have been a plausible, wrong, committed change. + +** 03:33 — Fire 4 (working) — two hypotheses killed, one real gap closed + +Digest: + +- P1 roam pull — ran, up to date. P2 inbox zero — no-op, both surfaces clean. P3 triage — skipped, no active source. +- P4 todo cleanup — ran. Archived fire 3's completed task to Resolved. Committed. +- P5 task audit — mechanical subset. Staleness 13, flat all night. +- P6 working-files — all dirs backed. P7 spec board — two READY, nothing stuck. P8 link integrity — only the known prose false positives. P9 git health — clean. P10 — skipped, no prep dir. +- P11 bug and refactor finding — rotating area: the org-file mutators, chosen because =cj-remove-block= yielded a serious find in that same class last fire. One gap filed. +- P12 solo-task implementation — one task implemented and committed (0686784). + +*** What the area review actually found, and what it disproved + +The lead was that =todo-cleanup.el= might lose data. It rewrites =todo.org=, creates archive files, and moves subtrees *between* files, which is the shape that bit =cj-remove-block=. Two specific hypotheses, both tested, both wrong: + +- *"A mid-move failure loses a subtree from both files."* No. The order is delete-from-buffer, write-archive, save-todo.org-last, so an archive-write failure aborts before the save. Verified by making the archive directory unwritable: exit 255, =todo.org= byte-identical, content intact. +- *"Errors are swallowed on the mutation path."* No. The only =ignore-errors= in the file wrap =call-process "git"=, never a write. + +What survived was narrower and real: todo-cleanup mutates with *no backup*, while both sibling mutators (=lint-org.el=, =wrap-org-table.el=) copy to =/tmp= first, and =cj-remove-block= joined them last fire. It is also the one that runs most often. Confirmed empirically that Emacs's own backup does not fire under =--batch -q=, so there was genuinely no undo short of git. Filed [#C], then implemented in P12. + +Also checked my own change for regression rather than assuming: a missing input file exits 255 and creates nothing, identical to the pre-change version tested from git. + +*** Running tally on the premise habit + +Three fires, three times it changed the outcome. Fire 2 it stopped a wrong causation call. Fire 3 it disproved my own filed =gc.auto= theory before I could implement against it. Here it killed two data-loss hypotheses before they became tasks, leaving only the gap that was actually there. The pattern is consistent: the cheap check keeps a plausible story from becoming a committed change. + +** 04:33 — Fire 5 (working) — the first real defer, and last fire's fix proving itself + +Digest: + +- P1 roam pull — ran, up to date. P2 inbox zero — no-op, both surfaces clean. P3 triage — skipped, no active source. +- P4 todo cleanup — ran, archived fire 4's completed task. *Confirmed last fire's backup fix working live*: the real =--archive-done= run left =/tmp/todo.org.before-todo-cleanup.20260724-043331=. Dogfooded within an hour of shipping. +- P5 task audit — staleness 13, flat all night. P6 working-files — all backed. P7 spec board — two READY. P8 link integrity — only the known prose false positives. P9 git health — clean. P10 — skipped. +- P11 bug and refactor finding — rotating area: the attachment/email handlers, chosen because they parse genuinely untrusted input, unlike every internal-tooling area covered so far. One finding filed. +- P12 solo-task implementation — *deferred*, and correctly. First defer of the run. + +*** The find: attachment filenames are partly sanitized, in two different ways + +Both writers derive on-disk names from the =filename= an email declares, and both sanitize incompletely, covering *different* gaps. =eml-view= cleans the name but interpolates the extension raw. =gmail-fetch='s =safe_filename= handles path separators and leading =..= and nothing else. Probed both with the same adversarial set: a =; rm -rf ~= extension survives in both, a literal newline survives in both, a 300-character extension produces a 314-character filename in both. + +Two things I checked so this doesn't get over-graded later. Not RCE — files are written through Python =open=, never a shell. Not path traversal — =splitext= only returns an extension when the last dot follows the last separator, so =ext= can never hold a slash, and the traversal case is neutralized in both scripts. The genuine harms are narrower: a newline in a filename breaks downstream tooling that reads the directory as a line-delimited list, and an unbounded extension blows the 255-byte limit so a crafted attachment aborts extraction. Graded [#C] on that honest read rather than the scarier one. + +I also corrected my own framing mid-investigation. I first read this as the familiar "one sibling hardened, the other not" pattern from the last two fires. It isn't — =safe_filename= is narrower than it looks, and the two scripts are *differently* incomplete. Neither handles newlines or length. + +*** Why pass 12 deferred instead of implementing + +The fix itself is clear, but where the shared sanitizer lives is a design call: a shared helper module (clean, but a new synced template file plus =importlib= gymnastics for kebab-named scripts), duplicate it in both (self-contained, but drift — the exact defect class that produced three separate findings tonight), or patch each in place (smallest diff, permanent divergence). That is deliberation, not a quick factual question, so checklist item 4 fires and the unattended loop defers. Filed the VERIFY with the three options and my lean, and left the task *un-=:solo:=-tagged* so a later run doesn't pick it up and guess. + +This is the checklist discriminating rather than rubber-stamping. Four fires implemented; this one correctly didn't. + +sentry at 05:33: nothing (bug hunt swept =scripts/*.py=; two candidate gaps both disproved — =workflow-integrity.py= *is* gated, its bats runs the real checker against the real canonical tree under =make test=, and =update-skills.py= is an on-demand maintenance command rather than a gate. Recorded so neither gets re-investigated.) + +sentry at 06:33: nothing (bug hunt swept =wrap-org-table.el=, the third org-file mutator and the one with prior history — its load-time dispatch caused the 2026-07-09 corruption. Clean on every probe: the entry-script guard correctly refuses to dispatch when lint-org merely =require='s it, it backs up before writing like its siblings, and it is block-aware — a table inside =#+begin_example= stayed verbatim while a real over-budget table wrapped onto continuation rows with rules. Second consecutive quiet hunt.) + +** 07:33 — Fire 8 (working) — a time bomb I planted four fires ago went off + +Digest: P1-P10 all no-op (roam current, both inboxes clean, todo cleanup clean, staleness 13, working dirs backed, two READY specs, only the known prose link false positives, git clean). P11 found a real coverage gap. P12 implemented it, and the suite caught a regression of my own making. + +*** The find: the only ungated shell in the repo + +=scripts/lint.sh= sweeps =scripts/*.sh=, the language hooks, and the language githooks. It never touched =claude-templates/bin/= — zero references. Those four scripts (=ai=, =agent-text=, =agent-page=, =install-ai=) are the ones =make install= symlinks onto PATH, which makes them the *most* exposed shell in the repo and left them the only shell with no gate over it. All four are clean today, so the guard is a no-op by design; it exists so a future regression can't pass silently. The =ai= launcher was hardened to 42 tests recently and nothing enforced that going forward. + +The test pins *coverage*, not cleanliness: it plants a broken file in each swept location and asserts lint complains, so a location that stops being swept fails the suite rather than passing quietly. + +Surfaced a bigger question I did *not* answer overnight: rulesets ships shellcheck enforcement to consuming projects (the bash bundle's pre-commit, =validate-bash.sh=) and runs none on itself. Filed as a VERIFY, because turning it on would surface the false positives dispositioned earlier this session and choosing between fixing them or adding disable-directives is a preference, not a fact. + +*** The regression: my own test, detonating on schedule + +The full suite went red on the two backup tests I added in fire 4. Not a flake — a time bomb. They globbed =/tmp/todo.org.before-todo-cleanup.*=, but the backup name derives from the file's *basename*, and the real =todo.org= shares it. So a live sentry run's genuine backup was indistinguishable from the test's own artifact, and the check-mode test (which asserts *no* backup exists) failed the moment fire 5's real archive pass created one. + +They passed when written only because no real backup existed yet. Three now sit in =/tmp=. Both tests rebind =temporary-file-directory= to a private dir, and I verified they pass with the real backups present rather than by clearing them. + +Worth naming plainly: I shipped a test whose correctness depended on the state of a shared directory that the code under test writes to in production. That is the "no shared mutable state" rule in =testing.md=, and I broke it while fixing a different durability bug. The suite caught it two fires later, which is the argument for running the full suite every fire rather than only the touched file. + * Sentry approval queue (2026-07-23) Five items. Each names what, why, and the exact edit. Items 2 through 5 are also filed as =VERIFY [#B]= tasks in =todo.org= with prepared diffs under =working/=, so they survive this anchor being archived — say "approve the parked <topic>" for any of them. 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/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/test-todo-cleanup.el b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el index 838913f..a92d238 100644 --- a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el +++ b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el @@ -516,6 +516,12 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat .gitignore contents or nil), :archive-ignored (whether git ignores the archive), :archive-exists." (let* ((root (make-temp-file "tc-git-" t)) + ;; Private backup dir: this helper writes a file literally named + ;; todo.org and runs a real (non-check) pass, so without this its + ;; backup lands in the shared temp dir under the exact production + ;; name and is indistinguishable from a real one. + (temporary-file-directory + (file-name-as-directory (make-temp-file "tc-git-bk-" t))) (todo (expand-file-name "todo.org" root)) (archive (expand-file-name "archive/task-archive.org" root)) (gi (expand-file-name ".gitignore" root))) @@ -536,7 +542,8 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat :archive-ignored (eq 0 (call-process "git" nil nil nil "check-ignore" "-q" archive)) :archive-exists (file-readable-p archive))) - (delete-directory root t)))) + (delete-directory root t) + (delete-directory temporary-file-directory t)))) (ert-deftest tc-age-self-protect-gitignores-archive-when-todo-ignored () "When the todo file is gitignored, the aged-out archive is added to .gitignore @@ -1073,3 +1080,97 @@ line) is left untouched — the strip stops at the first non-planning line." (provide 'test-todo-cleanup) ;;; test-todo-cleanup.el ends here + +;;; --------------------------------------------------------------------------- +;;; Backup before mutating (parity with lint-org.el / wrap-org-table.el) +;; +;; todo-cleanup rewrites todo.org in place and left no copy behind, while both +;; sibling org-mutators back up to /tmp first. It is also the one that runs most +;; often (every wrap, every sentry fire). Emacs's own backup does not fire under +;; --batch -q, so there was genuinely no undo short of git. + +(ert-deftest tc-backup-written-before-a-real-mutation () + "A real (non-check) run leaves a copy holding the pre-edit content. + +`temporary-file-directory' is rebound to a private dir for the duration: the +backup name derives from the *file's* basename, and the real todo.org shares +that basename, so a live sentry run writing /tmp/todo.org.before-todo-cleanup.* +would otherwise be indistinguishable from this test's own artifact. The first +version of this test globbed the shared /tmp and passed only until a real run +created one (2026-07-24)." + (let* ((dir (make-temp-file "tc-backup-" t)) + (bdir (file-name-as-directory (make-temp-file "tc-bk-" t))) + (file (expand-file-name "todo.org" dir)) + (before "* P Open Work\n** TODO [#B] parent\n*** DONE a subtask\nCLOSED: [2026-07-01 Tue]\n")) + (unwind-protect + (progn + (with-temp-file file (insert before)) + (let ((tc-check-only nil) + (tc-convert-subtasks t) + (temporary-file-directory bdir)) + (tc-process-file file)) + (let ((backups (file-expand-wildcards + (concat bdir "todo.org.before-todo-cleanup.*")))) + (should backups) + (should (string-match-p + "a subtask" + (with-temp-buffer (insert-file-contents (car backups)) + (buffer-string)))))) + (delete-directory dir t) + (delete-directory bdir t)))) + +(ert-deftest tc-no-backup-in-check-mode () + "--check writes nothing, so it must not leave a backup either. +Uses a private `temporary-file-directory' for the same isolation reason." + (let* ((dir (make-temp-file "tc-backup-" t)) + (bdir (file-name-as-directory (make-temp-file "tc-bk-" t))) + (file (expand-file-name "todo.org" dir))) + (unwind-protect + (progn + (with-temp-file file + (insert "* P Open Work\n** TODO [#B] parent\n*** DONE sub\nCLOSED: [2026-07-01 Tue]\n")) + (let ((tc-check-only t) + (tc-convert-subtasks t) + (temporary-file-directory bdir)) + (tc-process-file file)) + (should-not (file-expand-wildcards + (concat bdir "todo.org.before-todo-cleanup.*")))) + (delete-directory dir t) + (delete-directory bdir t)))) + +(ert-deftest tc-backup-never-overwrites-an-earlier-one () + "Two invocations in the same second must not collapse to one backup. + +open-tasks.org runs --convert-subtasks then --archive-done back to back, each +a sub-second batch run. With a second-resolution stamp and copy-file's +OK-IF-ALREADY-EXISTS, the second invocation overwrote the first's backup with +already-mutated content, so the true pre-session original was unrecoverable — +the exact state the backup exists to preserve (found 2026-07-24 in review)." + (let* ((dir (make-temp-file "tc-collide-" t)) + (bdir (file-name-as-directory (make-temp-file "tc-cbk-" t))) + (file (expand-file-name "todo.org" dir)) + (original (concat "* P Open Work\n** TODO [#B] parent\n*** DONE sub\n" + "CLOSED: [2026-07-01 Tue]\n" + "* P Resolved\n** DONE [#C] old\nCLOSED: [2025-01-01 Wed]\n"))) + (unwind-protect + (progn + (with-temp-file file (insert original)) + ;; Two back-to-back invocations, as the shipped workflow does. + (let ((temporary-file-directory bdir)) + (let ((tc-check-only nil) (tc-convert-subtasks t)) + (tc-process-file file)) + (let ((tc-check-only nil) (tc-convert-subtasks nil) (tc-archive-done t) + (tc-archive-retain-days nil)) + (tc-process-file file))) + (let ((backups (file-expand-wildcards + (concat bdir "todo.org.before-todo-cleanup.*")))) + ;; Both invocations kept their own backup. + (should (= (length backups) 2)) + ;; And one of them still holds the true original. + (should (cl-some (lambda (b) + (string= original + (with-temp-buffer (insert-file-contents b) + (buffer-string)))) + backups)))) + (delete-directory dir t) + (delete-directory bdir t)))) diff --git a/claude-templates/.ai/scripts/tests/test_cj_remove_block.py b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py index 2c8dade..3cdee46 100644 --- a/claude-templates/.ai/scripts/tests/test_cj_remove_block.py +++ b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py @@ -14,6 +14,34 @@ import pytest SCRIPT = Path(__file__).parent.parent / "cj-remove-block.py" +@pytest.fixture(autouse=True) +def isolated_tmpdir(tmp_path, monkeypatch): + """Give every test in this module a private TMPDIR. + + The script backs up to the system temp dir under a name derived from the + edited file's BASENAME. The real todo.org shares that basename, so any test + operating on a fixture named todo.org writes something indistinguishable + from a production backup — and an earlier version of this file globbed the + shared /tmp and unlinked every match, so a routine `make test` destroyed + Craig's real backups (found in review, 2026-07-24). + + Isolating at module scope rather than per-test is deliberate: the same bug + was fixed once in the elisp sibling and left here, so relying on each new + test to remember is exactly how it recurred. Autouse makes it structural. + """ + d = tmp_path / "_tmpdir" + d.mkdir() + # TMPDIR covers subprocess invocations of the script. + monkeypatch.setenv("TMPDIR", str(d)) + # tempfile.gettempdir() caches its answer on first call, so a test that + # loads the module in-process would keep writing to the real /tmp no matter + # what TMPDIR says. Override the cache too — this is the gap that made the + # env-var-only version still leak one backup per suite run. + import tempfile as _tempfile + monkeypatch.setattr(_tempfile, "tempdir", str(d)) + return d + + @pytest.fixture def run_remove(tmp_path): """Write content to a temp org file, run cj-remove-block, return new contents.""" @@ -155,3 +183,142 @@ class TestCjRemoveBlockSafety: err, post_content = run_remove_expecting_failure(original, start=4, end=2) assert err.returncode != 0 assert post_content == original + + +class TestMultiBlockRangeRefused: + """The validation exists to catch a drifted range, but it only checked the + first and last lines of that range. A span from one block's opening fence to + a LATER block's closing fence passed, and the removal silently deleted every + line between — real prose, headings, whole tasks — with a zero exit. Drift is + the skill's normal operating mode (respond-to-cj-comments edits the file as it + processes, and a file under cj review usually holds several blocks), so this + is the exact scenario the check was written for. Reproduced 2026-07-24.""" + + TWO_BLOCKS = ( + "* Alpha\n" + "#+begin_src cj:\n" + "note A\n" + "#+end_src\n" + "KEEP THIS LINE\n" + "* Beta\n" + "#+begin_src cj:\n" + "note B\n" + "#+end_src\n" + ) + + def test_range_spanning_two_blocks_is_refused(self, run_remove_expecting_failure): + # Lines 2..9: block one's opener through block two's closer. + err, content = run_remove_expecting_failure(self.TWO_BLOCKS, 2, 9) + assert err.returncode == 1 + assert "KEEP THIS LINE" in content, "content between the blocks was destroyed" + assert "* Beta" in content, "a heading between the blocks was destroyed" + + def test_refusal_names_the_reason(self, run_remove_expecting_failure): + err, _ = run_remove_expecting_failure(self.TWO_BLOCKS, 2, 9) + assert "more than one" in err.stderr.decode().lower() + + def test_a_correct_single_block_range_still_removes(self, run_remove): + # The fix must not over-tighten: the legitimate range still works. + out = run_remove(self.TWO_BLOCKS, 2, 4) + assert "note A" not in out + assert "KEEP THIS LINE" in out + assert "note B" in out, "the second block must be untouched" + + def test_a_nested_end_src_inside_the_range_is_refused(self, run_remove_expecting_failure): + # Any #+end_src before the final line means the range covers >1 block. + content = ( + "#+begin_src cj:\n" + "a\n" + "#+end_src\n" + "middle\n" + "#+begin_src cj:\n" + "b\n" + "#+end_src\n" + ) + err, after = run_remove_expecting_failure(content, 1, 7) + assert err.returncode == 1 + assert "middle" in after + + +class TestSafeMutation: + """The script rewrites Craig's org files (todo.org, notes.org). It wrote with + a bare write_text, which truncates the target on open, and took no backup — + so a mid-write failure left the file truncated with no copy to recover from. + lint-org.el, the other tool that mutates these files, backs up to a temp dir + first. Match that, and make the write atomic. + + Every test here redirects TMPDIR to a private directory. The backup name + derives from the file's basename, and the real todo.org shares it, so a test + globbing the shared temp dir cannot tell its own artifact from a genuine + backup — and an earlier version of this class globbed /tmp and unlinked every + match, so a routine `make test` destroyed real backups (found in review, + 2026-07-24). Never glob or delete across the shared temp dir.""" + + ONE_BLOCK = "* T\n#+begin_src cj:\nnote\n#+end_src\nkeep\n" + + def test_a_backup_is_written_before_mutating(self, tmp_path): + import subprocess, glob, os + bdir = tmp_path / "bk" + bdir.mkdir() + f = tmp_path / "todo.org" + f.write_text(self.ONE_BLOCK) + subprocess.run( + ["python3", str(SCRIPT), "--file", str(f), "--start", "2", "--end", "4"], + check=True, capture_output=True, + env={**os.environ, "TMPDIR": str(bdir)}, + ) + backups = glob.glob(str(bdir / "todo.org.before-cj-remove.*")) + assert backups, "no backup was written before mutating the org file" + assert "note" in Path(max(backups)).read_text() + + def test_no_partial_file_when_the_write_fails(self, tmp_path, monkeypatch): + import importlib.util + spec = importlib.util.spec_from_file_location("crb", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + bdir = tmp_path / "bk" + bdir.mkdir() + monkeypatch.setenv("TMPDIR", str(bdir)) + f = tmp_path / "todo.org" + f.write_text(self.ONE_BLOCK) + def boom(*a, **k): + raise OSError("disk full") + monkeypatch.setattr(mod.os, "replace", boom) + with pytest.raises(OSError): + mod.remove_range(f, 2, 4) + # The original survives intact — no truncation, no partial. + assert f.read_text() == self.ONE_BLOCK + + +class TestBackupNeverOverwrites: + """Same defect class as todo-cleanup's, and more reachable here: the + respond-to-cj-comments skill removes several annotations in quick + succession, so a second-resolution stamp collides and the later backup + overwrote the earlier one with already-mutated content.""" + + TWO_BLOCKS = ( + "* A\n#+begin_src cj:\nfirst\n#+end_src\n" + "* B\n#+begin_src cj:\nsecond\n#+end_src\n" + ) + + def test_consecutive_removals_each_keep_a_backup(self, tmp_path, monkeypatch): + import subprocess, glob + bdir = tmp_path / "bk" + bdir.mkdir() + monkeypatch.setenv("TMPDIR", str(bdir)) + f = tmp_path / "todo.org" + f.write_text(self.TWO_BLOCKS) + original = f.read_text() + # Remove the second block, then the first — back to back, same second. + subprocess.run(["python3", str(SCRIPT), "--file", str(f), + "--start", "6", "--end", "8"], + check=True, capture_output=True, + env={**__import__("os").environ, "TMPDIR": str(bdir)}) + subprocess.run(["python3", str(SCRIPT), "--file", str(f), + "--start", "2", "--end", "4"], + check=True, capture_output=True, + env={**__import__("os").environ, "TMPDIR": str(bdir)}) + backups = glob.glob(str(bdir / "todo.org.before-cj-remove.*")) + assert len(backups) == 2, f"expected 2 backups, got {len(backups)}" + contents = [Path(b).read_text() for b in backups] + assert original in contents, "no backup holds the true original" diff --git a/claude-templates/.ai/scripts/tests/test_route_recommend.py b/claude-templates/.ai/scripts/tests/test_route_recommend.py index acc4755..2ec900a 100644 --- a/claude-templates/.ai/scripts/tests/test_route_recommend.py +++ b/claude-templates/.ai/scripts/tests/test_route_recommend.py @@ -122,3 +122,31 @@ def test_cli_exclude_drops_current_project(tmp_path): r = _run(["--exclude", "foo"], roots=[tmp_path / "projects"], item="fix the foo widget") assert r.returncode == 0 assert r.stdout.strip() == "none" + + +# ---------------------------------------------------------------------- +# Duplicate candidate names +# +# Projects are collapsed to bare basenames, so two projects sharing a basename +# across roots (~/code/notes and ~/projects/notes) appear twice in the candidate +# list. Both literal-match, recommend read len(strong) > 1 as an ambiguous tie, +# and a correct strong match was downgraded to weak. Latent when discovered +# 2026-07-24 (27 projects, 27 distinct basenames) but real. +# ---------------------------------------------------------------------- + +def test_duplicate_candidate_name_keeps_strong_confidence(): + assert rr.recommend("fix the notes thing", ["notes", "other"]) == ("notes", "strong") + # The same name twice must not read as a tie. + assert rr.recommend("fix the notes thing", ["notes", "notes", "other"]) == ("notes", "strong") + + +def test_genuine_ambiguity_still_downgrades(): + # Two DIFFERENT projects both matching is a real tie and stays weak — the + # dedupe must collapse identical names only, never real ambiguity. + dest, conf = rr.recommend("notes and other both", ["notes", "other"]) + assert conf == "weak" + + +def test_duplicates_do_not_change_the_chosen_destination(): + dest, _ = rr.recommend("fix the notes thing", ["notes", "notes"]) + assert dest == "notes" diff --git a/claude-templates/.ai/scripts/todo-cleanup.el b/claude-templates/.ai/scripts/todo-cleanup.el index c4a87de..cb333e2 100644 --- a/claude-templates/.ai/scripts/todo-cleanup.el +++ b/claude-templates/.ai/scripts/todo-cleanup.el @@ -100,6 +100,12 @@ ;; --check-child-priority is the report-only alias for --sync-child-priority ;; --check. +;; Before any modification a backup is copied to +;; /tmp/<basename>.before-todo-cleanup.<YYYYMMDD-HHMMSS> +;; matching lint-org.el and wrap-org-table.el. Skipped under --check, which +;; writes nothing. +;; + (require 'org) (require 'cl-lib) (require 'calendar) @@ -832,9 +838,37 @@ event-log entry, pulling the timestamp from its CLOSED cookie. Honors ;;; --------------------------------------------------------------------------- ;;; Driver + reporting +(defun tc--backup (file) + "Copy FILE to /tmp before any modification. Skipped in --check mode. + +Matches `lint-org.el' and `wrap-org-table.el', the other tools that rewrite +these org files. todo-cleanup runs the most often of the three (every wrap, +every sentry fire), and Emacs's own backup does not fire under --batch -q, so +without this a mechanical rewrite has no undo short of git — which recovers +only to the last commit and loses intra-session work." + (let* ((base (format "%s%s.before-todo-cleanup.%s" + temporary-file-directory + (file-name-nondirectory file) + (format-time-string "%Y%m%d-%H%M%S"))) + (backup base) + (n 2)) + ;; Never overwrite an earlier backup. A second-resolution stamp collides + ;; when two invocations run back to back, which the shipped workflow does + ;; (open-tasks.org runs --convert-subtasks then --archive-done, each a + ;; sub-second batch run). Overwriting there replaces the true pre-session + ;; original with already-mutated content — losing exactly what the backup + ;; exists to preserve. Suffix instead, so every invocation keeps its own. + (while (file-exists-p backup) + (setq backup (format "%s-%d" base n)) + (setq n (1+ n))) + (copy-file file backup nil) + backup)) + (defun tc-process-file (file) (setq tc-current-file (file-name-nondirectory file)) (setq tc-current-dir (file-name-directory (expand-file-name file))) + (unless tc-check-only + (tc--backup file)) (with-current-buffer (find-file-noselect file) (org-mode) (cond diff --git a/scripts/lint.sh b/scripts/lint.sh index 61a27a1..82f3b34 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -114,6 +114,14 @@ for s in scripts/*.sh; do check_hook "$s" done +# Scripts `make install` symlinks onto PATH. Extensionless by convention, so +# they need their own glob — scripts/*.sh above never matched them, which left +# the most exposed shell in the repo as the only shell with no gate over it. +for s in claude-templates/bin/*; do + [ -f "$s" ] || continue + check_hook "$s" +done + # Markdown link validation across rules and skills for f in claude-rules/*.md */SKILL.md; do [ -f "$f" ] || continue diff --git a/scripts/tests/audit.bats b/scripts/tests/audit.bats index 0b062fb..c6123e1 100644 --- a/scripts/tests/audit.bats +++ b/scripts/tests/audit.bats @@ -40,9 +40,28 @@ git_init_with_ai_tracked() { local proj_dir="$1" (cd "$proj_dir" \ && git init -q \ + && git config maintenance.auto false \ + && git config gc.auto 0 \ && git add -A \ && git -c user.email=test@test -c user.name=test commit -q -m initial) } +# maintenance.auto/gc.auto are off because `git commit` otherwise spawns +# `git maintenance run --auto --quiet --detach` (traced on git 2.55). The commit +# returns while that detached process is still writing .git/objects/pack, so +# teardown's `rm -rf` intermittently failed with "Directory not empty" and bats +# reported a passing test as failed. Measured at ~8% of runs. Killing the +# background writer is the fix; a retry loop in teardown would only hide it. +# +# What triggers it: the fixture rsyncs ~170 workflow/script files before +# committing, and maintenance's loose-objects task fires at a default threshold +# of 100. (Not gc.auto's 6700 — that never fires here, which is why disabling +# gc.auto alone was never the explanation.) maintenance.auto false is what +# suppresses it on 2.55; gc.auto 0 is the portable guard for older git, where +# commit calls gc --auto directly and maintenance.auto does not exist. Either +# is sufficient on its own here. +# +# Other bats suites that git-init fixtures are currently safe only by staying +# under that 100-object threshold — an implicit dependency, not a guarantee. @test "audit: clean projects report ok with exit 0" { scaffold_synced_ai "$TEST_HOME/code/alpha" diff --git a/scripts/tests/lint-coverage.bats b/scripts/tests/lint-coverage.bats new file mode 100644 index 0000000..130212d --- /dev/null +++ b/scripts/tests/lint-coverage.bats @@ -0,0 +1,54 @@ +#!/usr/bin/env bats +# +# Coverage tests for scripts/lint.sh. +# +# lint.sh sweeps scripts/*.sh, languages/*/claude/hooks/*.sh, and +# languages/*/githooks/* through check_hook (shebang present, executable bit +# set). It never touched claude-templates/bin/ — the four scripts `make install` +# symlinks into ~/.local/bin, so the most exposed shell in the repo was the only +# shell with no gate over it (found 2026-07-24). +# +# These tests pin the coverage itself rather than the current cleanliness: they +# plant a deliberately broken file in each swept location and assert lint.sh +# complains. A location that stops being swept fails here. + +REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" +LINT="$REPO_ROOT/scripts/lint.sh" + +teardown() { + [ -n "${PLANTED:-}" ] && rm -f "$PLANTED" + PLANTED="" +} + +@test "lint: a bin/ script missing its shebang is flagged" { + PLANTED="$REPO_ROOT/claude-templates/bin/zz-test-noshebang" + printf 'echo hi\n' > "$PLANTED" + chmod +x "$PLANTED" + run bash "$LINT" + [[ "$output" == *"zz-test-noshebang"* ]] || { + echo "lint.sh did not report the planted bin/ script — that path is unswept" + echo "$output" + return 1 + } +} + +@test "lint: a bin/ script that is not executable is flagged" { + PLANTED="$REPO_ROOT/claude-templates/bin/zz-test-noexec" + printf '#!/usr/bin/env bash\necho hi\n' > "$PLANTED" + chmod -x "$PLANTED" + run bash "$LINT" + [[ "$output" == *"zz-test-noexec"* ]] +} + +@test "lint: the existing scripts/ sweep still works (guards the regression)" { + PLANTED="$REPO_ROOT/scripts/zz-test-noshebang.sh" + printf 'echo hi\n' > "$PLANTED" + chmod +x "$PLANTED" + run bash "$LINT" + [[ "$output" == *"zz-test-noshebang.sh"* ]] +} + +@test "lint: the real tree passes (no planted file)" { + run bash "$LINT" + [ "$status" -eq 0 ] +} @@ -39,55 +39,107 @@ Tags are assigned and refreshed by =task-audit=; =task-review= keeps them honest * Rulesets Open Work -** DONE [#A] Python and TypeScript bundles ship no secret-scan hook :bug: -CLOSED: [2026-07-23 Thu] +** DONE [#C] claude-templates/bin/ gets no lint coverage at all :bug:quick:solo: +CLOSED: [2026-07-24 Fri] :PROPERTIES: -:LAST_REVIEWED: 2026-07-23 +:LAST_REVIEWED: 2026-07-24 :END: -Fixed 2026-07-23. Both bundles now ship all four components: =githooks/pre-commit= (the secret scan, shared verbatim with the other bundles, plus a language-appropriate syntax gate), =claude/hooks/validate-python.sh= / =validate-typescript.sh=, =claude/settings.json= wiring the PostToolUse hook, and a seed =CLAUDE.md=. 53 new tests (13 + 13 hook, 13 + 14 pre-commit), suite green. +Fixed 2026-07-24 (sentry fire 8, f91feef). lint.sh now sweeps =claude-templates/bin/*= through =check_hook=, matching the extensionless-file shape =languages/*/githooks/*= already uses. Added =scripts/tests/lint-coverage.bats=, which pins the *coverage* rather than current cleanliness: it plants a broken file in each swept location and asserts lint.sh complains, so a location that silently stops being swept fails the suite. The broader question this surfaced — whether rulesets should run shellcheck on its own shell at all — is the VERIFY below and stays Craig's call. +=scripts/lint.sh= sweeps =scripts/*.sh=, =languages/*/claude/hooks/*.sh=, and =languages/*/githooks/*= through =check_hook= (shebang present, executable bit set). It never touches =claude-templates/bin/=. Verified: zero references to that path in the file. -Two findings from the build worth keeping. First, =node --check= must never be used on TypeScript: it ignores =--experimental-strip-types=, so it rejects valid TS (an =interface= reads as a syntax error) and accepts broken TS. Measured on node v26.4.0. The hook uses =tsc= filtered to TS1xxx (syntactic) diagnostics instead, which also keeps type errors out of scope — those need the whole project graph. Second, the install now warns when a bundle lacks a documented component, which is the half that stops this recurring: nobody will remember the seventh bundle either, so the installer says so. +Those four scripts — =ai=, =agent-text=, =agent-page=, =install-ai= — are the ones =make install= symlinks into =~/.local/bin=, so they run on Craig's PATH on every machine. They are the *most* exposed shell in the repo and the only shell with no gate over it. -Left undone deliberately: re-running =make install-lang= on =work= and =clock-panel=. Both are other projects, so that's a cross-project action for Craig. See the polyglot task below — =clock-panel= is python + typescript and can no longer install both. -The =python= and =typescript= language bundles carry rules, a coverage script, and a gitignore fragment. They carry no =githooks/pre-commit=, no =claude/hooks/=, no =claude/settings.json=, and no =CLAUDE.md=. The =bash=, =elisp=, and =go= bundles carry all four. +All four are clean today (shebangs present, mode 755, shellcheck-clean when run by hand), so nothing is broken. This is a missing gate, not an active defect: the =ai= launcher was hardened to 42 tests recently and that cleanliness is not enforced going forward. -The pre-commit hook is the secret scanner. So a project installing the Python or TypeScript bundle gets no credential scan on commit, no validate-on-edit hook, and no settings — while README's "Bundle structure" section documents all four as what each bundle follows, and notes.org describes the bundles as "rules + hooks + settings". +Grading: Minor severity (nothing broken now; the exposure is a future regression in a PATH-installed script going uncaught) x every user, every time (every =make lint= silently skips them) = P2 = [#C]. -Live as of 2026-07-23, verified by scanning every project carrying =.claude/rules/=: =work= (python) and =clock-panel= (python + typescript) both have no =githooks/= and no =.claude/settings.json=. The four elisp projects all have both. =work= is the one that matters — a work repo is where a leaked credential is most costly and most likely to reach a company remote. +Fix: add =claude-templates/bin/*= to the =check_hook= loop, the same shape =languages/*/githooks/*= already uses for extensionless files. A no-op today by design — it passes immediately — which is exactly what a guard should do. -Not a design choice. Both bundles were added 2026-05-31; =go='s githooks landed 2026-06-02 and =bash='s 2026-06-23, so the hook rollout swept the two bundles added *after* these and skipped these. =install-lang.sh= guards its copy with =[ -d "$SRC/githooks" ]=, so the install succeeds silently and reports nothing missing — which is why this stayed invisible for nearly two months. +** TODO [#B] cj-remove-block still can delete the WRONG cj block :bug: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +The 2026-07-24 range fix (17f5d48) closed the *over-deletion* case: a range spanning two blocks is now refused. It did NOT close the underlying class. The validator proves the range is a well-formed single block; it never proves it is the block that was *scanned*. -Grading: Major severity (a documented security control absent, silently, with the install reporting success) x every user, every time (every install of either bundle, standing on 2 of the 6 bundle-using projects) = P1 = [#A]. +An adversarial reviewer demonstrated this on the fixed code: passing the second block's range while intending the first deletes the second, exit 0, no warning. Drift by exactly one whole block still slips through — which is the failure the validator exists to prevent. -Fix direction: port =githooks/pre-commit= to both bundles, adapting the language-specific half (the secret scan is common; =gofmt=/=shellcheck= becomes a formatter/linter check per language), add =claude/hooks/validate-*.sh= and =claude/settings.json=, and seed =CLAUDE.md=. Then make the gap loud: =install-lang= should warn when a bundle lacks a component the README documents, so the next partial bundle announces itself instead of installing quietly. Re-run =make install-lang= on =work= and =clock-panel= afterward. +Grading: Major severity (silent deletion of the wrong annotation in Craig's org files, no warning, success exit) x rare edge case (needs drift landing exactly on another well-formed block) = P3 = [#C]... graded up to [#B] because it is the *same* failure the fix was believed to have closed, so the current state carries false confidence. -** DONE [#C] Two language bundles' pre-commit hooks lost the cd guard :bug:quick:solo: -CLOSED: [2026-07-23 Thu] +Fix direction needs a decision, which is why this is not :solo:. The range alone cannot identify the intended block, so the caller must assert something about content — a hash of the expected block, or the expected first body line, passed alongside the range and verified before deletion. That changes the CLI contract and the calling skill, so it is a design call. + +** TODO [#D] Atomic-write residuals in cj-remove-block :bug: :PROPERTIES: -:LAST_REVIEWED: 2026-07-23 +:LAST_REVIEWED: 2026-07-24 :END: -Fixed 2026-07-23. =go= and =elisp= now read =cd "$REPO_ROOT" || exit 1=, matching =bash=; the two new bundles were written with the guard. All five siblings agree, all five shellcheck clean. -=languages/bash/githooks/pre-commit= line 8 reads =cd "$REPO_ROOT" || exit 1=. The =go= and =elisp= copies of the same line read a bare =cd "$REPO_ROOT"=. Three siblings of one file, one hardened and two not — the guard landed in bash and never propagated. +Three narrower gaps a reviewer found in the atomic write, all verified, none fixed in the 2026-07-24 repair round: -Low impact, stated honestly: git chdirs to the working-tree root before running a hook, so if =cd= fails the cwd is already correct and the checks still run against the right tree. Neither script sets =-e=, so a failure wouldn't abort them either. It takes a =GIT_WORK_TREE= or bare-repo edge case for =rev-parse --show-toplevel= to disagree with the cwd at all. +1. =shutil.copymode= runs before =write_text=, and writing clears setuid/setgid — a 2755 file comes back 755. +2. ACLs are not carried across, and copying the ACL mask into the group bits can *widen* group permission on a file carrying a named ACL entry. +3. Hard links are broken the same way symlinks were: =os.replace= gives the path a new inode, so a second hard link keeps stale content. Introduced by the atomic write itself (17f5d48), which did not exist on main. +4. No =fsync= before =os.replace=, so the "never a partial" guarantee holds against process failure but not a system crash. Two lines to close. -Grading: Minor severity (belt-and-braces guard, no silent-pass path found — the checks run regardless) x rare edge case (needs a git configuration that makes toplevel differ from the hook's cwd) = P4... graded [#C] rather than [#D] because it's a two-character fix in a security-relevant gate and the drift pattern is the real signal: a fix landed in one bundle copy and stopped there, which is the same shape as the [#A] above. +Grading: Minor severity (each needs an unusual file mode, an ACL, a hard link, or a crash mid-write) x rare edge case = P4 = [#D]. -** DONE [#B] Parked: zero markup in chat output, fences included (from org-drill) -CLOSED: [2026-07-23 Thu] -Craig approved 2026-07-23. Applied to =claude-rules/interaction.md=: the fenced-code-block carve-out is gone, replaced with a zero-markup-always statement citing his 2026-05-30 direction. The rule's factual claim stays honest — fences don't invert the way inline spans do, so the text says they read as markup he didn't ask for rather than inventing a rendering problem, and points at plain indented text or a named file path as the way to hand over something copyable. It was the only copy of the carve-out. +** TODO [#D] Test suites leak backup files into shared /tmp :test: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +The elisp todo-cleanup suite writes roughly 81 backup files into the shared temp dir per =make test= run, named after randomized fixtures (=tc-test-XXXXXX.org.before-todo-cleanup.*=). 1598 had accumulated by 2026-07-24. Not data loss and not production-named, so nothing masquerades as a real backup and nothing is deleted — this is clutter that grows every run. -** DONE [#B] Parked: sentry Living Document updates from two dogfood runs (from takuzu + archangel) -CLOSED: [2026-07-23 Thu] -Craig approved 2026-07-23. Four folds applied to =sentry.org=: =--archive-done= touches =.gitignore= on its first run so an "org-only" pass can still produce a commit, a mirror-only project's quiet fires leave no commits so the anchor's heartbeat list is the only record, randomized property sweeps are good quiet-fire work, and the task-audit pass splits into a mechanical hourly subset plus a nightly judgment half. The fifth finding (make the bug hunt official) had already landed as pass 11, so it became a corroboration note. Takuzu's lint-org finding stayed a filed bug task rather than a workflow edit, since it's a defect not a design change. +The python sibling was fixed with an autouse fixture (f850ad6) that sets =TMPDIR= and overrides =tempfile.tempdir=. ERT has no autouse equivalent, so the elisp fix wants a load-time rebind of =temporary-file-directory= plus a =kill-emacs-hook= cleanup. -** DONE [#B] Parked: clear four lint flags in the notes.org template (from smoke) -CLOSED: [2026-07-23 Thu] -Craig approved 2026-07-23. Applied to =claude-templates/.ai/notes.org= and synced to the mirror. The template now lints with zero mechanical and zero judgment findings, down from four. Two fixes beyond what smoke reported: two more column-0 bold lines, and the real cause of the block flags — a literal =** Feature Name= inside the example block that org parses as a heading, cleared by comma-escaping that one line. Worth remembering: those heading flags were mechanical, not judgment, so =lint-org --fix= in any project would have rewritten the template locally and drifted it from canonical. +Also unaddressed: =test-lint-org.el= scans a hardcoded =/tmp=, and =lint-org.el= hardcodes =/tmp/= in =lo--backup=, so that pair cannot be isolated the same way without touching the script. And =lint-org.el= carries the same second-resolution backup-overwrite flaw that todo-cleanup and cj-remove-block were fixed for. -** DONE [#B] Parked: clear temp/ during wrap-up teardown (from your roam capture) -CLOSED: [2026-07-23 Thu] -Craig approved 2026-07-23. Added =*** Clear temp/= to =wrap-it-up.org= Step 3, after the archive pass, and synced to the mirror. Two guards: confirm before deleting anything that reads as in-progress rather than throwaway (that belongs in =working/=, so move it there), and skip entirely where =temp/= isn't gitignored, since that means the project uses the directory for something else. This closes the last open clause of his 2026-07-20 working/temp capture. +Grading: Minor severity (clutter in a directory cleared on reboot) x every user, every time (every suite run) = P2... graded [#D] on the no-harm read. + +** VERIFY [#B] Should rulesets run shellcheck on its own shell? +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Surfaced by the lint-coverage finding above, and a bigger question than that task should decide. + +rulesets ships shellcheck enforcement to *other* projects — the bash bundle's =githooks/pre-commit= scans staged shell, and =validate-bash.sh= blocks an edit that fails it. rulesets runs neither on itself. Its own =githooks/pre-commit= calls =sync-check.sh= and nothing else; =lint.sh='s =check_hook= validates only shebang and exec bit; =make test= has no shellcheck step. So the repo asks consumers for a standard it does not apply to its own shell. + +Why this needs your call rather than an overnight fix: turning shellcheck on today would surface the findings I dispositioned as false positives during this session's sweeps — =SC2094= on =install-ai.sh= and =sweep-gitignore-tooling.sh= (append-with-stat, not a read-write race), =SC2088= on =doctor.sh= and =audit.sh= (tilde in a *display* string, not a path), =SC2164= on =lint.sh= and =status.sh=. Enabling the gate means either fixing those or adding =# shellcheck disable== directives with justifications, and which of those you want is a preference, not a fact. + +Options: (1) shellcheck in =make lint= as a warning, (2) in =githooks/pre-commit= as a hard gate matching what consumers get, (3) leave it, on the grounds that the repo's shell is small and reviewed. My lean is 2, since the asymmetry is the odd part — but it is your repo's bar to set. + +** TODO [#C] Attachment filenames from email are only partly sanitized :bug: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Both attachment writers derive on-disk filenames from attacker-controlled input (the =filename= an email declares for its attachment), and both sanitize incompletely — in *different* ways, so neither covers the other's gaps. + +=eml-view-and-extract-attachments.py= runs the name through =_clean_for_filename= but interpolates the *extension* raw: =name, ext = os.path.splitext(...)= then =f"{basename}-ATTACH-{clean_name}{ext}"=. =gmail-fetch-attachments.py='s =safe_filename= handles path separators and leading =..= and nothing else. + +Verified 2026-07-24 by probing both with the same adversarial set: + +| input | eml-view result | gmail-fetch result | +|------------------------+------------------------------------+--------------------| +| =report.pdf; rm -rf ~= | keeps =; rm -rf ~= in the filename | keeps it | +| =x.p\ndf= | keeps a literal newline | keeps it | +| =a.= + 300 chars | 314-char filename | unbounded | +| =../../../etc/passwd= | neutralized | neutralized | + +What this is NOT, checked so it isn't over-graded later: not remote code execution (files are written through Python =open=, never a shell) and not path traversal (=os.path.splitext= only returns an extension when the last dot follows the last separator, so =ext= can never contain a slash — the traversal case above is neutralized in both). The real harms are narrower: a newline in a filename breaks any downstream tool that reads the output directory as a newline-delimited list, and an unbounded extension exceeds the 255-byte filesystem limit so a crafted attachment aborts the extraction. + +Grading: Major severity (untrusted input reaches a filesystem name unsanitized, and the newline case breaks real tooling today) x rare edge case (needs an attachment name with unusual characters *after* the last dot, which ordinary mail doesn't produce) = P3 = [#C]. + +Fix direction: sanitize the extension with the same rules as the name, cap the *whole* filename rather than just the stem, and reject or replace control characters in both scripts. The open question is where the sanitizer lives — see the VERIFY below. + +** VERIFY [#B] One sanitizer or two for the attachment filename fix? +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Deferred from pass 12 on 2026-07-24: the bug above is well-specified, but *where* the shared sanitizer lives is a design call with real tradeoffs, and the unattended loop has no one to ask. + +The two scripts are standalone stdlib-only CLI tools with kebab-case names, so neither is importable as a normal module. Three options, none obviously right: + +1. *A shared helper module* in =.ai/scripts/=. Cleanest single source of truth, but it becomes another synced template file, and the kebab-named callers need =importlib= gymnastics to reach it — the same trick =route_recommend.py= already uses to load =inbox-send.py=, so there is precedent. +2. *Duplicate a small sanitizer in each script.* Zero import machinery and each tool stays self-contained, which is the current house style for these scripts. Costs drift, and sibling drift is exactly the defect class that produced three separate findings this session. +3. *Fix only the demonstrated gaps in place* (extension sanitizing in one, length and control chars in both) without unifying. Smallest diff, leaves the two sanitizers permanently different. + +I lean 1 given how much drift has bitten tonight, but it changes the shape of =.ai/scripts/= and that is Craig's call, not an overnight one. ** VERIFY [#B] Parked: question-capture pattern — ask async, answer live (from archsetup, Craig's idea) :PROPERTIES: @@ -163,24 +215,6 @@ Not [#A] because nothing is currently broken: no project is running with a bundl Pinned by =scripts/tests/install-lang-collision.bats= so the trade can't drift back unnoticed. -** DONE [#B] inbox-send leaves a phantom empty handoff in the target inbox :bug:solo: -CLOSED: [2026-07-23 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-23 -:END: -Fixed 2026-07-23 (speedrun, 0f91a8e). Both send paths write to a .inbox-send-* temp sibling and os.replace it into place, so a mid-write failure leaves no phantom; utf-8 pinned on the write and the roots read; inbox-status skips the in-flight temp. 5 new tests (atomic write, no-partial-on-failure, no-temp-on-success, both paths) + 1 inbox-status bats. Review found and fixed one Important: the temp was discoverable by inbox-status during the write window. -=inbox-send.py= writes straight to the destination path in the target project's =inbox/=. =Path.write_text= opens with mode =w=, which creates and truncates before any content is written, so *any* failure between opening and finishing leaves a zero-byte =.org= file sitting in another project's inbox. - -That file is not inert. =inbox-status= counts it as a pending handoff, so it trips the receiving project's =inbox-boundary-check= Stop hook and blocks a turn there. The receiving agent then has to resolve a handoff with no content and no sender context, which it cannot do from the file. Meanwhile the sender saw an error and will most likely retry, so the target gets a second file too. - -Reproduced 2026-07-23 end to end: a send whose text contains non-ASCII under =LC_ALL=C PYTHONUTF8=0 PYTHONCOERCECLOCALE=0= fails on the ASCII codec, leaves a zero-byte file in the destination inbox, and =inbox-status= in that project then reports it as pending. - -The encoding case is one trigger, not the defect. =write_text= and =read_text= are both called with no =encoding= argument, so they follow the locale; passing =encoding="utf-8"= closes that trigger. The defect underneath is the non-atomic write, which a full disk, a revoked permission, or an interrupted process reaches just as easily. - -Grading: Major severity (a phantom handoff in a *different* project's inbox, unresolvable from its own content, that blocks a turn there via the boundary hook) x some users, sometimes (any mid-write failure, of which the locale case is only the one reproduced) = P2 = [#B]. - -Fix direction: write to a temp file in the destination directory and =os.replace= into place, so the inbox only ever sees a complete file. Pin =encoding="utf-8"= on both =write_text= and the =read_text= in =resolve_roots=. Same treatment for =send_file=, whose =shutil.copy2= has the same shape. Test by forcing a mid-write failure and asserting the destination directory is unchanged. - ** TODO [#C] Two Signal-channel gaps: unbounded send, second account never received :bug: :PROPERTIES: :LAST_REVIEWED: 2026-07-23 @@ -195,50 +229,6 @@ Both found 2026-07-23 reading =claude-templates/bin/agent-text= and =scripts/sig Grading: Minor severity for both (one is a latent hang in a tool with a documented fallback, the other a warning on an account nothing currently depends on) x most users, frequently (the stale warning is a standing condition on this host; the hang needs a stall) = P3 = [#C]. -** DONE [#C] Two smaller inbox-send defects: uncaught traceback, duplicate roots :bug:quick:solo: -CLOSED: [2026-07-23 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-23 -:END: -Fixed 2026-07-23 (speedrun, a053e9d). main now catches OSError, so an unreadable source gives the clean inbox-send: error rather than a traceback. discover_projects dedupes on the resolved path. 2 tests. -Both verified 2026-07-23, both in =.ai/scripts/inbox-send.py=, both low-harm. Grouped because they're the same file and the same fix session. - -1. *Uncaught =PermissionError=.* =main= catches =(ValueError, FileNotFoundError)= around the send, but =send_file= reaches =shutil.copy2=, which raises =PermissionError= on an unreadable source. That is an =OSError=, not caught, so the user gets a raw Python traceback instead of the clean =inbox-send: <message>= error every other failure path produces. Reproduced with a =chmod 000= source. No partial file is left in this case. Fix: catch =OSError= alongside the other two. - -2. *Duplicate roots list a project twice.* =discover_projects= appends without deduping, so a roots config naming both a parent and one of its children (=/x= and =/x/proj=) lists =proj= at two different numeric indices. Reproduced via =INBOX_SEND_ROOTS=. Harmless today — both indices resolve to the same project, so no message goes to the wrong place, and Craig's current =~/.claude/inbox-roots.txt= has no overlap. Fix: dedupe on =resolve()= before returning. - -Grading: Minor severity (one is cosmetic output noise, the other an ugly but accurate failure message; neither loses or misroutes a message) x rare edge case (an unreadable source file, or a roots config with an overlap) = P4... graded up to [#C] rather than [#D] because both fixes are one line each and sit in a script every project depends on for cross-project messaging. - -** DONE [#C] lint-org todo-format checkers fire on spec files :bug:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-23 -:END: -Fixed 2026-07-24 (speedrun, c38bab9). A lo--spec-file-p path guard skips all five todo-format-family checkers on files under docs/specs/. Verified: the nine repo specs go from 100 todo-format findings to zero, todo.org keeps its full checker set. 7 ERT tests. -=level2-done-without-closed= and =level-2-dated-header= encode =todo.org= completion conventions, but they run against every org file, including =docs/specs/=. A spec's Decisions section legitimately uses =** DONE <decision>= with no =CLOSED:= cookie, and its Review-and-iteration-history section legitimately uses =** <dated> — <who> — <role>= headings. Neither is a completion defect there. - -Reproduced 2026-07-23 across rulesets' own specs: 100 findings over 7 of the 9 files in =docs/specs/= (docs-lifecycle 27, sentry-workflow 25, autonomous-batch 11, wrapup-routing 11, inbox-consolidation 10, agent-kb 8, encourage-kb 8; the two 2026-07-20 specs are clean). Every project carrying =docs/specs/= inherits the same noise on every sweep. Reported independently by takuzu's first sentry dogfood run. - -Grading: Minor severity (judgment-kind output, so nothing mutates; the cost is noise that trains the reader to skim) x every user, every time (fires on every sweep in every project with specs) = P2... graded down to [#C] because the two inputs disagree: the frequency row is genuinely universal, but Minor severity with zero mutation risk and a known cause reads as P3. Recorded so the read can be argued. - -Fix direction: scope the todo-format-family checkers away from =docs/specs/= by path. Correction (2026-07-23 speedrun): the earlier note that these are org-lint's own checkers was wrong — =lo--check-level2-dated-headers= (line 413) and =lo--check-level2-done-without-closed= (line 507) are lint-org.el's own functions, so the fix is a direct local guard, not upstream filtering. - -Decision (Craig, 2026-07-23 speedrun pre-flight): scope *all four* todo-format-family checkers, not just the two that fired — the two named plus =subtask-done-not-dated= and =dated-log-heading-active-timestamp=, which would misfire the same way on a spec's dated review-history headings. Path-based (any file under a =docs/specs/= segment), matching the docs-lifecycle canon that specs live there. Takuzu's spec-create alternative fixes new specs only and leaves the nine existing ones noisy, so path-scoping is the general fix. - -** DONE [#C] notes.org template trips four lint-org flags in every project :bug:quick:solo: -CLOSED: [2026-07-24 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-23 -:END: -Already satisfied. The canonical claude-templates/.ai/notes.org was fixed in 10ea44b (2026-07-23) when Craig approved the smoke proposal — the two column-0 bold lines rephrased, the example block's marker comma-escaped. Verified 2026-07-24: the template lints with zero mechanical and zero judgment findings, down from four. This TODO and the applied smoke VERIFY were duplicate work items for the same fix; closing DONE citing the commit rather than filing a no-op VERIFY, since the end-state is verifiable now with nothing to ask. -The synced =claude-templates/.ai/notes.org= carries two boilerplate lines that open with markdown-style bold at column 0 (=**Session history is NOT in this file.**=, =**For protocols and conventions, see:**=). Org parses a line-initial =**= as a level-2 heading, so =misplaced-heading= flags both, and the =#+begin_example= block in the Pending Decisions instructions trips =invalid-block= twice. Every project inherits all four on every sweep. - -Reproduced 2026-07-23. Confirms smoke's proposal. Additional finding from the same run: these two are =mechanical-fixed=, not judgment, so =lint-org --fix= silently rewrites the template's =**bold**= to org =*bold*=. The rewrite is correct org, but it lands on a synced template, so whichever project runs =--fix= first creates drift against canonical. - -Grading: Minor severity (cosmetic noise; the mechanical rewrite is correct org and harmless in isolation) x every user, every time (every project, every sweep) = P2... same disagreement as the checker task above; graded [#C] on the no-real-harm read. - -Fix direction: rephrase both lines in the canonical template so no line starts with =**= (a list dash, or move the bold off column 0), and comma-escape the example block's own markers. One canonical fix clears it everywhere. - ** TODO Manual testing and validation *** Sentry — entry gates fire with Craig present What we're verifying: the interactive entry gates stop for the right states and start the loop only on a clean, green baseline. @@ -1819,3 +1809,180 @@ The claimed evidence was that linting =claude-templates/.ai/notes.org= from the Retested three ways: the real file from the repo root gives zero link findings, the real file from its own directory gives zero, and only the =/tmp= copy gives two. A direct probe confirms =find-file-noselect= already sets =default-directory= to the linted file's directory, so the mechanism I proposed could not have been the cause either. Worth keeping from the episode: =link-to-local-file= is org-lint's own checker, not one lint-org.el implements, so a real fix here would mean pre- or post-filtering upstream output rather than editing a local checker. +** DONE [#A] Python and TypeScript bundles ship no secret-scan hook :bug: +CLOSED: [2026-07-23 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-23 +:END: +Fixed 2026-07-23. Both bundles now ship all four components: =githooks/pre-commit= (the secret scan, shared verbatim with the other bundles, plus a language-appropriate syntax gate), =claude/hooks/validate-python.sh= / =validate-typescript.sh=, =claude/settings.json= wiring the PostToolUse hook, and a seed =CLAUDE.md=. 53 new tests (13 + 13 hook, 13 + 14 pre-commit), suite green. + +Two findings from the build worth keeping. First, =node --check= must never be used on TypeScript: it ignores =--experimental-strip-types=, so it rejects valid TS (an =interface= reads as a syntax error) and accepts broken TS. Measured on node v26.4.0. The hook uses =tsc= filtered to TS1xxx (syntactic) diagnostics instead, which also keeps type errors out of scope — those need the whole project graph. Second, the install now warns when a bundle lacks a documented component, which is the half that stops this recurring: nobody will remember the seventh bundle either, so the installer says so. + +Left undone deliberately: re-running =make install-lang= on =work= and =clock-panel=. Both are other projects, so that's a cross-project action for Craig. See the polyglot task below — =clock-panel= is python + typescript and can no longer install both. +The =python= and =typescript= language bundles carry rules, a coverage script, and a gitignore fragment. They carry no =githooks/pre-commit=, no =claude/hooks/=, no =claude/settings.json=, and no =CLAUDE.md=. The =bash=, =elisp=, and =go= bundles carry all four. + +The pre-commit hook is the secret scanner. So a project installing the Python or TypeScript bundle gets no credential scan on commit, no validate-on-edit hook, and no settings — while README's "Bundle structure" section documents all four as what each bundle follows, and notes.org describes the bundles as "rules + hooks + settings". + +Live as of 2026-07-23, verified by scanning every project carrying =.claude/rules/=: =work= (python) and =clock-panel= (python + typescript) both have no =githooks/= and no =.claude/settings.json=. The four elisp projects all have both. =work= is the one that matters — a work repo is where a leaked credential is most costly and most likely to reach a company remote. + +Not a design choice. Both bundles were added 2026-05-31; =go='s githooks landed 2026-06-02 and =bash='s 2026-06-23, so the hook rollout swept the two bundles added *after* these and skipped these. =install-lang.sh= guards its copy with =[ -d "$SRC/githooks" ]=, so the install succeeds silently and reports nothing missing — which is why this stayed invisible for nearly two months. + +Grading: Major severity (a documented security control absent, silently, with the install reporting success) x every user, every time (every install of either bundle, standing on 2 of the 6 bundle-using projects) = P1 = [#A]. + +Fix direction: port =githooks/pre-commit= to both bundles, adapting the language-specific half (the secret scan is common; =gofmt=/=shellcheck= becomes a formatter/linter check per language), add =claude/hooks/validate-*.sh= and =claude/settings.json=, and seed =CLAUDE.md=. Then make the gap loud: =install-lang= should warn when a bundle lacks a component the README documents, so the next partial bundle announces itself instead of installing quietly. Re-run =make install-lang= on =work= and =clock-panel= afterward. +** DONE [#C] Two language bundles' pre-commit hooks lost the cd guard :bug:quick:solo: +CLOSED: [2026-07-23 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-23 +:END: +Fixed 2026-07-23. =go= and =elisp= now read =cd "$REPO_ROOT" || exit 1=, matching =bash=; the two new bundles were written with the guard. All five siblings agree, all five shellcheck clean. +=languages/bash/githooks/pre-commit= line 8 reads =cd "$REPO_ROOT" || exit 1=. The =go= and =elisp= copies of the same line read a bare =cd "$REPO_ROOT"=. Three siblings of one file, one hardened and two not — the guard landed in bash and never propagated. + +Low impact, stated honestly: git chdirs to the working-tree root before running a hook, so if =cd= fails the cwd is already correct and the checks still run against the right tree. Neither script sets =-e=, so a failure wouldn't abort them either. It takes a =GIT_WORK_TREE= or bare-repo edge case for =rev-parse --show-toplevel= to disagree with the cwd at all. + +Grading: Minor severity (belt-and-braces guard, no silent-pass path found — the checks run regardless) x rare edge case (needs a git configuration that makes toplevel differ from the hook's cwd) = P4... graded [#C] rather than [#D] because it's a two-character fix in a security-relevant gate and the drift pattern is the real signal: a fix landed in one bundle copy and stopped there, which is the same shape as the [#A] above. +** DONE [#B] Parked: zero markup in chat output, fences included (from org-drill) +CLOSED: [2026-07-23 Thu] +Craig approved 2026-07-23. Applied to =claude-rules/interaction.md=: the fenced-code-block carve-out is gone, replaced with a zero-markup-always statement citing his 2026-05-30 direction. The rule's factual claim stays honest — fences don't invert the way inline spans do, so the text says they read as markup he didn't ask for rather than inventing a rendering problem, and points at plain indented text or a named file path as the way to hand over something copyable. It was the only copy of the carve-out. +** DONE [#B] Parked: sentry Living Document updates from two dogfood runs (from takuzu + archangel) +CLOSED: [2026-07-23 Thu] +Craig approved 2026-07-23. Four folds applied to =sentry.org=: =--archive-done= touches =.gitignore= on its first run so an "org-only" pass can still produce a commit, a mirror-only project's quiet fires leave no commits so the anchor's heartbeat list is the only record, randomized property sweeps are good quiet-fire work, and the task-audit pass splits into a mechanical hourly subset plus a nightly judgment half. The fifth finding (make the bug hunt official) had already landed as pass 11, so it became a corroboration note. Takuzu's lint-org finding stayed a filed bug task rather than a workflow edit, since it's a defect not a design change. +** DONE [#B] Parked: clear four lint flags in the notes.org template (from smoke) +CLOSED: [2026-07-23 Thu] +Craig approved 2026-07-23. Applied to =claude-templates/.ai/notes.org= and synced to the mirror. The template now lints with zero mechanical and zero judgment findings, down from four. Two fixes beyond what smoke reported: two more column-0 bold lines, and the real cause of the block flags — a literal =** Feature Name= inside the example block that org parses as a heading, cleared by comma-escaping that one line. Worth remembering: those heading flags were mechanical, not judgment, so =lint-org --fix= in any project would have rewritten the template locally and drifted it from canonical. +** DONE [#B] Parked: clear temp/ during wrap-up teardown (from your roam capture) +CLOSED: [2026-07-23 Thu] +Craig approved 2026-07-23. Added =*** Clear temp/= to =wrap-it-up.org= Step 3, after the archive pass, and synced to the mirror. Two guards: confirm before deleting anything that reads as in-progress rather than throwaway (that belongs in =working/=, so move it there), and skip entirely where =temp/= isn't gitignored, since that means the project uses the directory for something else. This closes the last open clause of his 2026-07-20 working/temp capture. +** DONE [#B] inbox-send leaves a phantom empty handoff in the target inbox :bug:solo: +CLOSED: [2026-07-23 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-23 +:END: +Fixed 2026-07-23 (speedrun, 0f91a8e). Both send paths write to a .inbox-send-* temp sibling and os.replace it into place, so a mid-write failure leaves no phantom; utf-8 pinned on the write and the roots read; inbox-status skips the in-flight temp. 5 new tests (atomic write, no-partial-on-failure, no-temp-on-success, both paths) + 1 inbox-status bats. Review found and fixed one Important: the temp was discoverable by inbox-status during the write window. +=inbox-send.py= writes straight to the destination path in the target project's =inbox/=. =Path.write_text= opens with mode =w=, which creates and truncates before any content is written, so *any* failure between opening and finishing leaves a zero-byte =.org= file sitting in another project's inbox. + +That file is not inert. =inbox-status= counts it as a pending handoff, so it trips the receiving project's =inbox-boundary-check= Stop hook and blocks a turn there. The receiving agent then has to resolve a handoff with no content and no sender context, which it cannot do from the file. Meanwhile the sender saw an error and will most likely retry, so the target gets a second file too. + +Reproduced 2026-07-23 end to end: a send whose text contains non-ASCII under =LC_ALL=C PYTHONUTF8=0 PYTHONCOERCECLOCALE=0= fails on the ASCII codec, leaves a zero-byte file in the destination inbox, and =inbox-status= in that project then reports it as pending. + +The encoding case is one trigger, not the defect. =write_text= and =read_text= are both called with no =encoding= argument, so they follow the locale; passing =encoding="utf-8"= closes that trigger. The defect underneath is the non-atomic write, which a full disk, a revoked permission, or an interrupted process reaches just as easily. + +Grading: Major severity (a phantom handoff in a *different* project's inbox, unresolvable from its own content, that blocks a turn there via the boundary hook) x some users, sometimes (any mid-write failure, of which the locale case is only the one reproduced) = P2 = [#B]. + +Fix direction: write to a temp file in the destination directory and =os.replace= into place, so the inbox only ever sees a complete file. Pin =encoding="utf-8"= on both =write_text= and the =read_text= in =resolve_roots=. Same treatment for =send_file=, whose =shutil.copy2= has the same shape. Test by forcing a mid-write failure and asserting the destination directory is unchanged. +** DONE [#C] Two smaller inbox-send defects: uncaught traceback, duplicate roots :bug:quick:solo: +CLOSED: [2026-07-23 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-23 +:END: +Fixed 2026-07-23 (speedrun, a053e9d). main now catches OSError, so an unreadable source gives the clean inbox-send: error rather than a traceback. discover_projects dedupes on the resolved path. 2 tests. +Both verified 2026-07-23, both in =.ai/scripts/inbox-send.py=, both low-harm. Grouped because they're the same file and the same fix session. + +1. *Uncaught =PermissionError=.* =main= catches =(ValueError, FileNotFoundError)= around the send, but =send_file= reaches =shutil.copy2=, which raises =PermissionError= on an unreadable source. That is an =OSError=, not caught, so the user gets a raw Python traceback instead of the clean =inbox-send: <message>= error every other failure path produces. Reproduced with a =chmod 000= source. No partial file is left in this case. Fix: catch =OSError= alongside the other two. + +2. *Duplicate roots list a project twice.* =discover_projects= appends without deduping, so a roots config naming both a parent and one of its children (=/x= and =/x/proj=) lists =proj= at two different numeric indices. Reproduced via =INBOX_SEND_ROOTS=. Harmless today — both indices resolve to the same project, so no message goes to the wrong place, and Craig's current =~/.claude/inbox-roots.txt= has no overlap. Fix: dedupe on =resolve()= before returning. + +Grading: Minor severity (one is cosmetic output noise, the other an ugly but accurate failure message; neither loses or misroutes a message) x rare edge case (an unreadable source file, or a roots config with an overlap) = P4... graded up to [#C] rather than [#D] because both fixes are one line each and sit in a script every project depends on for cross-project messaging. +** DONE [#C] lint-org todo-format checkers fire on spec files :bug:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-23 +:END: +Fixed 2026-07-24 (speedrun, c38bab9). A lo--spec-file-p path guard skips all five todo-format-family checkers on files under docs/specs/. Verified: the nine repo specs go from 100 todo-format findings to zero, todo.org keeps its full checker set. 7 ERT tests. +=level2-done-without-closed= and =level-2-dated-header= encode =todo.org= completion conventions, but they run against every org file, including =docs/specs/=. A spec's Decisions section legitimately uses =** DONE <decision>= with no =CLOSED:= cookie, and its Review-and-iteration-history section legitimately uses =** <dated> — <who> — <role>= headings. Neither is a completion defect there. + +Reproduced 2026-07-23 across rulesets' own specs: 100 findings over 7 of the 9 files in =docs/specs/= (docs-lifecycle 27, sentry-workflow 25, autonomous-batch 11, wrapup-routing 11, inbox-consolidation 10, agent-kb 8, encourage-kb 8; the two 2026-07-20 specs are clean). Every project carrying =docs/specs/= inherits the same noise on every sweep. Reported independently by takuzu's first sentry dogfood run. + +Grading: Minor severity (judgment-kind output, so nothing mutates; the cost is noise that trains the reader to skim) x every user, every time (fires on every sweep in every project with specs) = P2... graded down to [#C] because the two inputs disagree: the frequency row is genuinely universal, but Minor severity with zero mutation risk and a known cause reads as P3. Recorded so the read can be argued. + +Fix direction: scope the todo-format-family checkers away from =docs/specs/= by path. Correction (2026-07-23 speedrun): the earlier note that these are org-lint's own checkers was wrong — =lo--check-level2-dated-headers= (line 413) and =lo--check-level2-done-without-closed= (line 507) are lint-org.el's own functions, so the fix is a direct local guard, not upstream filtering. + +Decision (Craig, 2026-07-23 speedrun pre-flight): scope *all four* todo-format-family checkers, not just the two that fired — the two named plus =subtask-done-not-dated= and =dated-log-heading-active-timestamp=, which would misfire the same way on a spec's dated review-history headings. Path-based (any file under a =docs/specs/= segment), matching the docs-lifecycle canon that specs live there. Takuzu's spec-create alternative fixes new specs only and leaves the nine existing ones noisy, so path-scoping is the general fix. +** DONE [#C] notes.org template trips four lint-org flags in every project :bug:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-23 +:END: +Already satisfied. The canonical claude-templates/.ai/notes.org was fixed in 10ea44b (2026-07-23) when Craig approved the smoke proposal — the two column-0 bold lines rephrased, the example block's marker comma-escaped. Verified 2026-07-24: the template lints with zero mechanical and zero judgment findings, down from four. This TODO and the applied smoke VERIFY were duplicate work items for the same fix; closing DONE citing the commit rather than filing a no-op VERIFY, since the end-state is verifiable now with nothing to ask. +The synced =claude-templates/.ai/notes.org= carries two boilerplate lines that open with markdown-style bold at column 0 (=**Session history is NOT in this file.**=, =**For protocols and conventions, see:**=). Org parses a line-initial =**= as a level-2 heading, so =misplaced-heading= flags both, and the =#+begin_example= block in the Pending Decisions instructions trips =invalid-block= twice. Every project inherits all four on every sweep. + +Reproduced 2026-07-23. Confirms smoke's proposal. Additional finding from the same run: these two are =mechanical-fixed=, not judgment, so =lint-org --fix= silently rewrites the template's =**bold**= to org =*bold*=. The rewrite is correct org, but it lands on a synced template, so whichever project runs =--fix= first creates drift against canonical. + +Grading: Minor severity (cosmetic noise; the mechanical rewrite is correct org and harmless in isolation) x every user, every time (every project, every sweep) = P2... same disagreement as the checker task above; graded [#C] on the no-real-harm read. + +Fix direction: rephrase both lines in the canonical template so no line starts with =**= (a list dash, or move the bold off column 0), and comma-escape the example block's own markers. One canonical fix clears it everywhere. +** DONE [#B] cj-remove-block silently deletes content between two cj blocks :bug:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed 2026-07-24 (sentry fire 2, 17f5d48). The range validation now looks inside the range and refuses when a second fence appears before the end. Verified it doesn't over-tighten: indented blocks, interior blank lines, legacy single-line annotations, and prose mentioning a fence mid-line all still validate. Second defect fixed in the same commit: remove_range now backs up to /tmp (matching lint-org.el) and writes atomically through a temp sibling, utf-8 pinned. 6 new tests. +=looks_like_cj_range= validates only that the *first* line of the range opens a =#+begin_src cj:= fence and the *last* line closes with =#+end_src=. It never checks that the range holds exactly one block. A range spanning one block's opening fence to a *later* block's closing fence passes validation, and =remove_range= then deletes everything between — real prose, headings, whole tasks — silently, exit 0. + +That is precisely the failure the validation exists to prevent. Its docstring says it "protects against accidentally trimming the wrong block when line numbers drift between a cj-scan call and a remove call", and drift is the normal operating mode: =respond-to-cj-comments= edits the file as it processes each item, and a file being processed for cj comments generally holds several of them. + +Reproduced 2026-07-24 on a fixture with two cj blocks separated by real content. =looks_like_cj_range(lines, 2, 10)= returned =ok=True=, and the removal reduced a 10-line file to its first line. A heading and two content lines were destroyed with no warning and a zero exit. + +Grading: Major severity (silent destruction of real content in the file that holds Craig's tasks and notes, with no warning and a success exit; git recovers only to the last commit, so intra-session work is lost) x some users, sometimes (needs drift plus multiple cj blocks, which together are the skill's normal operating mode) = P2 = [#B]. + +Fix direction: =looks_like_cj_range= must confirm the range contains exactly one block — scan lines start..end and reject when any =#+end_src= appears before the final line, or when any second =#+begin_src cj:= appears after the first. Test with the two-block fixture above asserting the validation refuses. + +Second defect, same file, same fix session: =remove_range= writes the mutated org file with a bare =path.write_text=, which truncates the target on open, and it takes no backup first. A mid-write failure leaves Craig's =todo.org= truncated. =lint-org.el= — the other tool that mutates these files — copies a backup to =/tmp/<basename>.before-lint-pass.<timestamp>= before touching anything. cj-remove-block should match that: back up first, then write atomically via a temp file and =os.replace=, the same shape shipped for =inbox-send= in 0f91a8e. Pin =encoding="utf-8"= on the read and write while there. +** DONE [#D] route_recommend downgrades strong to weak on a duplicate basename :bug:quick:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed 2026-07-24 (sentry fire 2, 1b0f284). The dedupe went into recommend rather than discover_destination_names as the task proposed, so every caller of the pure core is protected, not just the CLI path. Identical names collapse; genuine ambiguity between two different projects still downgrades to weak, pinned by a test. 3 new tests. +=discover_destination_names= collapses discovered projects to bare basenames (=[p.name for p in ...]=). Two projects sharing a basename across roots (=~/code/notes= and =~/projects/notes=) therefore appear twice in the candidate list, both literal-match the same item, and =recommend= reads =len(strong) > 1= as an ambiguous tie — downgrading a correct strong match to weak. + +Reproduced 2026-07-24 by direct probe: =recommend("fix the notes thing", ["notes", "other"])= returns =('notes', 'strong')=, while the same call with =["notes", "notes", "other"]= returns =('notes', 'weak')=. + +Latent, not live: the current project set is 27 projects with 27 distinct basenames, so no collision exists today. The destination stays correct either way; only the confidence tier is wrong, which costs an unnecessary routing prompt rather than a misroute. + +Grading: Minor severity (right destination, wrong tier, cost is one extra prompt) x rare edge case (needs a basename collision across roots, which doesn't currently exist) = P4 = [#D]. + +Fix direction: dedupe names in =discover_destination_names= (=list(dict.fromkeys(names))=, order-preserving). A duplicate basename is one addressable name as far as routing goes, since =inbox-send='s =find_target= resolves a name to its first match anyway. Add a test with a duplicated candidate asserting the tier stays strong. +** DONE [#C] audit.bats has a flaky teardown that produces false suite reds :bug:test:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed 2026-07-24 (sentry fire 3, 7f45d4b). The fixture now sets =maintenance.auto false= and =gc.auto 0= before staging. + +Cause, traced not guessed: =git commit= spawns =git maintenance run --auto --quiet --detach= on git 2.55. The commit returns while that detached process is still writing a pack, and teardown's =rm -rf= races it. Every failed run left a =tmp_pack_*= behind, which is what pointed at it. + +*My original lead in this task was wrong* and worth recording as such. It blamed =gc.auto='s loose-object threshold; the object counts kill that outright (a fixture holds five objects against a default threshold of 6700). The right knob on modern git is =maintenance.auto=, and the mechanism is the =--detach=, not the threshold. Filing the theory as an explicitly-labelled lead rather than a finding is what kept it from being implemented as fact. + +Validation: 20 consecutive runs clean with no leftover fixture directories, against a baseline of roughly one failure in eight. Stated honestly, 20 clean runs alone would be about 7% likely by luck at that rate, so the statistics confirm rather than prove — the trace is the evidence. Disabled the background writer rather than retrying the delete, since a retry loop hides a live process instead of removing it. +=scripts/tests/audit.bats= test 4 ("tracked project with dirty .ai/ is skipped") intermittently fails in its =teardown=, not its assertions: =rm -rf "$TEST_HOME"= exits non-zero with =rm: cannot remove '/tmp/audit-bats.XXXX/code/alpha/.git/objects': Directory not empty=. The test body passes; only the cleanup fails, and bats reports the whole test as failed. + +Verified intermittent 2026-07-24: it failed twice during a sentry fire (once in a full =make test=, once running the file alone), then passed three consecutive runs immediately afterward with an identical tree. That intermittency is the defect — it makes =make test= return a false red. + +This matters more than a normal flaky test because the green suite is load-bearing for sentry: it gates entry, and the fire-end conditional run gates whether a night's commits are trusted. A spurious red there either blocks a fire or flags a clean night for morning review. + +Grading: Minor severity (a teardown-only failure, no production code implicated, and the assertions themselves pass) x some users, sometimes (fired twice in roughly six runs tonight) = P3 = [#C]. + +Lead, not a verified cause: =audit.bats= line 42 runs =git init -q= in each fixture project and sets no =gc.auto=, while =audit.sh= runs five git commands against them. Git can fork background maintenance (=gc --auto=) that keeps writing into =.git/objects= after the foreground command returns, which would race the teardown's =rm -rf=. That fits the symptom exactly but is untested. Confirm before fixing. + +Fix direction (once the cause is confirmed): set =git config gc.auto 0= (and =maintenance.auto false=) in the fixture setup so no background writer exists. Failing that, make teardown resilient rather than papering over it — a retry loop hides a real writer instead of removing it, so prefer killing the writer. +** DONE [#C] todo-cleanup rewrites todo.org with no backup, unlike its siblings :bug:solo: +CLOSED: [2026-07-24 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-24 +:END: +Fixed 2026-07-24 (sentry fire 4, 0686784). Copies to =/tmp/<basename>.before-todo-cleanup.<stamp>= before the first mutation, matching lint-org.el's convention, once per invocation and skipped under =--check=. 2 ERT tests. The two non-findings recorded in this task's original body stand: the archive move was already fail-safe, and missing-file behavior is unchanged (exit 255, nothing created) — both re-verified against the pre-change version. +=todo-cleanup.el= rewrites =todo.org= in place (hygiene fixes, =--convert-subtasks=, =--archive-done=, =--sync-child-priority=) and leaves no copy behind. The two sibling tools that mutate the same files both do: =lint-org.el= copies to =/tmp/<basename>.before-lint-pass.<stamp>= and =wrap-org-table.el= does the equivalent. =cj-remove-block= joined them in 17f5d48. todo-cleanup is the outlier, and it is the one that runs most often — wrap-up calls it, and every sentry fire's pass 4 calls it three times (four runs tonight alone). + +Verified 2026-07-24: after a real =--convert-subtasks= mutation on a fixture, the directory holds only =todo.org=. Emacs's own backup mechanism does not fire under =--batch -q=, so there is genuinely no undo short of git, which recovers only to the last commit and loses intra-session work. + +Two things this is NOT, both checked so they don't get re-investigated: + +- The archive move is *fail-safe*, not lossy. It deletes subtrees from the buffer, writes the archive file, and only saves =todo.org= at the very end, so an archive-write failure aborts before the save. Verified by making the archive directory unwritable: exit 255, =todo.org= byte-identical, content intact. My initial hypothesis that a mid-move failure could lose a subtree from both files was wrong. +- No error swallowing on the mutation path. The only =ignore-errors= in the file wrap =call-process "git"=, not any write. + +So this is a hardening gap rather than an active bug: a future defect in a mechanical rewriter would have no undo. + +Grading: Minor severity (no known active defect; the exposure is that any future one is unrecoverable within a session) x every user, every time (it runs on every wrap and every sentry fire) = P2 = [#C]. + +Fix direction: back up before the first mutation, matching =lint-org.el='s convention exactly — =/tmp/<basename>.before-todo-cleanup.<YYYYMMDD-HHMMSS>=. One copy per invocation, not per pass, and skip it under =--check= (which writes nothing). Test by asserting the backup exists and holds the pre-edit content after a real mutation. |
