aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests
diff options
context:
space:
mode:
Diffstat (limited to '.ai/scripts/tests')
-rw-r--r--.ai/scripts/tests/agent-lock.bats214
-rw-r--r--.ai/scripts/tests/flashcard-sync.bats25
-rw-r--r--.ai/scripts/tests/inbox-status.bats12
-rw-r--r--.ai/scripts/tests/test-lint-org.el281
-rw-r--r--.ai/scripts/tests/test-todo-cleanup.el243
-rw-r--r--.ai/scripts/tests/test_apkg_to_orgdrill.py301
-rw-r--r--.ai/scripts/tests/test_cj_remove_block.py167
-rw-r--r--.ai/scripts/tests/test_flashcard_stats.py25
-rw-r--r--.ai/scripts/tests/test_flashcard_to_anki.py61
-rw-r--r--.ai/scripts/tests/test_inbox_send.py114
-rw-r--r--.ai/scripts/tests/test_route_recommend.py28
11 files changed, 1462 insertions, 9 deletions
diff --git a/.ai/scripts/tests/agent-lock.bats b/.ai/scripts/tests/agent-lock.bats
new file mode 100644
index 0000000..dbcffe1
--- /dev/null
+++ b/.ai/scripts/tests/agent-lock.bats
@@ -0,0 +1,214 @@
+#!/usr/bin/env bats
+#
+# Tests for claude-templates/.ai/scripts/agent-lock — a mkdir-atomic advisory
+# lock helper for agent workflows (sentry's single-runner and roam-write
+# locks). flock can't span an agent's tool calls: every Bash call is its own
+# short-lived shell, so a flock dies with the call that took it. This helper
+# persists the lock on disk between calls and self-clears after a crash via
+# age-based staleness reclaim.
+#
+# Contract under test:
+# agent-lock acquire <name> [--ttl=SECONDS] [--wait[=SECONDS]]
+# exit 0 → acquired (fresh, or reclaimed from a stale prior holder).
+# exit 1 → busy: a live lock holds <name>; deferred (note on stderr).
+# exit 2 → usage error (bad/absent name, unknown subcommand).
+# agent-lock refresh <name> → re-touch a held lock (heartbeat); exit 1 if absent.
+# agent-lock release <name> → remove the lock; idempotent (exit 0 if already free).
+# agent-lock status <name> → print free|held|stale + metadata; exit 0 (query).
+# agent-lock path <name> → print the resolved lock dir path; does not create it.
+#
+# Staleness is age-based on the metadata file's mtime versus the lock's own
+# recorded TTL, so a crashed holder's lock expires instead of wedging every
+# later acquire. Heartbeat (refresh) re-touches the mtime, keeping a live
+# holder's lock young. Every reclaim surfaces a note (never silent).
+#
+# Lock home: /run/user/<uid>/agent-locks/<name>/ (tmpfs: host-local, out of
+# every repo, cleared on reboot), with ~/.cache/agent-locks/ as the fallback
+# where no runtime dir exists. AGENT_LOCK_DIR overrides the base for tests and
+# advanced callers; the helper otherwise owns the path scheme and callers pass
+# only names.
+#
+# Strategy: AGENT_LOCK_DIR points every lock at a temp base, so tests never
+# touch a real runtime dir. Staleness is exercised by aging the metadata
+# file's mtime with `touch` rather than sleeping.
+
+SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/agent-lock"
+BASH_BIN="$(command -v bash)"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t agent-lock-bats.XXXXXX)"
+ LOCK_BASE="$TEST_DIR/locks"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+lock() {
+ run env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" "$@"
+}
+
+# meta-file path for a lock name, for direct inspection / aging.
+meta_of() {
+ printf '%s/%s/meta\n' "$LOCK_BASE" "$1"
+}
+
+# ---- acquire: fresh win + metadata --------------------------------------
+
+@test "acquire: fresh name wins (exit 0) and writes pid/host/timestamp/ttl" {
+ lock acquire job
+ [ "$status" -eq 0 ]
+ local meta; meta="$(meta_of job)"
+ [ -f "$meta" ]
+ grep -q "^pid=$$\|^pid=[0-9][0-9]*$" "$meta"
+ grep -q "^host=$(uname -n)$" "$meta"
+ grep -qE "^acquired=[0-9]{4}-[0-9]{2}-[0-9]{2}T" "$meta"
+ grep -qE "^ttl=[0-9]+$" "$meta"
+}
+
+@test "acquire: honors an explicit --ttl in the metadata" {
+ lock acquire job --ttl=45
+ [ "$status" -eq 0 ]
+ grep -q "^ttl=45$" "$(meta_of job)"
+}
+
+# ---- acquire: contention (one winner) -----------------------------------
+
+@test "acquire: a second acquire of a live lock defers (exit 1, note)" {
+ lock acquire job
+ [ "$status" -eq 0 ]
+ lock acquire job
+ [ "$status" -eq 1 ]
+ [[ "$output" == *job* ]]
+}
+
+@test "acquire: two racing acquires yield exactly one winner" {
+ # Fire both without releasing; exactly one mkdir wins.
+ env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" acquire race & p1=$!
+ env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" acquire race & p2=$!
+ local r1=0 r2=0
+ wait $p1 || r1=$?
+ wait $p2 || r2=$?
+ # One exits 0 (won), one exits 1 (deferred).
+ [ "$((r1 + r2))" -eq 1 ]
+}
+
+# ---- release: frees the lock --------------------------------------------
+
+@test "release: frees a held lock so the next acquire wins" {
+ lock acquire job
+ [ "$status" -eq 0 ]
+ lock release job
+ [ "$status" -eq 0 ]
+ [ ! -d "$LOCK_BASE/job" ]
+ lock acquire job
+ [ "$status" -eq 0 ]
+}
+
+@test "release: is idempotent on an already-free lock (exit 0)" {
+ lock release never-held
+ [ "$status" -eq 0 ]
+}
+
+# ---- staleness reclaim (surfaced, never silent) -------------------------
+
+@test "acquire: reclaims a stale lock and surfaces the reclaim note" {
+ lock acquire job --ttl=1
+ [ "$status" -eq 0 ]
+ # Age the metadata mtime well past the 1s TTL.
+ touch -d '1 hour ago' "$(meta_of job)"
+ lock acquire job --ttl=1
+ [ "$status" -eq 0 ]
+ [[ "$output" == *reclaim* ]]
+ [[ "$output" == *job* ]]
+ # The reclaim installed fresh metadata (young again), not the aged holder's.
+ lock status job
+ [[ "$output" == *held* ]]
+ [[ "$output" != *stale* ]]
+}
+
+@test "acquire: a lock inside its TTL is not stale (stays deferred)" {
+ lock acquire job --ttl=3600
+ [ "$status" -eq 0 ]
+ lock acquire job --ttl=3600
+ [ "$status" -eq 1 ]
+}
+
+# ---- heartbeat (refresh keeps a live lock young) ------------------------
+
+@test "refresh: re-touches a held lock so it is no longer stale" {
+ lock acquire job --ttl=1
+ [ "$status" -eq 0 ]
+ touch -d '1 hour ago' "$(meta_of job)"
+ lock status job
+ [[ "$output" == *stale* ]]
+ lock refresh job
+ [ "$status" -eq 0 ]
+ lock status job
+ [[ "$output" == *held* ]]
+ [[ "$output" != *stale* ]]
+}
+
+@test "refresh: an absent lock cannot be refreshed (exit 1)" {
+ lock refresh nothing
+ [ "$status" -eq 1 ]
+}
+
+# ---- status query -------------------------------------------------------
+
+@test "status: reports free for an unheld lock (exit 0)" {
+ lock status job
+ [ "$status" -eq 0 ]
+ [[ "$output" == *free* ]]
+}
+
+@test "status: reports held with metadata for a live lock" {
+ lock acquire job --ttl=3600
+ lock status job
+ [ "$status" -eq 0 ]
+ [[ "$output" == *held* ]]
+ [[ "$output" == *"host=$(uname -n)"* ]]
+}
+
+# ---- path resolution: runtime dir home with cache fallback --------------
+
+@test "path: resolves under AGENT_LOCK_DIR when set" {
+ lock path job
+ [ "$status" -eq 0 ]
+ [ "$output" = "$LOCK_BASE/job" ]
+ [ ! -d "$LOCK_BASE/job" ] # path does not create the lock
+}
+
+@test "path: prefers the runtime dir home when no override is set" {
+ local rt="$TEST_DIR/run"
+ mkdir -p "$rt"
+ run env -u AGENT_LOCK_DIR XDG_RUNTIME_DIR="$rt" "$BASH_BIN" "$SCRIPT" path job
+ [ "$status" -eq 0 ]
+ [ "$output" = "$rt/agent-locks/job" ]
+}
+
+@test "path: falls back to the cache home when no runtime dir exists" {
+ local home="$TEST_DIR/home"
+ mkdir -p "$home"
+ run env -u AGENT_LOCK_DIR -u XDG_RUNTIME_DIR -u XDG_CACHE_HOME \
+ HOME="$home" "$BASH_BIN" "$SCRIPT" path job
+ [ "$status" -eq 0 ]
+ [ "$output" = "$home/.cache/agent-locks/job" ]
+}
+
+# ---- usage errors -------------------------------------------------------
+
+@test "usage: a missing name is a usage error (exit 2)" {
+ lock acquire
+ [ "$status" -eq 2 ]
+}
+
+@test "usage: a name with a slash is rejected (exit 2)" {
+ lock acquire bad/name
+ [ "$status" -eq 2 ]
+}
+
+@test "usage: an unknown subcommand is a usage error (exit 2)" {
+ lock frobnicate job
+ [ "$status" -eq 2 ]
+}
diff --git a/.ai/scripts/tests/flashcard-sync.bats b/.ai/scripts/tests/flashcard-sync.bats
index 608a280..e6ffc21 100644
--- a/.ai/scripts/tests/flashcard-sync.bats
+++ b/.ai/scripts/tests/flashcard-sync.bats
@@ -6,6 +6,7 @@
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
SYNC="$SCRIPT_DIR/flashcard-sync"
+ STATS="$SCRIPT_DIR/flashcard-stats.py"
TMP="$(mktemp -d)"
}
@@ -36,3 +37,27 @@ EOF
[ "$status" -eq 1 ]
[ ! -f "$HOME/sync/phone/anki/dirty.apkg" ]
}
+
+@test "flashcard-stats: a multi-tagged :fundamental:drill: card still counts" {
+ # Regression guard: a curated card carrying a second org tag must not drop
+ # from the count. A :drill:$ anchor would have counted only one card here.
+ cat > "$TMP/multitag.org" <<'EOF'
+#+TITLE: Multitag Test
+
+* Orbital Regimes
+** What is LEO? :fundamental:drill:
+:PROPERTIES:
+:ID: c1
+:END:
+Low Earth Orbit is the region below about 2000 kilometers.
+** What is GEO? :drill:
+:PROPERTIES:
+:ID: c2
+:END:
+Geostationary orbit sits at roughly 35786 kilometers of altitude.
+EOF
+ run python3 "$STATS" "$TMP/multitag.org"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Cards: 2"* ]]
+ [[ "$output" == *clean* ]]
+}
diff --git a/.ai/scripts/tests/inbox-status.bats b/.ai/scripts/tests/inbox-status.bats
index bc8a734..27a497e 100644
--- a/.ai/scripts/tests/inbox-status.bats
+++ b/.ai/scripts/tests/inbox-status.bats
@@ -45,6 +45,18 @@ teardown() {
[[ "$output" == *"0 pending"* ]]
}
+@test "inbox-status: ignores an in-flight .inbox-send-* temp file" {
+ mkdir "$TMP/inbox"
+ # inbox-send writes to a .inbox-send-* temp then renames it into place;
+ # during that window the temp must not read as a pending handoff, or a
+ # concurrent boundary check blocks on a file that's about to become real.
+ touch "$TMP/inbox/.inbox-send-abc123.org"
+ cd "$TMP"
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"0 pending"* ]]
+}
+
@test "inbox-status: -q suppresses the per-item lines" {
mkdir "$TMP/inbox"
echo body > "$TMP/inbox/handoff.org"
diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el
index 8e3e190..ceee209 100644
--- a/.ai/scripts/tests/test-lint-org.el
+++ b/.ai/scripts/tests/test-lint-org.el
@@ -193,6 +193,65 @@ real suspicious-language warning here
#+end_src
")
+;; invalid-block, false-positive case — a correctly paired example block whose
+;; body holds a heading-shaped line. org's parser reads the `** ' inside the
+;; verbatim body as a structural break, loses the open block, and flags BOTH
+;; delimiters as "Possible incomplete block".
+(defconst lo-test--verbatim-heading-block "\
+* Heading
+
+#+begin_example
+** Feature Name or Topic
+Body line.
+#+end_example
+
+Trailing prose.
+")
+
+;; invalid-block, literal-delimiter case — a paired src block whose body holds
+;; a literal `#+end_example' plus a heading-shaped line. Only `#+end_src'
+;; closes a src block, so all three findings here are false.
+(defconst lo-test--literal-end-in-src "\
+* Heading
+
+#+begin_src text
+#+end_example
+** heading shaped
+#+end_src
+")
+
+;; invalid-block, uppercase-delimiter case — org accepts #+BEGIN_/#+END_ in
+;; either case, and the pre-fix script flagged both delimiters here too.
+(defconst lo-test--uppercase-verbatim-block "\
+* Heading
+
+#+BEGIN_EXAMPLE
+** heading shaped
+#+END_EXAMPLE
+")
+
+;; invalid-block, genuine case — a block that really is never closed. The
+;; suppression must not reach this one.
+(defconst lo-test--unterminated-block "\
+* Heading
+
+#+begin_example
+truly unterminated block body
+")
+
+;; A genuinely unterminated block *after* a correctly paired one — verifies the
+;; suppression is scoped per block rather than per file.
+(defconst lo-test--paired-then-unterminated "\
+* Heading
+
+#+begin_example
+** heading shaped
+#+end_example
+
+#+begin_example
+never closed
+")
+
;; Mixed fixture — each category once.
(defconst lo-test--mixed "\
* Mixed
@@ -392,6 +451,55 @@ suspicious-language judgment."
(should (= 1 suspicious))))
;;; ---------------------------------------------------------------------------
+;;; invalid-block — false positives on correctly paired verbatim blocks
+
+(ert-deftest lo-verbatim-heading-block-emits-no-invalid-block ()
+ "Normal: a paired example block containing a heading-shaped body line emits
+no invalid-block judgment. Both delimiters are flagged by org-lint because the
+parser treats the `** ' inside the verbatim body as a structural break."
+ (let* ((out (lo-test--run lo-test--verbatim-heading-block))
+ (res (plist-get out :result))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ ;; File untouched, no fixes applied — suppression only, never a rewrite.
+ (should (equal lo-test--verbatim-heading-block res))
+ (should (= 0 (plist-get out :fixes)))
+ (should-not (member 'invalid-block (lo-test--checkers judgments)))))
+
+(ert-deftest lo-literal-end-delimiter-in-src-emits-no-invalid-block ()
+ "Boundary: a paired src block whose body holds a literal `#+end_example' and
+a heading-shaped line emits no invalid-block judgment. Only `#+end_src' closes
+a src block, so the interior delimiter is body text."
+ (let* ((out (lo-test--run lo-test--literal-end-in-src))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'invalid-block (lo-test--checkers judgments)))))
+
+(ert-deftest lo-uppercase-verbatim-block-emits-no-invalid-block ()
+ "Boundary: block delimiters are case-insensitive in org, so an uppercase
+`#+BEGIN_EXAMPLE' pair is suppressed the same as a lowercase one."
+ (let* ((out (lo-test--run lo-test--uppercase-verbatim-block))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'invalid-block (lo-test--checkers judgments)))))
+
+(ert-deftest lo-unterminated-block-still-emits-invalid-block ()
+ "Error: a block that is never closed still emits its invalid-block judgment.
+This is the finding the checker exists for — the suppression must not mask it."
+ (let* ((out (lo-test--run lo-test--unterminated-block))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (member 'invalid-block (lo-test--checkers judgments)))))
+
+(ert-deftest lo-invalid-block-suppression-is-scoped-per-block ()
+ "Boundary: a paired block and an unterminated block in the same file — the
+paired one is suppressed and the unterminated one still reports. Exactly one
+invalid-block judgment, and it points at the unterminated opener (line 7)."
+ (let* ((out (lo-test--run lo-test--paired-then-unterminated))
+ (judgments (lo-test--judgments (plist-get out :issues)))
+ (invalid (cl-remove-if-not
+ (lambda (i) (eq (plist-get i :checker) 'invalid-block))
+ judgments)))
+ (should (= 1 (length invalid)))
+ (should (= 7 (plist-get (car invalid) :line)))))
+
+;;; ---------------------------------------------------------------------------
;;; --check mode
(ert-deftest lo-check-mode-does-not-modify-file ()
@@ -739,6 +847,48 @@ missing-rules violation."
(judgments (lo-test--judgments (plist-get out :issues))))
(should-not (member 'subtask-done-not-dated (lo-test--checkers judgments)))))
+;;; dated-log-heading-active-timestamp check (stale SCHEDULED/DEADLINE on a
+;;; completed dated-log entry — the home 2026-07-17 agenda-pollution bug)
+
+(ert-deftest lo-dated-log-active-scheduled-is-flagged ()
+ "A dated-log entry still carrying an active SCHEDULED is flagged: org renders
+it on the agenda forever despite the missing keyword."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 trip booked\nSCHEDULED: <2026-06-18 Thu>\nBody.\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed
+ (should (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-active-deadline-is-flagged ()
+ "An active DEADLINE on a dated-log entry is flagged too."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 shipped\nDEADLINE: <2026-06-25 Thu>\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-clean-entry-not-flagged ()
+ "A dated-log entry with no active planning timestamp is correct — not flagged."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 done cleanly\nBody only.\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-inactive-timestamp-not-flagged ()
+ "An inactive [..] timestamp doesn't render on the agenda, so it isn't flagged —
+only active <..> planning timestamps are the defect."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 recorded\nSCHEDULED: [2026-06-18 Thu]\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-active-scheduled-on-live-todo-not-flagged ()
+ "A live TODO (keyword present) that legitimately carries an active SCHEDULED is
+not a dated-log heading, so this checker leaves it alone."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** TODO [#C] real upcoming task\nSCHEDULED: <2026-06-18 Thu>\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
;;; ---------------------------------------------------------------------------
;;; structural heading checks (org-lint gaps)
@@ -817,3 +967,134 @@ heading, so it is not flagged — only two-or-more indented stars are."
(provide 'test-lint-org)
;;; test-lint-org.el ends here
+
+;;; ---------------------------------------------------------------------------
+;;; task-missing-last-reviewed (claude-rules/todo-format.md)
+
+(ert-deftest lo-task-without-last-reviewed-is-judgment ()
+ "An open level-2 task with no :LAST_REVIEWED: is flagged."
+ (let* ((out (lo-test--run "* Open Work\n** TODO [#B] A task :feature:\nBody.\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-task-with-last-reviewed-is-clean ()
+ "A task carrying the property is not flagged."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n"
+ "Body.\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-task-last-reviewed-accepts-org-timestamp ()
+ "The org-native [YYYY-MM-DD Day] form counts, matching the staleness script."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: [2026-07-23 Thu]\n:END:\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-done-task-without-last-reviewed-is-clean ()
+ "Completed tasks leave the review pool, so they are never flagged."
+ (let* ((out (lo-test--run (concat "* Open Work\n** DONE [#B] A task :feature:\n"
+ "CLOSED: [2026-07-23 Thu]\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-subtask-without-last-reviewed-is-clean ()
+ "Only level-2 tasks are in the review pool; deeper headings are not."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] Parent :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n"
+ "*** TODO A sub-task\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-cookieless-task-without-last-reviewed-is-clean ()
+ "The staleness script selects on a priority cookie, so match that scope."
+ (let* ((out (lo-test--run "* Open Work\n** TODO Manual testing and validation\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-verify-task-without-last-reviewed-is-judgment ()
+ "VERIFY is in the review pool too."
+ (let* ((out (lo-test--run "* Open Work\n** VERIFY [#B] Waiting on Craig\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+;;; ---------------------------------------------------------------------------
+;;; todo-format checkers skip docs/specs/ files (claude-rules/todo-format.md)
+;;
+;; The four todo-format-family checkers encode todo.org completion conventions.
+;; A spec legitimately uses ** DONE <decision> with no CLOSED cookie and
+;; ** <dated> — <who> review-history headings, so those checkers misfire on
+;; every spec. They must skip any file under a docs/specs/ path segment.
+
+(defun lo-test--run-at (relpath content)
+ "Write CONTENT to <tmpdir>/RELPATH, run lint on it, return :issues.
+RELPATH is a relative path (may contain slashes) so a docs/specs/ segment
+can be exercised — the checkers key on the file's path, not just its name."
+ (let* ((root (make-temp-file "lo-test-root-" t))
+ (file (expand-file-name relpath root)))
+ (make-directory (file-name-directory file) t)
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert content))
+ (lo-test--reset)
+ (lo-process-file file)
+ (prog1 (list :issues lo-issues)
+ (lo-test--drop-buffer file)))
+ (delete-directory root t))))
+
+(defconst lo-test--spec-decisions
+ "* Decisions [1/1]\n** DONE Some decision\n- Context: x\n"
+ "A spec Decisions section: a level-2 DONE with no CLOSED cookie.")
+
+(defconst lo-test--spec-history
+ "* Review history\n** 2026-07-14 Tue @ 02:03:28 -0500 — Claude — responder\n- What: x\n"
+ "A spec review-history section: a level-2 dated header.")
+
+(ert-deftest lo-todo-checkers-fire-on-a-normal-org-file ()
+ "Baseline: the checkers DO fire on a non-spec path (the bug is scope, not silence)."
+ (let* ((out (lo-test--run-at "todo.org" lo-test--spec-decisions))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should (memq 'level2-done-without-closed cs))))
+
+(ert-deftest lo-level2-done-without-closed-skips-specs ()
+ (let* ((out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" lo-test--spec-decisions))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'level2-done-without-closed cs))))
+
+(ert-deftest lo-level2-dated-header-skips-specs ()
+ (let* ((out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" lo-test--spec-history))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'level-2-dated-header cs))))
+
+(ert-deftest lo-dated-log-active-timestamp-skips-specs ()
+ (let* ((c "* History\n** 2026-07-14 Tue @ 02:03:28 -0500 — did a thing\nSCHEDULED: <2026-07-20 Mon>\n")
+ (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'dated-log-heading-active-timestamp cs))))
+
+(ert-deftest lo-subtask-done-not-dated-skips-specs ()
+ (let* ((c "* Work\n** TODO Parent\n*** DONE A sub-decision\n")
+ (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'subtask-done-not-dated cs))))
+
+(ert-deftest lo-link-checks-still-fire-on-specs ()
+ "Only the todo-format family is scoped out; a broken link in a spec still flags."
+ (let* ((c "* X\n[[file:does-not-exist-xyz.org][link]]\n")
+ (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should (memq 'link-to-local-file cs))))
+
+(ert-deftest lo-task-missing-last-reviewed-skips-specs ()
+ "The fifth todo-format checker (added 2026-07-23) skips specs too — a spec's
+phases section may carry ** TODO [#x] items that aren't backlog tasks."
+ (let* ((c "* Implementation phases\n** TODO [#B] Phase one\nBody.\n")
+ (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'task-missing-last-reviewed cs)))
+ ;; And still fires on a normal file.
+ (let* ((c "* Work\n** TODO [#B] Real backlog task\nBody.\n")
+ (out (lo-test--run-at "todo.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should (memq 'task-missing-last-reviewed cs))))
diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el
index ffbf2fb..1e964b3 100644
--- a/.ai/scripts/tests/test-todo-cleanup.el
+++ b/.ai/scripts/tests/test-todo-cleanup.el
@@ -31,6 +31,7 @@
(defun tc-test--reset (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil
+ tc-sealed 0 tc-seal nil tc-convert-subtasks nil
tc-check-only (and check t)
tc-archive-done t tc-sync-child-priority nil
tc-current-file nil
@@ -40,6 +41,7 @@
(defun tc-test--reset-sync (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil
+ tc-sealed 0 tc-seal nil
tc-check-only (and check t)
tc-archive-done nil tc-sync-child-priority t
tc-current-file nil
@@ -514,6 +516,12 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat
.gitignore contents or nil), :archive-ignored (whether git ignores the archive),
:archive-exists."
(let* ((root (make-temp-file "tc-git-" t))
+ ;; Private backup dir: this helper writes a file literally named
+ ;; todo.org and runs a real (non-check) pass, so without this its
+ ;; backup lands in the shared temp dir under the exact production
+ ;; name and is indistinguishable from a real one.
+ (temporary-file-directory
+ (file-name-as-directory (make-temp-file "tc-git-bk-" t)))
(todo (expand-file-name "todo.org" root))
(archive (expand-file-name "archive/task-archive.org" root))
(gi (expand-file-name ".gitignore" root)))
@@ -534,7 +542,8 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat
:archive-ignored
(eq 0 (call-process "git" nil nil nil "check-ignore" "-q" archive))
:archive-exists (file-readable-p archive)))
- (delete-directory root t))))
+ (delete-directory root t)
+ (delete-directory temporary-file-directory t))))
(ert-deftest tc-age-self-protect-gitignores-archive-when-todo-ignored ()
"When the todo file is gitignored, the aged-out archive is added to .gitignore
@@ -578,6 +587,95 @@ entry is added for it."
(should (> (plist-get out :archived) 0)))))
;;; ---------------------------------------------------------------------------
+;;; --archive-done retention default
+
+(ert-deftest tc-archive-retain-default-is-one-month ()
+ "The shipped retention default is one month (31 days), not the legacy 7.
+The defvar initializes from this defconst; the live var itself is mutated by
+other tests, so the immutable defconst is the stable contract to pin."
+ (should (= 31 tc-archive-retain-days-default)))
+
+;;; ---------------------------------------------------------------------------
+;;; --seal: rename the working archive to resolved-YYYY-MM-DD.org
+
+(defun tc-test--seal (&optional opts)
+ "Run `--seal' against a temp todo file with a temp archive dir.
+OPTS is a plist: :archive-content (seed task-archive.org with this; nil = no
+working archive), :ref (YEAR MONTH DAY seal date; default (2026 7 18)),
+:check, :presealed (also create resolved-<ref>.org first, to test collision).
+Returns a plist: :sealed count, :issues, :working-exists, :sealed-exists,
+:sealed-name, :report."
+ (let* ((ref (or (plist-get opts :ref) '(2026 7 18)))
+ (check (plist-get opts :check))
+ (archive-content (plist-get opts :archive-content))
+ (todo (make-temp-file "tc-seal-todo-" nil ".org"))
+ (adir (make-temp-file "tc-seal-arch-" t))
+ (afile (expand-file-name "task-archive.org" adir))
+ (sealed-name (format "resolved-%04d-%02d-%02d.org"
+ (nth 0 ref) (nth 1 ref) (nth 2 ref)))
+ (sealed (expand-file-name sealed-name adir)))
+ (unwind-protect
+ (progn
+ (with-temp-file todo (insert "* Open Work\n** TODO [#A] live\n"))
+ (when archive-content (with-temp-file afile (insert archive-content)))
+ (when (plist-get opts :presealed)
+ (with-temp-file sealed (insert "pre-existing seal\n")))
+ (tc-test--reset check)
+ ;; Set every mode flag explicitly: tc-test--reset leaves
+ ;; tc-convert-subtasks untouched, so a convert test running earlier in
+ ;; the suite would otherwise still own the dispatch and run convert.
+ (setq tc-archive-done nil tc-sync-child-priority nil
+ tc-convert-subtasks nil tc-seal t tc-sealed 0
+ tc-archive-reference-date ref
+ tc-archive-file afile)
+ (let ((report (with-output-to-string (tc-process-file todo) (tc-emit-report))))
+ (tc-test--drop-buffer todo)
+ (list :sealed tc-sealed
+ :issues tc-issues
+ :working-exists (file-readable-p afile)
+ :sealed-exists (file-readable-p sealed)
+ :sealed-name sealed-name
+ :report report)))
+ (tc-test--drop-buffer todo)
+ (delete-file todo)
+ (delete-directory adir t))))
+
+(ert-deftest tc-seal-renames-working-archive-to-dated-file ()
+ "Normal: --seal renames task-archive.org to resolved-<seal-date>.org."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n** DONE old\n"
+ :ref (2026 7 18)))))
+ (should (= 1 (plist-get out :sealed)))
+ (should-not (plist-get out :working-exists))
+ (should (plist-get out :sealed-exists))
+ (should (equal "resolved-2026-07-18.org" (plist-get out :sealed-name)))
+ (should (tc-test--has (plist-get out :report) "sealed task-archive.org → resolved-2026-07-18.org"))))
+
+(ert-deftest tc-seal-nothing-to-seal-is-a-reported-noop ()
+ "Boundary: no working archive present — reported no-op, nothing created."
+ (let ((out (tc-test--seal '(:ref (2026 7 18)))))
+ (should (= 0 (plist-get out :sealed)))
+ (should-not (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "no working archive to seal"))))
+
+(ert-deftest tc-seal-check-mode-previews-without-renaming ()
+ "Boundary: --check reports the seal but leaves the working archive in place."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n"
+ :ref (2026 7 18) :check t))))
+ (should (= 1 (plist-get out :sealed)))
+ (should (plist-get out :working-exists))
+ (should-not (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "would seal"))))
+
+(ert-deftest tc-seal-refuses-to-clobber-existing-sealed-file ()
+ "Error: resolved-<today>.org already exists — refuse, leave both files intact."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n"
+ :ref (2026 7 18) :presealed t))))
+ (should (= 0 (plist-get out :sealed)))
+ (should (plist-get out :working-exists))
+ (should (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "already exists"))))
+
+;;; ---------------------------------------------------------------------------
;;; Sync-child-priority harness + fixtures
(defun tc-test--sync (content &optional runs check)
@@ -773,7 +871,7 @@ in ISSUES, in document order."
(defun tc-test--reset-convert (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-converted 0 tc-archived-to-file 0
- tc-issues nil
+ tc-issues nil tc-sealed 0 tc-seal nil
tc-check-only (and check t)
tc-archive-done nil tc-sync-child-priority nil tc-convert-subtasks t
tc-current-file nil
@@ -927,8 +1025,9 @@ CLOSED: [2026-06-27 Sat 12:50] DEADLINE: <2026-06-30 Tue>
Body line.
")
-(ert-deftest tc-convert-preserves-deadline-on-shared-planning-line-boundary ()
- "Boundary: removing the CLOSED cookie keeps a DEADLINE sharing its planning line."
+(ert-deftest tc-convert-strips-deadline-sharing-the-planning-line-boundary ()
+ "Boundary: a DEADLINE sharing the CLOSED planning line goes too — a dated-log
+entry carries no active planning timestamp (todo-format.md). Body survives."
(let* ((out (tc-test--convert tc-test--convert-closed-with-deadline))
(res (plist-get out :result)))
(should (= 1 (plist-get out :converted)))
@@ -936,8 +1035,142 @@ Body line.
"^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Ship the panel$"
res))
(should-not (string-match-p "CLOSED:" res))
- (should (string-match-p "^DEADLINE: <2026-06-30 Tue>$" res))
+ (should-not (string-match-p "DEADLINE:" res))
+ (should (string-match-p "^Body line\\.$" res))))
+
+(defconst tc-test--convert-closed-and-scheduled-separate-lines
+ "* Project Open Work
+** TODO [#B] Parent task
+*** DONE [#C] Book the venue :feature:
+CLOSED: [2026-06-27 Sat 12:50]
+SCHEDULED: <2026-06-20 Sat>
+Body line.
+")
+
+(ert-deftest tc-convert-strips-scheduled-on-its-own-line ()
+ "Normal (the home bug): a SCHEDULED planning line on its own — the completion
+rewrite dropped keyword/priority/tags but left the SCHEDULED, pinning the dated
+entry to the agenda as weeks-overdue. Both planning lines go; body survives."
+ (let* ((out (tc-test--convert tc-test--convert-closed-and-scheduled-separate-lines))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :converted)))
+ (should (string-match-p
+ "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Book the venue$"
+ res))
+ (should-not (string-match-p "CLOSED:" res))
+ (should-not (string-match-p "SCHEDULED:" res))
(should (string-match-p "^Body line\\.$" res))))
+(defconst tc-test--convert-scheduled-in-body-prose
+ "* Project Open Work
+** TODO [#B] Parent task
+*** DONE [#C] Note the mechanism :feature:
+CLOSED: [2026-06-27 Sat 12:50]
+An active SCHEDULED: <2026-06-20 Sat> in prose must survive.
+")
+
+(ert-deftest tc-convert-leaves-planning-shaped-body-prose-alone ()
+ "Boundary: a planning-shaped token inside body prose (not a canonical planning
+line) is left untouched — the strip stops at the first non-planning line."
+ (let* ((out (tc-test--convert tc-test--convert-scheduled-in-body-prose))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :converted)))
+ (should-not (string-match-p "CLOSED:" res))
+ (should (string-match-p "An active SCHEDULED: <2026-06-20 Sat> in prose must survive\\." res))))
+
(provide 'test-todo-cleanup)
;;; test-todo-cleanup.el ends here
+
+;;; ---------------------------------------------------------------------------
+;;; Backup before mutating (parity with lint-org.el / wrap-org-table.el)
+;;
+;; todo-cleanup rewrites todo.org in place and left no copy behind, while both
+;; sibling org-mutators back up to /tmp first. It is also the one that runs most
+;; often (every wrap, every sentry cycle). Emacs's own backup does not fire under
+;; --batch -q, so there was genuinely no undo short of git.
+
+(ert-deftest tc-backup-written-before-a-real-mutation ()
+ "A real (non-check) run leaves a copy holding the pre-edit content.
+
+`temporary-file-directory' is rebound to a private dir for the duration: the
+backup name derives from the *file's* basename, and the real todo.org shares
+that basename, so a live sentry run writing /tmp/todo.org.before-todo-cleanup.*
+would otherwise be indistinguishable from this test's own artifact. The first
+version of this test globbed the shared /tmp and passed only until a real run
+created one (2026-07-24)."
+ (let* ((dir (make-temp-file "tc-backup-" t))
+ (bdir (file-name-as-directory (make-temp-file "tc-bk-" t)))
+ (file (expand-file-name "todo.org" dir))
+ (before "* P Open Work\n** TODO [#B] parent\n*** DONE a subtask\nCLOSED: [2026-07-01 Tue]\n"))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert before))
+ (let ((tc-check-only nil)
+ (tc-convert-subtasks t)
+ (temporary-file-directory bdir))
+ (tc-process-file file))
+ (let ((backups (file-expand-wildcards
+ (concat bdir "todo.org.before-todo-cleanup.*"))))
+ (should backups)
+ (should (string-match-p
+ "a subtask"
+ (with-temp-buffer (insert-file-contents (car backups))
+ (buffer-string))))))
+ (delete-directory dir t)
+ (delete-directory bdir t))))
+
+(ert-deftest tc-no-backup-in-check-mode ()
+ "--check writes nothing, so it must not leave a backup either.
+Uses a private `temporary-file-directory' for the same isolation reason."
+ (let* ((dir (make-temp-file "tc-backup-" t))
+ (bdir (file-name-as-directory (make-temp-file "tc-bk-" t)))
+ (file (expand-file-name "todo.org" dir)))
+ (unwind-protect
+ (progn
+ (with-temp-file file
+ (insert "* P Open Work\n** TODO [#B] parent\n*** DONE sub\nCLOSED: [2026-07-01 Tue]\n"))
+ (let ((tc-check-only t)
+ (tc-convert-subtasks t)
+ (temporary-file-directory bdir))
+ (tc-process-file file))
+ (should-not (file-expand-wildcards
+ (concat bdir "todo.org.before-todo-cleanup.*"))))
+ (delete-directory dir t)
+ (delete-directory bdir t))))
+
+(ert-deftest tc-backup-never-overwrites-an-earlier-one ()
+ "Two invocations in the same second must not collapse to one backup.
+
+open-tasks.org runs --convert-subtasks then --archive-done back to back, each
+a sub-second batch run. With a second-resolution stamp and copy-file's
+OK-IF-ALREADY-EXISTS, the second invocation overwrote the first's backup with
+already-mutated content, so the true pre-session original was unrecoverable —
+the exact state the backup exists to preserve (found 2026-07-24 in review)."
+ (let* ((dir (make-temp-file "tc-collide-" t))
+ (bdir (file-name-as-directory (make-temp-file "tc-cbk-" t)))
+ (file (expand-file-name "todo.org" dir))
+ (original (concat "* P Open Work\n** TODO [#B] parent\n*** DONE sub\n"
+ "CLOSED: [2026-07-01 Tue]\n"
+ "* P Resolved\n** DONE [#C] old\nCLOSED: [2025-01-01 Wed]\n")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert original))
+ ;; Two back-to-back invocations, as the shipped workflow does.
+ (let ((temporary-file-directory bdir))
+ (let ((tc-check-only nil) (tc-convert-subtasks t))
+ (tc-process-file file))
+ (let ((tc-check-only nil) (tc-convert-subtasks nil) (tc-archive-done t)
+ (tc-archive-retain-days nil))
+ (tc-process-file file)))
+ (let ((backups (file-expand-wildcards
+ (concat bdir "todo.org.before-todo-cleanup.*"))))
+ ;; Both invocations kept their own backup.
+ (should (= (length backups) 2))
+ ;; And one of them still holds the true original.
+ (should (cl-some (lambda (b)
+ (string= original
+ (with-temp-buffer (insert-file-contents b)
+ (buffer-string))))
+ backups))))
+ (delete-directory dir t)
+ (delete-directory bdir t))))
diff --git a/.ai/scripts/tests/test_apkg_to_orgdrill.py b/.ai/scripts/tests/test_apkg_to_orgdrill.py
new file mode 100644
index 0000000..6a95ea4
--- /dev/null
+++ b/.ai/scripts/tests/test_apkg_to_orgdrill.py
@@ -0,0 +1,301 @@
+"""Tests for apkg-to-orgdrill.py — the inverse of flashcard-to-anki.py.
+
+The converter reads an Anki .apkg (a zip holding collection.anki2 / .anki21
+sqlite) and emits an org-drill .org in the house canonical shape. It is
+stdlib-only (zipfile + sqlite3), so it imports directly — no genanki stub.
+
+The apkg schema these tests build by hand mirrors what genanki actually
+writes, confirmed against a real apkg generated from flashcard-to-anki.py:
+ - col.decks : JSON {did: {"name": ...}}, always including id-1 "Default"
+ - col.models : JSON {mid: {"name": ..., "flds": [{"name": "Front"}, ...]}}
+ - notes.flds : fields joined by \x1f; tags space-padded (" tag ")
+ - cards : nid -> did (the Default deck carries no cards)
+
+The round-trip test closes the loop through flashcard-to-anki.py's own
+parse(): original org -> forward parse tuples -> apkg fixture -> converter
+-> recovered org -> forward parse -> assert the (front, back, tag) tuples
+match. Only the apkg materialization is hand-built (the genanki boundary);
+everything else is the real code on both sides.
+"""
+from __future__ import annotations
+
+import importlib.util
+import json
+import sqlite3
+import sys
+import types
+import zipfile
+from pathlib import Path
+
+import pytest
+
+SCRIPTS = Path(__file__).resolve().parents[1]
+CONVERTER = SCRIPTS / "apkg-to-orgdrill.py"
+FORWARD = SCRIPTS / "flashcard-to-anki.py"
+
+
+def _load(path: Path, name: str, stub_genanki: bool = False):
+ if stub_genanki:
+ sys.modules.setdefault("genanki", types.ModuleType("genanki"))
+ spec = importlib.util.spec_from_file_location(name, path)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ # Register before exec: @dataclass resolves cls.__module__ via sys.modules
+ # (Python 3.14), which is None for an unregistered importlib module.
+ sys.modules[name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+@pytest.fixture(scope="module")
+def conv():
+ return _load(CONVERTER, "apkg_to_orgdrill")
+
+
+@pytest.fixture(scope="module")
+def forward():
+ return _load(FORWARD, "flashcard_to_anki", stub_genanki=True)
+
+
+# --- fixture builder: write a genanki-shaped apkg by hand ------------------
+
+def _make_apkg(
+ path: Path,
+ decks: dict[int, str],
+ models: dict[int, list[str]],
+ notes: list[tuple[int, int, list[str], str]], # (nid, mid, fields, tag)
+ cards: list[tuple[int, int]], # (nid, did)
+ *,
+ media: str = "{}",
+) -> None:
+ """Materialize a minimal apkg matching genanki's collection.anki2 shape."""
+ col_dir = path.parent / f"{path.stem}-build"
+ col_dir.mkdir(parents=True, exist_ok=True)
+ db = col_dir / "collection.anki2"
+ if db.exists():
+ db.unlink()
+ con = sqlite3.connect(db)
+ con.execute("CREATE TABLE col (id INTEGER, decks TEXT, models TEXT)")
+ decks_json = {"1": {"name": "Default"}}
+ decks_json.update({str(did): {"name": name} for did, name in decks.items()})
+ models_json = {
+ str(mid): {"name": f"{decks.get(list(decks)[0], 'M')} model",
+ "flds": [{"name": n, "ord": i} for i, n in enumerate(flds)]}
+ for mid, flds in models.items()
+ }
+ con.execute("INSERT INTO col (id, decks, models) VALUES (1, ?, ?)",
+ (json.dumps(decks_json), json.dumps(models_json)))
+ con.execute("CREATE TABLE notes (id INTEGER, mid INTEGER, flds TEXT, tags TEXT)")
+ for nid, mid, fields, tag in notes:
+ con.execute("INSERT INTO notes (id, mid, flds, tags) VALUES (?, ?, ?, ?)",
+ (nid, mid, "\x1f".join(fields), f" {tag} " if tag else " "))
+ con.execute("CREATE TABLE cards (id INTEGER, nid INTEGER, did INTEGER)")
+ for i, (nid, did) in enumerate(cards):
+ con.execute("INSERT INTO cards (id, nid, did) VALUES (?, ?, ?)", (1000 + i, nid, did))
+ con.commit()
+ con.close()
+ with zipfile.ZipFile(path, "w") as z:
+ z.write(db, "collection.anki2")
+ z.writestr("media", media)
+
+
+# --- html_to_org_body ------------------------------------------------------
+
+def test_html_to_org_splits_br_into_lines(conv):
+ assert conv.html_to_org_body("one<br>two<br>three") == ["one", "two", "three"]
+
+
+def test_html_to_org_handles_br_variants(conv):
+ assert conv.html_to_org_body("a<br/>b<br />c<BR>d") == ["a", "b", "c", "d"]
+
+
+def test_html_to_org_unescapes_entities_amp_last(conv):
+ # Inverts escape_html (which escapes & first): &lt; &gt; &amp; -> < > &.
+ assert conv.html_to_org_body("x &lt;tag&gt; &amp; y") == ["x <tag> & y"]
+
+
+def test_html_to_org_preserves_a_literal_escaped_entity(conv):
+ # Forward-escaping the literal "&lt;" yields "&amp;lt;"; the inverse must
+ # recover "&lt;", not "<".
+ assert conv.html_to_org_body("&amp;lt;") == ["&lt;"]
+
+
+def test_html_to_org_strips_answer_hr(conv):
+ assert conv.html_to_org_body('front<hr id="answer">back') == ["front", "back"]
+
+
+def test_html_to_org_empty_back_is_empty(conv):
+ assert conv.html_to_org_body("") == []
+
+
+# --- read_apkg -------------------------------------------------------------
+
+def test_read_apkg_single_deck_recovers_front_back_tag_deck(conv, tmp_path):
+ apkg = tmp_path / "d.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "My Deck"},
+ models={9: ["Front", "Back"]},
+ notes=[(100, 9, ["Q1?", "A1.<br>line2"], "sec-one")],
+ cards=[(100, 20)],
+ )
+ recovered = conv.read_apkg(apkg)
+ assert len(recovered) == 1
+ note = recovered[0]
+ assert note.deck == "My Deck"
+ assert note.front == "Q1?"
+ assert note.back_html == "A1.<br>line2"
+ assert note.tag == "sec-one"
+
+
+def test_read_apkg_multiple_decks_grouped(conv, tmp_path):
+ apkg = tmp_path / "multi.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "Deck A", 21: "Deck B"},
+ models={9: ["Front", "Back"]},
+ notes=[(100, 9, ["QA?", "AA"], "ta"), (101, 9, ["QB?", "AB"], "tb")],
+ cards=[(100, 20), (101, 21)],
+ )
+ decks = {n.deck for n in conv.read_apkg(apkg)}
+ assert decks == {"Deck A", "Deck B"}
+
+
+def test_read_apkg_skips_default_deck_without_cards(conv, tmp_path):
+ apkg = tmp_path / "def.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "Real Deck"},
+ models={9: ["Front", "Back"]},
+ notes=[(100, 9, ["Q?", "A"], "t")],
+ cards=[(100, 20)],
+ )
+ assert {n.deck for n in conv.read_apkg(apkg)} == {"Real Deck"}
+
+
+def test_read_apkg_warns_and_skips_non_basic_model(conv, tmp_path, capsys):
+ apkg = tmp_path / "cloze.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "Cloze Deck"},
+ models={9: ["Text", "Extra"]}, # not Front/Back
+ notes=[(100, 9, ["some {{c1::text}}", "extra"], "t")],
+ cards=[(100, 20)],
+ )
+ recovered = conv.read_apkg(apkg)
+ assert recovered == []
+ assert "skip" in capsys.readouterr().err.lower()
+
+
+def test_read_apkg_reads_anki21_collection_name(conv, tmp_path):
+ # A .anki21 collection filename must be read the same as .anki2.
+ apkg = tmp_path / "new.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "Deck"},
+ models={9: ["Front", "Back"]},
+ notes=[(100, 9, ["Q?", "A"], "t")],
+ cards=[(100, 20)],
+ )
+ # Rewrite the zip renaming the collection member to .anki21.
+ with zipfile.ZipFile(apkg) as z:
+ data = z.read("collection.anki2")
+ media = z.read("media")
+ with zipfile.ZipFile(apkg, "w") as z:
+ z.writestr("collection.anki21", data)
+ z.writestr("media", media)
+ assert conv.read_apkg(apkg)[0].front == "Q?"
+
+
+def test_read_apkg_flags_media_reference(conv, tmp_path, capsys):
+ apkg = tmp_path / "media.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "Deck"},
+ models={9: ["Front", "Back"]},
+ notes=[(100, 9, ["Q?", 'see <img src="x.png">'], "t")],
+ cards=[(100, 20)],
+ )
+ conv.read_apkg(apkg)
+ assert "media" in capsys.readouterr().err.lower()
+
+
+# --- notes_to_org ----------------------------------------------------------
+
+def test_notes_to_org_emits_canonical_shape(conv):
+ Note = conv.Note
+ notes = [
+ Note(deck="My Deck", front="Q1?", back_html="A1.", tag="alpha"),
+ Note(deck="My Deck", front="Q2?", back_html="A2.", tag="alpha"),
+ ]
+ ids = iter(["id-1", "id-2"])
+ org = conv.notes_to_org(notes, "My Deck", new_id=lambda: next(ids))
+ assert "#+TITLE: My Deck" in org
+ assert "* alpha" in org
+ assert "** Q1? :drill:" in org
+ assert ":ID: id-1" in org
+ assert ":ID: id-2" in org
+ assert org.count("* alpha") == 1 # both cards share one section
+
+
+def test_notes_to_org_distinct_tags_get_distinct_sections(conv):
+ Note = conv.Note
+ notes = [
+ Note(deck="D", front="Qa?", back_html="a", tag="alpha"),
+ Note(deck="D", front="Qb?", back_html="b", tag="beta"),
+ ]
+ org = conv.notes_to_org(notes, "D", new_id=lambda: "x")
+ assert "* alpha" in org and "* beta" in org
+
+
+# --- round-trip through the real forward parse() ---------------------------
+
+def test_round_trip_matches_forward_parse_tuples(conv, forward, tmp_path):
+ original = (
+ "#+TITLE: RT Deck\n"
+ "\n"
+ "* First Section\n"
+ "** What is 2+2? :drill:\n"
+ ":PROPERTIES:\n:ID: aaaa\n:END:\n"
+ "Four.\n"
+ "Second line with <angle> & amp.\n"
+ "\n"
+ "* Second Section\n"
+ "** Capital of France? :drill:\n"
+ "Paris.\n"
+ )
+ tuples = forward.parse(original) # [(front, back_html, anki_tags), ...]
+ assert len(tuples) == 2
+
+ apkg = tmp_path / "rt.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "RT Deck"},
+ models={9: ["Front", "Back"]},
+ # anki_tags is a list; the apkg tags field is space-joined.
+ notes=[(100 + i, 9, [f, b], " ".join(tags))
+ for i, (f, b, tags) in enumerate(tuples)],
+ cards=[(100 + i, 20) for i in range(len(tuples))],
+ )
+
+ by_deck = conv.convert(apkg)
+ assert set(by_deck) == {"RT Deck"}
+ recovered_tuples = forward.parse(by_deck["RT Deck"])
+ assert recovered_tuples == tuples
+
+
+# --- errors ----------------------------------------------------------------
+
+def test_read_apkg_missing_collection_errors(conv, tmp_path):
+ bad = tmp_path / "bad.apkg"
+ with zipfile.ZipFile(bad, "w") as z:
+ z.writestr("media", "{}")
+ with pytest.raises(Exception):
+ conv.read_apkg(bad)
+
+
+def test_read_apkg_not_a_zip_errors(conv, tmp_path):
+ notzip = tmp_path / "plain.apkg"
+ notzip.write_text("not a zip")
+ with pytest.raises(Exception):
+ conv.read_apkg(notzip)
diff --git a/.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_flashcard_stats.py b/.ai/scripts/tests/test_flashcard_stats.py
index 606f7c1..46deccc 100644
--- a/.ai/scripts/tests/test_flashcard_stats.py
+++ b/.ai/scripts/tests/test_flashcard_stats.py
@@ -217,6 +217,31 @@ def test_parse_cards_captures_body_without_drawer_planning_or_answer_header(stat
assert c["body"] == "the real answer"
+def test_parse_cards_counts_a_multitag_heading_as_a_card(stats):
+ """A card multi-tagged :fundamental:drill: still counts; the front is clean."""
+ text = "* Sec\n** Q multi? :fundamental:drill:\nthe answer\n"
+ cards, _ = stats.parse_cards(text.splitlines())
+ assert len(cards) == 1
+ assert cards[0]["heading"] == "Q multi?"
+ assert cards[0]["body"] == "the answer"
+
+
+def test_parse_cards_ignores_a_tagged_heading_without_drill(stats):
+ """A tagged heading missing :drill: is not a drill card."""
+ text = "* Sec\n** Just a note :note:\nbody\n"
+ cards, _ = stats.parse_cards(text.splitlines())
+ assert cards == []
+
+
+def test_parse_cards_body_stops_at_next_multitag_card(stats):
+ """The body scan ends at the next L2 card even when it is multi-tagged."""
+ text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n"
+ cards, _ = stats.parse_cards(text.splitlines())
+ assert len(cards) == 2
+ assert cards[0]["body"] == "body1"
+ assert cards[1]["body"] == "body2"
+
+
def test_find_duplicate_fronts_matches_normalized_headings(stats):
cards = [
{"heading": "What is LEO?"},
diff --git a/.ai/scripts/tests/test_flashcard_to_anki.py b/.ai/scripts/tests/test_flashcard_to_anki.py
index 87008a8..fa38b64 100644
--- a/.ai/scripts/tests/test_flashcard_to_anki.py
+++ b/.ai/scripts/tests/test_flashcard_to_anki.py
@@ -158,17 +158,18 @@ Geostationary Earth Orbit.
def test_parse_returns_front_back_tag_per_card(drill):
cards = drill.parse(SECTIONED)
assert len(cards) == 2
- assert cards[0] == ("What is LEO?", "Low Earth Orbit.", "orbital-regimes")
+ # The section becomes the sole Anki tag (as a one-element list).
+ assert cards[0] == ("What is LEO?", "Low Earth Orbit.", ["orbital-regimes"])
assert cards[1][0] == "What is GEO?"
def test_parse_card_without_a_section_gets_the_drill_tag(drill):
- assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", "drill")]
+ assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", ["drill"])]
def test_parse_strips_properties_drawer_from_back(drill):
text = "** Q? :drill:\n:PROPERTIES:\n:ID: abc\n:END:\nThe answer.\n"
- assert drill.parse(text) == [("Q?", "The answer.", "drill")]
+ assert drill.parse(text) == [("Q?", "The answer.", ["drill"])]
def test_parse_trims_leading_and_trailing_blank_body_lines(drill):
@@ -178,7 +179,59 @@ def test_parse_trims_leading_and_trailing_blank_body_lines(drill):
def test_parse_card_with_only_a_drawer_has_empty_back(drill):
text = "** Q? :drill:\n:PROPERTIES:\n:ID: x\n:END:\n"
- assert drill.parse(text) == [("Q?", "", "drill")]
+ assert drill.parse(text) == [("Q?", "", ["drill"])]
+
+
+# --- multi-tag headings, --tag-filter, --guid-salt -------------------------
+
+MULTITAG = """* Fundamentals
+** What is LEO? :fundamental:drill:
+Low Earth Orbit.
+** What is GEO? :drill:
+Geostationary Earth Orbit.
+"""
+
+
+def test_parse_multitag_heading_is_a_card_when_drill_is_present(drill):
+ """A heading with a second org tag still parses when drill is among them."""
+ cards = drill.parse(MULTITAG)
+ assert len(cards) == 2
+ assert cards[0][0] == "What is LEO?"
+
+
+def test_parse_multitag_tags_ride_along_next_to_the_section_tag(drill):
+ """Non-drill org tags become Anki tags alongside the section tag."""
+ cards = drill.parse(MULTITAG)
+ assert cards[0][2] == ["fundamentals", "fundamental"] # section slug + org tag
+ assert cards[1][2] == ["fundamentals"] # drill-only -> section only
+
+
+def test_parse_heading_without_drill_tag_is_not_a_card(drill):
+ """A tagged heading missing :drill: is not a card (e.g. :note:)."""
+ assert drill.parse("* S\n** Just a note :note:\nbody\n") == []
+
+
+def test_parse_tag_filter_returns_only_cards_with_that_org_tag(drill):
+ """--tag-filter narrows to cards carrying the given org tag."""
+ cards = drill.parse(MULTITAG, tag_filter="fundamental")
+ assert len(cards) == 1
+ assert cards[0][0] == "What is LEO?"
+
+
+def test_parse_body_bounded_by_any_l1_or_l2_heading(drill):
+ """A card body stops at the next L1/L2 heading, multi-tagged or not."""
+ text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n"
+ cards = drill.parse(text)
+ assert cards[0][1] == "body1"
+ assert cards[1][1] == "body2"
+
+
+def test_card_guid_salt_changes_the_guid(drill, monkeypatch):
+ """--guid-salt gives a subset deck its own GUID space; no salt is unchanged."""
+ monkeypatch.setattr(drill.genanki, "guid_for", lambda *a: ":".join(a), raising=False)
+ assert drill.card_guid("front", None) == "front"
+ assert drill.card_guid("front", "fundamentals") == "fundamentals:front"
+ assert drill.card_guid("front", None) != drill.card_guid("front", "fundamentals")
def test_parse_joins_multiline_body_with_br(drill):
diff --git a/.ai/scripts/tests/test_inbox_send.py b/.ai/scripts/tests/test_inbox_send.py
index f75d7a1..9b0a8c6 100644
--- a/.ai/scripts/tests/test_inbox_send.py
+++ b/.ai/scripts/tests/test_inbox_send.py
@@ -476,3 +476,117 @@ class TestFilenameCollisions:
assert len(files) == 2
bodies = "".join(f.read_text() for f in files)
assert "message one" in bodies and "message two" in bodies
+
+
+class TestAtomicWrite:
+ """A send wrote straight to the destination path in another project's
+ inbox/, and write_text truncates on open, so any mid-write failure left a
+ zero-byte .org there. inbox-status counts that phantom as a pending
+ handoff, blocking a turn in the receiving project over a file with no
+ content (2026-07-23). The write must be atomic: the inbox sees a complete
+ file or nothing."""
+
+ def test_send_text_writes_utf8(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ # An em dash and an accented char — both non-ASCII.
+ dest = mod.send_text(inbox, "accent café and dash — here", "src", None, now)
+ # Reading as utf-8 must round-trip; a locale-encoded write would raise
+ # under a C locale, and reading back proves the bytes are utf-8.
+ assert "—" in dest.read_text(encoding="utf-8")
+
+ def test_send_text_no_partial_on_write_failure(self, tmp_path, monkeypatch):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ # Force the atomic finalize to fail after the temp file is written.
+ def boom(*a, **k):
+ raise OSError("disk full")
+ monkeypatch.setattr(mod.os, "replace", boom)
+ with pytest.raises(OSError):
+ mod.send_text(inbox, "a message that should never half-land", "src", None, now)
+ # No phantom, no leftover temp: the inbox is empty.
+ assert list(inbox.iterdir()) == []
+
+ def test_send_text_leaves_no_temp_on_success(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ dest = mod.send_text(inbox, "clean send", "src", None, now)
+ assert list(inbox.iterdir()) == [dest]
+
+ def test_send_file_no_partial_on_write_failure(self, tmp_path, monkeypatch):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ src = tmp_path / "note.org"
+ src.write_text("body")
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ def boom(*a, **k):
+ raise OSError("disk full")
+ monkeypatch.setattr(mod.os, "replace", boom)
+ with pytest.raises(OSError):
+ mod.send_file(inbox, src, "src", None, now)
+ assert list(inbox.iterdir()) == []
+
+ def test_send_file_leaves_no_temp_on_success(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ src = tmp_path / "note.org"
+ src.write_text("payload")
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ dest = mod.send_file(inbox, src, "src", None, now)
+ assert list(inbox.iterdir()) == [dest]
+ assert dest.read_text() == "payload"
+
+
+class TestSmallerDefects:
+ """Two low-severity defects found reading inbox-send during the 2026-07-23
+ sweep: an unreadable source raised an uncaught traceback instead of the
+ clean error every other failure path produces, and a roots config naming
+ both a parent and one of its children listed the same project twice."""
+
+ def test_unreadable_source_gives_clean_error_not_traceback(
+ self, project_root, run_script, tmp_path
+ ):
+ project_root("sender")
+ project_root("receiver")
+ roots = [tmp_path / "projects"]
+ src = tmp_path / "secret.bin"
+ src.write_text("x")
+ src.chmod(0o000)
+ try:
+ result = run_script(
+ ["receiver", "--file", str(src)],
+ cwd=tmp_path / "projects" / "sender",
+ roots=roots,
+ expect_failure=True,
+ )
+ finally:
+ src.chmod(0o644)
+ assert result.returncode == 1
+ # The clean "inbox-send: <message>" shape, not a Python traceback.
+ assert result.stderr.startswith("inbox-send:")
+ assert "Traceback" not in result.stderr
+
+ def test_discover_projects_dedupes_parent_and_child_root(self, tmp_path):
+ mod = _load_module()
+ # A project directory, reachable both as a child of its parent root and
+ # as a root in its own right.
+ parent = tmp_path / "projects"
+ proj = parent / "app"
+ (proj / ".ai").mkdir(parents=True)
+ (proj / "inbox").mkdir()
+ found = mod.discover_projects([parent, proj])
+ resolved = [p.resolve() for p in found]
+ assert resolved.count(proj.resolve()) == 1
diff --git a/.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"