aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-31 12:19:34 -0500
committerCraig Jennings <c@cjennings.net>2026-05-31 12:19:34 -0500
commitddf48dc7ac780da1aacdff4e03f1d7da255b8f39 (patch)
tree99926b681a9ea6d4210d0dcd1bd8e8a6d47d7d9e
parentb46619cd17ed4e36f2e59c1b600078521b2049ef (diff)
downloadrulesets-ddf48dc7ac780da1aacdff4e03f1d7da255b8f39.tar.gz
rulesets-ddf48dc7ac780da1aacdff4e03f1d7da255b8f39.zip
feat: add rename-ai-artifact tool and rename the drill-deck family to flashcard
Renaming an .ai artifact by hand is the kind of mechanical job that gets done incompletely: the canonical copy moves but the mirror doesn't, a reference in the INDEX is missed, a trigger phrase points at the old name. I'd also assumed a rename was costly because references scatter, when the index update is trivial and the drift check already guards it. So I built the discipline into a script instead of re-deriving it each time. scripts/rename-ai-artifact.sh takes old and new basenames, moves the file in both the canonical and mirror trees, and rewrites every reference repo-wide on a token boundary so renaming "foo" can't corrupt "foobar" or "foo-bar". It rewrites the underscore module-name variant too (a hyphenated script imported as foo_bar via importlib), leaves the archived session records under sessions/ alone because they're history, and runs workflow-integrity + sync-check at the end to prove no drift. rename-artifact.org documents it and indexes the triggers. Then I used the tool to do the rename that prompted it: the org-drill deck workflow and its helpers are now flashcard-named, since "flashcard" is the word you'd actually search for. The renamed set is flashcard-review.org plus flashcard-stats.py, flashcard-sync, flashcard-to-anki.py, and flashcard-diff-ids.py, with their tests, every reference, and the INDEX entry updated. The deck is still an org-drill deck under the hood, so the ":drill:" tag handling and the "drill deck" trigger phrases stay. I added "review/update the flashcards" alongside them. Tests: 9 bats for the rename tool (including the prefix-collision and history-preservation edges), and the renamed script suites all pass under make test.
-rwxr-xr-x.ai/scripts/flashcard-diff-ids.py (renamed from .ai/scripts/drill-deck-diff-ids.py)4
-rwxr-xr-x.ai/scripts/flashcard-stats.py (renamed from .ai/scripts/drill-deck-stats.py)8
-rwxr-xr-x.ai/scripts/flashcard-sync (renamed from claude-templates/.ai/scripts/drill-deck-sync)26
-rwxr-xr-x.ai/scripts/flashcard-to-anki.py (renamed from claude-templates/.ai/scripts/drill-to-anki.py)6
-rw-r--r--.ai/scripts/tests/flashcard-sync.bats (renamed from .ai/scripts/tests/drill-deck-sync.bats)12
-rw-r--r--.ai/scripts/tests/test_flashcard_diff_ids.py (renamed from .ai/scripts/tests/test_drill_deck_diff_ids.py)6
-rw-r--r--.ai/scripts/tests/test_flashcard_stats.py (renamed from .ai/scripts/tests/test_drill_deck_stats.py)6
-rw-r--r--.ai/scripts/tests/test_flashcard_to_anki.py (renamed from .ai/scripts/tests/test_drill_to_anki.py)6
-rw-r--r--.ai/workflows/INDEX.org6
-rw-r--r--.ai/workflows/flashcard-review.org (renamed from .ai/workflows/drill-deck-review.org)90
-rw-r--r--.ai/workflows/rename-artifact.org44
-rwxr-xr-xclaude-templates/.ai/scripts/flashcard-diff-ids.py (renamed from claude-templates/.ai/scripts/drill-deck-diff-ids.py)4
-rwxr-xr-xclaude-templates/.ai/scripts/flashcard-stats.py (renamed from claude-templates/.ai/scripts/drill-deck-stats.py)8
-rwxr-xr-xclaude-templates/.ai/scripts/flashcard-sync (renamed from .ai/scripts/drill-deck-sync)26
-rwxr-xr-xclaude-templates/.ai/scripts/flashcard-to-anki.py (renamed from .ai/scripts/drill-to-anki.py)6
-rw-r--r--claude-templates/.ai/scripts/tests/flashcard-sync.bats (renamed from claude-templates/.ai/scripts/tests/drill-deck-sync.bats)12
-rw-r--r--claude-templates/.ai/scripts/tests/test_flashcard_diff_ids.py (renamed from claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py)6
-rw-r--r--claude-templates/.ai/scripts/tests/test_flashcard_stats.py (renamed from claude-templates/.ai/scripts/tests/test_drill_deck_stats.py)6
-rw-r--r--claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py (renamed from claude-templates/.ai/scripts/tests/test_drill_to_anki.py)6
-rw-r--r--claude-templates/.ai/workflows/INDEX.org6
-rw-r--r--claude-templates/.ai/workflows/flashcard-review.org (renamed from claude-templates/.ai/workflows/drill-deck-review.org)90
-rw-r--r--claude-templates/.ai/workflows/rename-artifact.org44
-rwxr-xr-xscripts/rename-ai-artifact.sh129
-rw-r--r--scripts/tests/rename-ai-artifact.bats119
24 files changed, 508 insertions, 168 deletions
diff --git a/.ai/scripts/drill-deck-diff-ids.py b/.ai/scripts/flashcard-diff-ids.py
index bd2c4cc..152bb70 100755
--- a/.ai/scripts/drill-deck-diff-ids.py
+++ b/.ai/scripts/flashcard-diff-ids.py
@@ -9,7 +9,7 @@ IDs are usually fine (new cards added on purpose) but worth surfacing.
Exits 0 when clean, 1 when any IDs disappeared or appeared.
Usage:
- drill-deck-diff-ids.py <before.org> <after.org>
+ flashcard-diff-ids.py <before.org> <after.org>
"""
from __future__ import annotations
@@ -68,7 +68,7 @@ def main() -> int:
disappeared = before_ids - after_ids
appeared = after_ids - before_ids
- print(f"drill-deck-diff-ids: {before_path.name} → {after_path.name}")
+ print(f"flashcard-diff-ids: {before_path.name} → {after_path.name}")
print()
print(f"IDs in BEFORE: {len(before_ids)}")
print(f"IDs in AFTER: {len(after_ids)}")
diff --git a/.ai/scripts/drill-deck-stats.py b/.ai/scripts/flashcard-stats.py
index 04c3468..1fa5afb 100755
--- a/.ai/scripts/drill-deck-stats.py
+++ b/.ai/scripts/flashcard-stats.py
@@ -6,7 +6,7 @@ Reports counts and flags two tiers of issue.
Blocking WARNs (exit 1):
- PROPERTIES drawer count not matching card count
- Cards missing :ID: (risks SRS-state loss across rewrites)
-- `*** Answer` sub-headers (should be 0 per drill-deck-review.org)
+- `*** Answer` sub-headers (should be 0 per flashcard-review.org)
- Non-prompt headings (topic-as-heading not yet rewritten)
- #+TITLE missing, or carrying source-tool jargon ("org-drill")
- Answer leakage: a card whose question echoes most of its own answer
@@ -20,14 +20,14 @@ Non-blocking NOTEs (exit unaffected):
- Binary yes/no prompts (low retrieval effort — candidate to reformulate)
Exits 0 when no blocking warnings are present, 1 otherwise, 2 on bad usage.
-Use as a gate before regenerating the Anki deck or running drill-deck-sync.
+Use as a gate before regenerating the Anki deck or running flashcard-sync.
The fuzzy checks (leakage, duplicate, overloaded) are tuned by the LEAKAGE_*
and BACK_WORD_LIMIT constants below; loosen them if a real deck trips false
positives.
Usage:
- drill-deck-stats.py <file.org>
+ flashcard-stats.py <file.org>
"""
from __future__ import annotations
@@ -297,7 +297,7 @@ def main() -> int:
elif SOURCE_TOOL_RE.search(title):
warn(f"#+TITLE contains source-tool jargon ('{title}'); the deck name shows in Anki — drop 'Org-Drill' for a name that reads well on the consumption side")
if answer_count:
- warn(f"{answer_count} cards have *** Answer sub-headers (drop per drill-deck-review.org)")
+ warn(f"{answer_count} cards have *** Answer sub-headers (drop per flashcard-review.org)")
if prop_count != len(cards):
warn(f"PROPERTIES count {prop_count} does not match card count {len(cards)}")
if no_id:
diff --git a/claude-templates/.ai/scripts/drill-deck-sync b/.ai/scripts/flashcard-sync
index 8e51cdd..f5ba7fb 100755
--- a/claude-templates/.ai/scripts/drill-deck-sync
+++ b/.ai/scripts/flashcard-sync
@@ -1,23 +1,23 @@
#!/usr/bin/env bash
-# drill-deck-sync: stats check + regenerate Anki apkg + place at ~/sync/phone/anki/
+# flashcard-sync: stats check + regenerate Anki apkg + place at ~/sync/phone/anki/
#
-# Wraps drill-deck-stats.py + drill-to-anki.py (and optionally
-# drill-deck-diff-ids.py) for the canonical "rewrote the deck, now ship
-# it" step in the drill-deck-review workflow.
+# Wraps flashcard-stats.py + flashcard-to-anki.py (and optionally
+# flashcard-diff-ids.py) for the canonical "rewrote the deck, now ship
+# it" step in the flashcard-review workflow.
#
# Usage:
-# drill-deck-sync <source.org>
-# drill-deck-sync <source.org> --diff-against <previous-version.org>
+# flashcard-sync <source.org>
+# flashcard-sync <source.org> --diff-against <previous-version.org>
#
# Exits non-zero when the stats check warns, when --diff-against shows
-# any disappeared / appeared IDs, or when drill-to-anki.py fails. The
+# any disappeared / appeared IDs, or when flashcard-to-anki.py fails. The
# Anki apkg is not written when any gate fails.
set -euo pipefail
usage() {
cat >&2 <<'EOF'
-usage: drill-deck-sync <source.org> [--diff-against <previous-version.org>]
+usage: flashcard-sync <source.org> [--diff-against <previous-version.org>]
EOF
exit 2
}
@@ -53,9 +53,9 @@ if [[ ! -f "$SOURCE" ]]; then
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-STATS="$SCRIPT_DIR/drill-deck-stats.py"
-DIFF_IDS="$SCRIPT_DIR/drill-deck-diff-ids.py"
-TO_ANKI="$SCRIPT_DIR/drill-to-anki.py"
+STATS="$SCRIPT_DIR/flashcard-stats.py"
+DIFF_IDS="$SCRIPT_DIR/flashcard-diff-ids.py"
+TO_ANKI="$SCRIPT_DIR/flashcard-to-anki.py"
for helper in "$STATS" "$DIFF_IDS" "$TO_ANKI"; do
if [[ ! -f "$helper" ]]; then
@@ -64,12 +64,12 @@ for helper in "$STATS" "$DIFF_IDS" "$TO_ANKI"; do
fi
done
-echo "=== drill-deck-sync: $SOURCE ==="
+echo "=== flashcard-sync: $SOURCE ==="
echo
echo "--- stats ---"
if ! python3 "$STATS" "$SOURCE"; then
echo
- echo "stats check failed — fix warnings before sync, or call drill-to-anki.py directly to override" >&2
+ echo "stats check failed — fix warnings before sync, or call flashcard-to-anki.py directly to override" >&2
exit 1
fi
echo
diff --git a/claude-templates/.ai/scripts/drill-to-anki.py b/.ai/scripts/flashcard-to-anki.py
index 9fe954e..7227683 100755
--- a/claude-templates/.ai/scripts/drill-to-anki.py
+++ b/.ai/scripts/flashcard-to-anki.py
@@ -22,9 +22,9 @@ a mobile-Anki artifact the phone picks up from its sync dir, so it lands
there rather than next to the org source.
Usage:
- drill-to-anki.py <input.org>
- drill-to-anki.py <input.org> --deck "My Deck Name"
- drill-to-anki.py <input.org> --output /path/to/deck.apkg
+ flashcard-to-anki.py <input.org>
+ flashcard-to-anki.py <input.org> --deck "My Deck Name"
+ flashcard-to-anki.py <input.org> --output /path/to/deck.apkg
Requires genanki, which uv resolves automatically via the PEP 723
script metadata above. No venv or system install needed.
diff --git a/.ai/scripts/tests/drill-deck-sync.bats b/.ai/scripts/tests/flashcard-sync.bats
index e141cab..608a280 100644
--- a/.ai/scripts/tests/drill-deck-sync.bats
+++ b/.ai/scripts/tests/flashcard-sync.bats
@@ -1,11 +1,11 @@
#!/usr/bin/env bats
-# Tests for the drill-deck-sync wrapper: argument handling + the stats gate.
-# The clean end-to-end path runs drill-to-anki.py (uv-resolved genanki) and is
+# Tests for the flashcard-sync wrapper: argument handling + the stats gate.
+# The clean end-to-end path runs flashcard-to-anki.py (uv-resolved genanki) and is
# not exercised here; these cover the guard paths that stop before that step.
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
- SYNC="$SCRIPT_DIR/drill-deck-sync"
+ SYNC="$SCRIPT_DIR/flashcard-sync"
TMP="$(mktemp -d)"
}
@@ -13,17 +13,17 @@ teardown() {
rm -rf "$TMP"
}
-@test "drill-deck-sync: no args exits 2" {
+@test "flashcard-sync: no args exits 2" {
run "$SYNC"
[ "$status" -eq 2 ]
}
-@test "drill-deck-sync: missing source file exits 2" {
+@test "flashcard-sync: missing source file exits 2" {
run "$SYNC" "$TMP/nope.org"
[ "$status" -eq 2 ]
}
-@test "drill-deck-sync: stats gate failure exits 1 and writes no apkg" {
+@test "flashcard-sync: stats gate failure exits 1 and writes no apkg" {
cat > "$TMP/dirty.org" <<'EOF'
#+TITLE: DeepSat Org-Drill Flashcards
diff --git a/.ai/scripts/tests/test_drill_deck_diff_ids.py b/.ai/scripts/tests/test_flashcard_diff_ids.py
index 15fb148..9554b48 100644
--- a/.ai/scripts/tests/test_drill_deck_diff_ids.py
+++ b/.ai/scripts/tests/test_flashcard_diff_ids.py
@@ -1,4 +1,4 @@
-"""Tests for drill-deck-diff-ids.py: :ID: extraction + SRS-state diff CLI.
+"""Tests for flashcard-diff-ids.py: :ID: extraction + SRS-state diff CLI.
Plain python3 script (no third-party deps), so card_id_map imports directly;
the disappeared/appeared reporting is exercised through the CLI.
@@ -12,12 +12,12 @@ from pathlib import Path
import pytest
-SCRIPT = Path(__file__).resolve().parents[1] / "drill-deck-diff-ids.py"
+SCRIPT = Path(__file__).resolve().parents[1] / "flashcard-diff-ids.py"
@pytest.fixture(scope="module")
def diff_ids():
- spec = importlib.util.spec_from_file_location("drill_deck_diff_ids", SCRIPT)
+ spec = importlib.util.spec_from_file_location("flashcard_diff_ids", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
diff --git a/.ai/scripts/tests/test_drill_deck_stats.py b/.ai/scripts/tests/test_flashcard_stats.py
index d60084d..606f7c1 100644
--- a/.ai/scripts/tests/test_drill_deck_stats.py
+++ b/.ai/scripts/tests/test_flashcard_stats.py
@@ -1,4 +1,4 @@
-"""Tests for drill-deck-stats.py: prompt-form heuristic + CLI inventory/gate.
+"""Tests for flashcard-stats.py: prompt-form heuristic + CLI inventory/gate.
Plain python3 script (no third-party deps), so the pure helper imports directly;
the inventory/gate behavior is exercised through the CLI.
@@ -12,12 +12,12 @@ from pathlib import Path
import pytest
-SCRIPT = Path(__file__).resolve().parents[1] / "drill-deck-stats.py"
+SCRIPT = Path(__file__).resolve().parents[1] / "flashcard-stats.py"
@pytest.fixture(scope="module")
def stats():
- spec = importlib.util.spec_from_file_location("drill_deck_stats", SCRIPT)
+ spec = importlib.util.spec_from_file_location("flashcard_stats", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
diff --git a/.ai/scripts/tests/test_drill_to_anki.py b/.ai/scripts/tests/test_flashcard_to_anki.py
index fc17817..058b0cd 100644
--- a/.ai/scripts/tests/test_drill_to_anki.py
+++ b/.ai/scripts/tests/test_flashcard_to_anki.py
@@ -1,4 +1,4 @@
-"""Tests for drill-to-anki.py default-path and deck-name helpers.
+"""Tests for flashcard-to-anki.py default-path and deck-name helpers.
The script is a PEP 723 uv-run script that imports genanki, which uv resolves
at runtime but isn't installed in the test environment. The fixture stubs
@@ -14,14 +14,14 @@ from pathlib import Path
import pytest
-SCRIPT = Path(__file__).resolve().parents[1] / "drill-to-anki.py"
+SCRIPT = Path(__file__).resolve().parents[1] / "flashcard-to-anki.py"
@pytest.fixture(scope="module")
def drill():
# Only stub when genanki is genuinely absent, so a real install isn't shadowed.
sys.modules.setdefault("genanki", types.ModuleType("genanki"))
- spec = importlib.util.spec_from_file_location("drill_to_anki", SCRIPT)
+ spec = importlib.util.spec_from_file_location("flashcard_to_anki", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
diff --git a/.ai/workflows/INDEX.org b/.ai/workflows/INDEX.org
index c8554d4..157a4e7 100644
--- a/.ai/workflows/INDEX.org
+++ b/.ai/workflows/INDEX.org
@@ -86,14 +86,16 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e
- Triggers: "page me on signal", "signal me when X is done", "send a signal note about X"
- =cross-project-broadcast.org= — fan out a single message to every AI project's inbox via the discovery helper =cross-project-broadcast.py= + the existing =inbox-send.py=. Use sparingly for capability announcements and shared rule changes; not for project-specific handoffs.
- Triggers: "broadcast this to every project", "notify every project about X", "fan out this announcement", "let every project know X is available"
-- =drill-deck-review.org= — review an org-drill flashcard file, restructure cards to question-form headings (no answer hints), audit content accuracy against project source-of-truth via subagent, rewrite source preserving SRS state, regenerate the Anki =.apkg= to =~/sync/phone/anki/=. Person cards use "Who is X? Tell me about their Y."; talking-points cards stay as-is. Script behavior: =drill-to-anki.py= strips =:PROPERTIES:= drawers + =SCHEDULED:= / =DEADLINE:= planning lines from Anki output.
- - Triggers: "review the drill deck", "update the drill deck", "refresh the Anki cards", "let's run the drill-deck-review workflow"
+- =flashcard-review.org= — review an org-drill flashcard file, restructure cards to question-form headings (no answer hints), audit content accuracy against project source-of-truth via subagent, rewrite source preserving SRS state, regenerate the Anki =.apkg= to =~/sync/phone/anki/=. Person cards use "Who is X? Tell me about their Y."; talking-points cards stay as-is. Script behavior: =flashcard-to-anki.py= strips =:PROPERTIES:= drawers + =SCHEDULED:= / =DEADLINE:= planning lines from Anki output.
+ - Triggers: "review the flashcards", "update the flashcards", "review the drill deck", "update the drill deck", "refresh the Anki cards", "let's run the flashcard-review workflow"
- =page-me.org= — set a timed notification.
- Triggers: anything containing the word "page" used as a verb ("page me", "page me in 10 minutes", "page me at 3pm")
- =status-check.org= — proactive long-running-job updates.
- Triggers: "keep me posted on this", "provide status checks on this job", "let me know when it's done", "monitor this for me". Auto: any job estimated 10+ min.
- =create-workflow.org= — define a new workflow.
- Triggers: "let's create/define/design a workflow for [activity]", or unmatched workflow request after this index returns no hit.
+- =rename-artifact.org= — rename an =.ai/= workflow or script across the canonical + mirror trees, rewriting every reference on a token boundary and leaving =sessions/= history alone. Backed by =scripts/rename-ai-artifact.sh=, which runs =workflow-integrity= + =sync-check= after the move.
+ - Triggers: "rename this workflow", "rename the [X] workflow/script", "let's run the rename-artifact workflow".
- =no-approvals.org= — drop the interaction-level approval gates for a pre-agreed batch while keeping engineering-discipline gates (=/review-code=, =/voice personal=, tests, session-log updates, subagent reviews, destructive-action consent). Mode stays on until Craig turns it off, a real question arises, the queue empties, or the conversation switches topics.
- Triggers: "no-approvals mode", "no approvals", "no-approval", "no need for approval gates", "stop asking, just keep going", "I'll check back in when you're done or stuck", "do all =<selector>= with no-approval"
- =cross-agent-comms.org= — protocol for cross-project agent coordination via =inbox/from-agents/= (file-based IPC, GPG-signed, supports cross-machine over Tailscale). Auto: when =cross-agent-watch= detects a new inbound message, or when an agent decides to initiate a cross-project conversation. Operational scripts (=cross-agent-send=, =-recv=, =-watch=, =-status=, =-discover=, =-halt=, =-resume=) and their READMEs live at =.ai/scripts/cross-agent-comms/=.
diff --git a/.ai/workflows/drill-deck-review.org b/.ai/workflows/flashcard-review.org
index 390f296..31027b3 100644
--- a/.ai/workflows/drill-deck-review.org
+++ b/.ai/workflows/flashcard-review.org
@@ -6,9 +6,9 @@
Take an org-drill flashcard file and bring it into the canonical shape — every card a question that doesn't give the answer away, every fact current — then regenerate the Anki =.apkg= and drop it where the phone can sync it.
-The workflow has three substantive passes (question-form audit, content-accuracy audit, source rewrite) followed by a mechanical regenerate-and-place step. Content review is dispatched to a subagent because it's bounded research across project source-of-truth files; the structural rewrite stays in the main thread because it touches the SRS state we don't want to lose. Three helper scripts (=drill-deck-stats.py=, =drill-deck-diff-ids.py=, =drill-deck-sync=) automate the inventory, the safety check, and the regenerate-and-place.
+The workflow has three substantive passes (question-form audit, content-accuracy audit, source rewrite) followed by a mechanical regenerate-and-place step. Content review is dispatched to a subagent because it's bounded research across project source-of-truth files; the structural rewrite stays in the main thread because it touches the SRS state we don't want to lose. Three helper scripts (=flashcard-stats.py=, =flashcard-diff-ids.py=, =flashcard-sync=) automate the inventory, the safety check, and the regenerate-and-place.
-*Scheduling lives on the Anki side.* Desired retention and the FSRS scheduling model are per-deck Anki options set on the phone, never controlled by the org source or =drill-to-anki.py=. The pipeline's only scheduling job is keeping each card's identity (the =:ID:=-derived GUID) stable so Anki's review history survives a rewrite. Don't try to encode retention, intervals, or org-drill's SM-2 state into the Anki output — the two schedulers are separate, and the import carries only card content plus identity. (Anki's desired-retention default is 90%; see [[https://docs.ankiweb.net/deck-options.html][the deck-options manual]].)
+*Scheduling lives on the Anki side.* Desired retention and the FSRS scheduling model are per-deck Anki options set on the phone, never controlled by the org source or =flashcard-to-anki.py=. The pipeline's only scheduling job is keeping each card's identity (the =:ID:=-derived GUID) stable so Anki's review history survives a rewrite. Don't try to encode retention, intervals, or org-drill's SM-2 state into the Anki output — the two schedulers are separate, and the import carries only card content plus identity. (Anki's desired-retention default is 90%; see [[https://docs.ankiweb.net/deck-options.html][the deck-options manual]].)
* When to Use This Workflow
@@ -17,7 +17,7 @@ Trigger phrases:
- "Review the drill deck"
- "Update the drill deck"
- "Refresh the Anki cards"
-- "Let's run the drill-deck-review workflow"
+- "Let's run the flashcard-review workflow"
Typical timing:
@@ -36,7 +36,7 @@ Typical timing:
- Project-root =knowledge.org=, =status.org=, =notes.org=
- =todo.org= for the freshest signal on people / partnerships / projects
- =deepsat/assets/= (or equivalent) for meeting transcripts when a specific fact needs confirmation
-- *Output location*: =~/sync/phone/anki/<basename>.apkg= (the phone-sync target). Both =drill-to-anki.py= and the =drill-deck-sync= wrapper default there.
+- *Output location*: =~/sync/phone/anki/<basename>.apkg= (the phone-sync target). Both =flashcard-to-anki.py= and the =flashcard-sync= wrapper default there.
* Canonical Card Shape
@@ -47,9 +47,9 @@ The =#+TITLE:= line at the top of the source file drives two surfaces: the org-d
Good: =DeepSat Flashcards=, =Health Flashcards=, =Philosophy Flashcards=.
Bad: =DeepSat Org-Drill Flashcards=, =DeepSat Drill Deck=.
-=drill-deck-stats.py= flags any title containing =org-drill= (case-insensitive, hyphenated or spaced) as a workflow violation.
+=flashcard-stats.py= flags any title containing =org-drill= (case-insensitive, hyphenated or spaced) as a workflow violation.
-*Stable-ID caveat.* =drill-to-anki.py= derives the Anki deck ID from the deck name. Changing =#+TITLE:= changes the deck ID, so the next import lands as a new deck rather than updating the existing one. Two consequences worth flagging:
+*Stable-ID caveat.* =flashcard-to-anki.py= derives the Anki deck ID from the deck name. Changing =#+TITLE:= changes the deck ID, so the next import lands as a new deck rather than updating the existing one. Two consequences worth flagging:
- Any review history accumulated in Anki under the old deck name stays attached to the old deck — it doesn't migrate.
- On rename, delete the old deck from Anki to avoid having two decks with similar content.
@@ -90,12 +90,12 @@ Format: "Who is X? Tell me about their Y." where X is a role descriptor that doe
Note: pick a role descriptor that genuinely identifies one person. If multiple people share the role description, add a single distinguishing detail (e.g., "the one who works evenings", "the Vineti alum"). Don't pile on parentheticals.
- Splitting: the person card deliberately trades atomicity for narrative recall — one card carries identity plus several attributes. When a body bundles genuinely unrelated attributes (role, employment history, limitations, scope) rather than one coherent topic, split it into multiple cards. One inherits the existing =:ID:= (and its SRS history); each new sibling starts fresh and will correctly show in =drill-deck-diff-ids.py= as an appeared ID. The criterion: split when the body reads as a list of separate facts, keep it whole when it reads as one story. (Minimum-information principle — Wozniak rule 4, Matuschak "Focused".)
+ Splitting: the person card deliberately trades atomicity for narrative recall — one card carries identity plus several attributes. When a body bundles genuinely unrelated attributes (role, employment history, limitations, scope) rather than one coherent topic, split it into multiple cards. One inherits the existing =:ID:= (and its SRS history); each new sibling starts fresh and will correctly show in =flashcard-diff-ids.py= as an appeared ID. The criterion: split when the body reads as a list of separate facts, keep it whole when it reads as one story. (Minimum-information principle — Wozniak rule 4, Matuschak "Focused".)
*** Talking-points and directive cards
Already in prompt form ("Introduce Yourself", "Spell out these orbital regime acronyms", "What is DeepSat?"). Leave the heading alone. Still strip the =*** Answer= sub-header and audit the body content for staleness.
-The =drill-deck-stats.py= helper recognizes both =?=-form and imperative-verb form as valid prompts (verbs like Spell, Describe, Explain, Name, List, Give, Show, Tell, Define, Compare, Identify, Outline, Introduce, Walk, State, Recite, Recall, Summarize).
+The =flashcard-stats.py= helper recognizes both =?=-form and imperative-verb form as valid prompts (verbs like Spell, Describe, Explain, Name, List, Give, Show, Tell, Define, Compare, Identify, Outline, Introduce, Walk, State, Recite, Recall, Summarize).
** Body (the answer)
@@ -103,16 +103,16 @@ The =drill-deck-stats.py= helper recognizes both =?=-form and imperative-verb fo
- *Body opens by naming the topic.* "Air Force Research Laboratory. Air Force's R&D arm." or "Vrezh Mikayelyan. Armenia-based, full-time as of ..." The Anki back shows this directly under the front question; restating the topic makes the back read as a complete answer.
- *PROPERTIES drawer stays.* Org-drill needs the =:ID:=, =:DRILL_LAST_INTERVAL:=, =:DRILL_EASE:= etc. for SRS state. The Anki output strips it (see the script change).
- *=SCHEDULED:= / =DEADLINE:= planning lines stay.* Same reason. The Anki output strips them.
-- *Source citation goes at the very end, after two blank lines.* When a card cites a source, put a =Source: <label> — <url>= line at the end of the body, separated from the answer by two blank lines (two empty paragraphs) so it reads as a footer, not part of the answer. =drill-deck-stats.py= ignores =Source:= lines when checking for answer leakage, since a URL slug often repeats the question's words.
+- *Source citation goes at the very end, after two blank lines.* When a card cites a source, put a =Source: <label> — <url>= line at the end of the body, separated from the answer by two blank lines (two empty paragraphs) so it reads as a footer, not part of the answer. =flashcard-stats.py= ignores =Source:= lines when checking for answer leakage, since a URL slug often repeats the question's words.
- *No created/added date on the card.* Don't stamp a card with the date it was written. If a card body carries a =Created:= line (or a =:CREATED:= line outside the drawer), remove it during the rewrite. The Anki output strips =Created:= lines as a backstop, but they shouldn't be in the source either. Volatile facts get dated in the answer prose itself ("full-time as of April 2026"), never via a card-level timestamp.
* Card Authoring Principles
-The canonical shapes above are the house style; these are the reasons behind them, drawn from the spaced-repetition literature. =drill-deck-stats.py= checks the mechanical ones; the rest guide the rewrite and the content pass.
+The canonical shapes above are the house style; these are the reasons behind them, drawn from the spaced-repetition literature. =flashcard-stats.py= checks the mechanical ones; the rest guide the rewrite and the content pass.
-- *One fact per card (minimum information principle).* A card should test a single retrievable connection. A back that bundles several independent facts gets partially recalled and burns repetitions on the parts you already know. When a body covers unrelated attributes, split it into separate cards. =drill-deck-stats.py= flags long backs as a non-blocking NOTE.
+- *One fact per card (minimum information principle).* A card should test a single retrievable connection. A back that bundles several independent facts gets partially recalled and burns repetitions on the parts you already know. When a body covers unrelated attributes, split it into separate cards. =flashcard-stats.py= flags long backs as a non-blocking NOTE.
-- *Demand recall, not recognition (effortful retrieval).* Pulling the answer from memory is what strengthens it, so the question must not let you infer the answer from its own wording. This is why person headings never name the person, and why a question that restates its own answer is a defect. =drill-deck-stats.py= flags high front/back word overlap as answer leakage — excluding =Source:= citation lines, and exempting range/category cards whose answer recalls numbers the question doesn't give away.
+- *Demand recall, not recognition (effortful retrieval).* Pulling the answer from memory is what strengthens it, so the question must not let you infer the answer from its own wording. This is why person headings never name the person, and why a question that restates its own answer is a defect. =flashcard-stats.py= flags high front/back word overlap as answer leakage — excluding =Source:= citation lines, and exempting range/category cards whose answer recalls numbers the question doesn't give away.
- *Avoid binary prompts.* "Is X true?" and "A or B?" allow a coin-flip guess and produce shallow understanding. Reformulate open-ended — "How does X affect Y?" beats "Does X affect Y?" Flagged as a non-blocking NOTE.
@@ -120,7 +120,7 @@ The canonical shapes above are the house style; these are the reasons behind the
- *Make cues precise.* A vague question admits several reasonable answers, so you can't tell whether you knew the intended one. Include enough context that only the intended answer fits, without narrowing into provincial trivia.
-- *Combat interference.* Confusable cards inhibit each other; two near-identical fronts are the worst case. Disambiguate them with distinguishing context, or merge them. =drill-deck-stats.py= flags duplicate / near-duplicate fronts.
+- *Combat interference.* Confusable cards inhibit each other; two near-identical fronts are the worst case. Disambiguate them with distinguishing context, or merge them. =flashcard-stats.py= flags duplicate / near-duplicate fronts.
- *Understand before you memorize.* Cards are the last step, after the material is understood and structured. A card you can't explain is a leech waiting to happen.
@@ -130,10 +130,10 @@ Sources: Wozniak's [[https://www.supermemo.com/en/blog/twenty-rules-of-formulati
** Phase A: Question-form + title audit (per card and per file)
-Run =drill-deck-stats.py= on the source first to get the structural inventory:
+Run =flashcard-stats.py= on the source first to get the structural inventory:
#+begin_src bash
-.ai/scripts/drill-deck-stats.py <source.org>
+.ai/scripts/flashcard-stats.py <source.org>
#+end_src
The script reports the deck title from =#+TITLE:= (and flags it if it contains source-tool jargon like "Org-Drill"), card count, PROPERTIES-drawer count, =*** Answer= sub-header count, cards missing =:ID:=, and cards whose heading is neither =?=-form nor an imperative-verb prompt. It also flags possible answer leakage and duplicate / near-duplicate fronts (both blocking), and surfaces non-blocking NOTEs for overloaded, list-shaped, or binary cards. Each surfaced card is a candidate for the rewrite, plus the title itself if flagged.
@@ -196,27 +196,27 @@ For the file as a whole, use a single =Write= rather than per-card =Edit= calls.
** Phase D: Regenerate the Anki deck
-Use the =drill-deck-sync= wrapper — it runs the stats check, optionally the ID-preservation check, then regenerates the apkg and places it at =~/sync/phone/anki/=:
+Use the =flashcard-sync= wrapper — it runs the stats check, optionally the ID-preservation check, then regenerates the apkg and places it at =~/sync/phone/anki/=:
#+begin_src bash
-.ai/scripts/drill-deck-sync <source.org> --diff-against <previous-version.org>
+.ai/scripts/flashcard-sync <source.org> --diff-against <previous-version.org>
#+end_src
The =--diff-against= flag is recommended on any rewrite where you want to confirm zero card IDs disappeared (zero SRS-state loss). The "previous version" is typically the file as it was before this run; grab it from git with =git show HEAD~1:<path> > /tmp/<name>-prerewrite.org=. Skip =--diff-against= on a first run when there's no previous version to compare against.
If the stats check or ID-preservation check fails, the wrapper exits non-zero and the apkg is not written. Fix the warnings, then re-run.
-To bypass the safety gates (rare, only when you know what you're doing), call =drill-to-anki.py= directly:
+To bypass the safety gates (rare, only when you know what you're doing), call =flashcard-to-anki.py= directly:
#+begin_src bash
-.ai/scripts/drill-to-anki.py <source.org> --output ~/sync/phone/anki/<basename>.apkg
+.ai/scripts/flashcard-to-anki.py <source.org> --output ~/sync/phone/anki/<basename>.apkg
#+end_src
The script writes the =.apkg= with stable deck/model IDs derived from the deck name, so re-importing into Anki updates existing cards rather than duplicating them.
** Phase E: Verify
-The =drill-deck-sync= wrapper covers the structural verify automatically (stats + diff-ids if =--diff-against= was passed). After it succeeds, do a quick visual spot-check:
+The =flashcard-sync= wrapper covers the structural verify automatically (stats + diff-ids if =--diff-against= was passed). After it succeeds, do a quick visual spot-check:
- Confirm the apkg size matches expectations. Significant changes are expected on a big rewrite; a wildly smaller file may mean the parser dropped cards.
- Open the source in Emacs (or =head -100=) and confirm a few cards visually: question heading, no =*** Answer=, PROPERTIES preserved, body opens with topic name.
@@ -225,8 +225,8 @@ The =drill-deck-sync= wrapper covers the structural verify automatically (stats
For ad-hoc verification on either side of a rewrite, run the individual scripts:
#+begin_src bash
-.ai/scripts/drill-deck-stats.py <source.org>
-.ai/scripts/drill-deck-diff-ids.py <before.org> <after.org>
+.ai/scripts/flashcard-stats.py <source.org>
+.ai/scripts/flashcard-diff-ids.py <before.org> <after.org>
#+end_src
** Phase F: Commit
@@ -234,7 +234,7 @@ For ad-hoc verification on either side of a rewrite, run the individual scripts:
Two clusters:
- *Source rewrite*: the org file (e.g., =deepsat.org=). Commit subject: =chore(drill): restructure cards to question-form headings + content refresh=. Body lists the content-update categories (Vrezh full-time, DCVC passed, etc.) and notes that =*** Answer= sub-headers were dropped.
-- *Workflow / script changes* (if any): if this run prompted updates to =drill-deck-review.org= or the helper scripts in the rulesets repo, commit those separately with =chore(workflows):= or =chore(scripts):= subjects.
+- *Workflow / script changes* (if any): if this run prompted updates to =flashcard-review.org= or the helper scripts in the rulesets repo, commit those separately with =chore(workflows):= or =chore(scripts):= subjects.
Push both. The =.apkg= itself lives under =~/sync/phone/= which is outside the repo — no commit needed there; Syncthing (or whatever sync mechanism) handles propagation.
@@ -242,39 +242,39 @@ Push both. The =.apkg= itself lives under =~/sync/phone/= which is outside the r
Three scripts under =.ai/scripts/= (canonical lives in =rulesets/claude-templates/.ai/scripts/=):
-** =drill-to-anki.py=
+** =flashcard-to-anki.py=
The core converter. Reads an org-drill source file, emits a stable-ID Anki =.apkg=. Strips =:PROPERTIES:= drawers and =SCHEDULED:= / =DEADLINE:= / =CLOSED:= planning lines from card bodies before rendering. Front = heading text without =:drill:=. Back = cleaned body, HTML-escaped, joined with =<br>=. Deck and model IDs derived from the deck name + a salt, so re-imports update existing cards rather than duplicating.
-** =drill-deck-stats.py=
+** =flashcard-stats.py=
-Inventory + authoring-quality checks for a single deck source. Counts cards, PROPERTIES drawers, =*** Answer= sub-headers, cards missing =:ID:=, and cards whose heading is neither =?=-form nor an imperative-verb prompt. It also checks authoring quality: answer leakage (front/back content-word overlap) and duplicate / near-duplicate fronts are blocking WARNs; overloaded backs, list-shaped backs, and binary prompts are non-blocking NOTEs. Exits 0 when no blocking warning is present, 1 otherwise, so it gates =drill-deck-sync=. The leakage check ignores =Source:= and created-date lines and exempts range/category cards whose answer recalls numbers the question doesn't give away.
+Inventory + authoring-quality checks for a single deck source. Counts cards, PROPERTIES drawers, =*** Answer= sub-headers, cards missing =:ID:=, and cards whose heading is neither =?=-form nor an imperative-verb prompt. It also checks authoring quality: answer leakage (front/back content-word overlap) and duplicate / near-duplicate fronts are blocking WARNs; overloaded backs, list-shaped backs, and binary prompts are non-blocking NOTEs. Exits 0 when no blocking warning is present, 1 otherwise, so it gates =flashcard-sync=. The leakage check ignores =Source:= and created-date lines and exempts range/category cards whose answer recalls numbers the question doesn't give away.
Imperative-verb allowlist: Spell, Describe, Explain, Name, List, Give, Show, Tell, Define, Compare, Identify, Outline, Introduce, Walk, State, Recite, Recall, Summarize.
The fuzzy checks (leakage ratio, overloaded word count) are tuned by the =LEAKAGE_*= and =BACK_WORD_LIMIT= constants at the top of the script. Loosen them if a real deck trips false positives.
-** =drill-deck-diff-ids.py=
+** =flashcard-diff-ids.py=
SRS-state preservation check between two versions of a deck. Extracts every =:ID:= from each, reports IDs that disappeared (lost SRS state — worst-case bug) or appeared (new cards). Exits 0 when clean, 1 when any disappeared/appeared.
-** =drill-deck-sync= (bash wrapper)
+** =flashcard-sync= (bash wrapper)
-Single command for the canonical "rewrote the deck, now ship it" step. Runs =drill-deck-stats=, optionally =drill-deck-diff-ids= (with =--diff-against=), then =drill-to-anki= writing to =~/sync/phone/anki/<basename>.apkg=. Exits non-zero if any gate fails; the apkg is not written when a gate fails.
+Single command for the canonical "rewrote the deck, now ship it" step. Runs =flashcard-stats=, optionally =flashcard-diff-ids= (with =--diff-against=), then =flashcard-to-anki= writing to =~/sync/phone/anki/<basename>.apkg=. Exits non-zero if any gate fails; the apkg is not written when a gate fails.
Usage:
#+begin_src bash
-drill-deck-sync <source.org>
-drill-deck-sync <source.org> --diff-against <previous-version.org>
+flashcard-sync <source.org>
+flashcard-sync <source.org> --diff-against <previous-version.org>
#+end_src
* Anki Script Behavior
-The =drill-to-anki.py= script has these contracts that this workflow depends on:
+The =flashcard-to-anki.py= script has these contracts that this workflow depends on:
1. *Strips =:PROPERTIES:= drawers* from the card body before rendering. Org-drill needs them in source; Anki cards shouldn't show them.
2. *Strips =SCHEDULED:= / =DEADLINE:= / =CLOSED:= planning lines and =Created:= / =:CREATED:= date lines* from the card body. Same reason — and a created date never belongs on a card.
-3. *Does NOT strip =*** Answer= sub-headers.* If the source still has them, the Anki cards will show them. This workflow's Phase C removes them at the source. =drill-deck-stats.py= flags any remaining as a workflow violation.
+3. *Does NOT strip =*** Answer= sub-headers.* If the source still has them, the Anki cards will show them. This workflow's Phase C removes them at the source. =flashcard-stats.py= flags any remaining as a workflow violation.
4. *Front of each Anki card* = the heading text without the =:drill:= tag.
5. *Back of each Anki card* = the cleaned body (after #1 and #2), joined with =<br>= and HTML-escaped.
6. *Stable IDs* derived from the deck name + a salt, so re-importing the same deck name updates cards rather than duplicating.
@@ -283,22 +283,22 @@ If you find the script doing something else, update the script before regenerati
* Output Path Convention
-- Default in =drill-to-anki.py=: =~/sync/phone/anki/<basename>.apkg=.
-- Default in =drill-deck-sync=: =~/sync/phone/anki/<basename>.apkg= (same target; the wrapper passes =--output= explicitly).
+- Default in =flashcard-to-anki.py=: =~/sync/phone/anki/<basename>.apkg=.
+- Default in =flashcard-sync=: =~/sync/phone/anki/<basename>.apkg= (same target; the wrapper passes =--output= explicitly).
=~/sync/org/drill/= holds the org sources and their symlinks; =~/sync/phone/anki/= holds the =.apkg= the phone consumes. Both tools write the =.apkg= to the phone dir by default, so a deck lands where Anki picks it up without an =--output= override.
* Common Mistakes
1. *Per-card =Edit= calls instead of one =Write=.* Multiplies tool calls and risks drift between cards. Read once, rewrite in memory, write once.
-2. *Dropping the PROPERTIES drawer in source.* Org-drill stores SRS state there; losing it resets every card's review history. =drill-deck-diff-ids.py= is the safety net.
+2. *Dropping the PROPERTIES drawer in source.* Org-drill stores SRS state there; losing it resets every card's review history. =flashcard-diff-ids.py= is the safety net.
3. *Rewriting person headings to include the name.* "Who is Vrezh Mikayelyan?" gives away the answer. The whole point is to test name recall from a role description.
-4. *Forgetting to strip =*** Answer= sub-headers.* The Anki output will show them as visible card content. =drill-deck-stats.py= catches this.
+4. *Forgetting to strip =*** Answer= sub-headers.* The Anki output will show them as visible card content. =flashcard-stats.py= catches this.
5. *Skipping the content-accuracy pass.* The structural rewrite alone leaves stale facts in place. The drill cards become a memorization tool for the wrong information.
6. *Treating subagent output as gospel.* Medium- and low-confidence findings need human review before baking. The subagent surfaces; the main thread decides.
-7. *Running =drill-deck-sync= without =--diff-against=.* The stats check still runs, but the SRS-state preservation check doesn't. On a rewrite of any size, pass =--diff-against /tmp/<name>-prerewrite.org= (grab from git first).
-8. *Answer leakage.* A question that restates its own answer tests recognition, not recall — the card looks learned when it isn't. =drill-deck-stats.py= flags high front/back word overlap.
-9. *Encoding scheduling in the source.* Retention, intervals, and FSRS state are Anki-side options; the org files and =drill-to-anki.py= carry only card content plus identity. See the scheduling note in the Overview.
+7. *Running =flashcard-sync= without =--diff-against=.* The stats check still runs, but the SRS-state preservation check doesn't. On a rewrite of any size, pass =--diff-against /tmp/<name>-prerewrite.org= (grab from git first).
+8. *Answer leakage.* A question that restates its own answer tests recognition, not recall — the card looks learned when it isn't. =flashcard-stats.py= flags high front/back word overlap.
+9. *Encoding scheduling in the source.* Retention, intervals, and FSRS state are Anki-side options; the org files and =flashcard-to-anki.py= carry only card content plus identity. See the scheduling note in the Overview.
* Living Document
@@ -307,7 +307,7 @@ Update this workflow as patterns emerge. Specifically:
- New card family beyond acronym / person / talking-point → document the heading shape for it.
- New source-of-truth doc beyond the standard set → add to Phase B's dispatch contract.
- Script behavior changes → mirror them in the "Anki Script Behavior" section.
-- New imperative-verb prompt forms → add the verb to =drill-deck-stats.py=' s allowlist.
+- New imperative-verb prompt forms → add the verb to =flashcard-stats.py=' s allowlist.
** Updates and Learnings
@@ -315,13 +315,13 @@ Update this workflow as patterns emerge. Specifically:
Built against =deepsat.org= after Craig flagged that the existing apkg surfaced PROPERTIES drawers + =*** Answer= headers on the back of every card, and that the person-card content (Vrezh in particular) had drifted. The Phase B subagent surfaced 8 high-confidence content updates plus several medium-confidence enrichments. Validated by running the rewrite and regenerating =deepsat.apkg= to =~/sync/phone/anki/=.
*** 2026-05-30: Helper scripts added (same day)
-After the first run, scripted the safety-net checks into three helpers: =drill-deck-stats.py= (inventory + warnings), =drill-deck-diff-ids.py= (SRS-state preservation between versions), and =drill-deck-sync= (single-command wrapper). Stats check on the deepsat rewrite flushed a heuristic bug — directive prompts ("Spell out these orbital regime acronyms", "Introduce Yourself") were flagged as non-question. Fix: =drill-deck-stats.py= now accepts =?=-form OR imperative-verb-start (Spell, Describe, Explain, ..., Recall) as valid prompt forms.
+After the first run, scripted the safety-net checks into three helpers: =flashcard-stats.py= (inventory + warnings), =flashcard-diff-ids.py= (SRS-state preservation between versions), and =flashcard-sync= (single-command wrapper). Stats check on the deepsat rewrite flushed a heuristic bug — directive prompts ("Spell out these orbital regime acronyms", "Introduce Yourself") were flagged as non-question. Fix: =flashcard-stats.py= now accepts =?=-form OR imperative-verb-start (Spell, Describe, Explain, ..., Recall) as valid prompt forms.
*** 2026-05-30: Title-audit added (same day)
-Craig noticed the Anki deck name still showed as "DeepSat Org-Drill Flashcards" because the source =#+TITLE:= leaks tool-name jargon into Anki. Added a "Deck title" subsection under Canonical Card Shape, expanded Phase A to audit the title, and extended =drill-deck-stats.py= to flag any title matching =org[-\s]?drill= (case-insensitive). Stable-ID caveat documented: renaming the deck changes the Anki deck ID, so the next import lands as a new deck and the old one needs deleting from Anki.
+Craig noticed the Anki deck name still showed as "DeepSat Org-Drill Flashcards" because the source =#+TITLE:= leaks tool-name jargon into Anki. Added a "Deck title" subsection under Canonical Card Shape, expanded Phase A to audit the title, and extended =flashcard-stats.py= to flag any title matching =org[-\s]?drill= (case-insensitive). Stable-ID caveat documented: renaming the deck changes the Anki deck ID, so the next import lands as a new deck and the old one needs deleting from Anki.
*** 2026-05-30: Authoring-quality checks + Card Authoring section (same day)
-Researched flashcard / spaced-repetition best practices (Wozniak's twenty rules, Matuschak's prompt-writing guide, Nielsen, the Anki manual, the FSRS docs) and folded the findings in. =drill-deck-stats.py= gained answer-leakage and duplicate-front checks (blocking), plus non-blocking NOTEs for overloaded backs, list-shaped backs, and binary prompts. Added a "Card Authoring Principles" section (the why behind the canonical shapes), a person-card splitting path, a Phase B cost-benefit-removal + leech-feedback disposition, and a scheduling-is-Anki-side note in the Overview. Deliberately not adopted, with reasons: cloze cards (would need a second note type and an authoring convention), per-card tractability targeting and FSRS-retention encoding (Anki-side telemetry that never flows back to the source), on-face source-stamping (the converter strips those drawers by design; provenance stays in the org layer).
+Researched flashcard / spaced-repetition best practices (Wozniak's twenty rules, Matuschak's prompt-writing guide, Nielsen, the Anki manual, the FSRS docs) and folded the findings in. =flashcard-stats.py= gained answer-leakage and duplicate-front checks (blocking), plus non-blocking NOTEs for overloaded backs, list-shaped backs, and binary prompts. Added a "Card Authoring Principles" section (the why behind the canonical shapes), a person-card splitting path, a Phase B cost-benefit-removal + leech-feedback disposition, and a scheduling-is-Anki-side note in the Overview. Deliberately not adopted, with reasons: cloze cards (would need a second note type and an authoring convention), per-card tractability targeting and FSRS-retention encoding (Anki-side telemetry that never flows back to the source), on-face source-stamping (the converter strips those drawers by design; provenance stays in the org layer).
*** 2026-05-30: Leakage false-positive fixes + source/created-date conventions (same day)
-Health ran the leakage check on a 43-card deck and hit two false-positive classes. Fixed both in =drill-deck-stats.py=: =Source:= citation lines are stripped before the overlap is computed (a URL slug repeats the question's words), and range/category cards whose answer carries numeric ranges or thresholds the question lacks are exempted (the recalled content is the numbers, which aren't given away). Codified two body conventions: a =Source:= citation sits at the end of the card after two blank lines, and no created/added date goes on a card. =drill-to-anki.py= now strips =Created:= / =:CREATED:= lines from the back as a backstop, and Phase C removes them from the source during the rewrite.
+Health ran the leakage check on a 43-card deck and hit two false-positive classes. Fixed both in =flashcard-stats.py=: =Source:= citation lines are stripped before the overlap is computed (a URL slug repeats the question's words), and range/category cards whose answer carries numeric ranges or thresholds the question lacks are exempted (the recalled content is the numbers, which aren't given away). Codified two body conventions: a =Source:= citation sits at the end of the card after two blank lines, and no created/added date goes on a card. =flashcard-to-anki.py= now strips =Created:= / =:CREATED:= lines from the back as a backstop, and Phase C removes them from the source during the rewrite.
diff --git a/.ai/workflows/rename-artifact.org b/.ai/workflows/rename-artifact.org
new file mode 100644
index 0000000..7b9f15b
--- /dev/null
+++ b/.ai/workflows/rename-artifact.org
@@ -0,0 +1,44 @@
+#+TITLE: Rename an .ai Artifact
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-05-31
+
+* Summary
+
+Rename a workflow or a script that lives in the =.ai/= tree, without leaving the canonical copy, the mirror copy, the INDEX, or any reference behind. The work is mechanical and easy to do incompletely by hand — a missed reference or a forgotten mirror copy is the classic failure. The =rename-ai-artifact.sh= script does it the same way every time, so reach for the script rather than renaming files by hand.
+
+Quick contract — what it does:
+- Moves the artifact in both =claude-templates/.ai/= (canonical) and =.ai/= (mirror), in lockstep.
+- Rewrites every reference to the artifact's stem, repo-wide, on a token boundary so renaming =foo= can't corrupt =foobar= or =foo-bar=.
+- Rewrites the underscore module-name variant too — a hyphenated script imported as =foo_bar= via importlib, not just the =foo-bar.py= path.
+- Leaves =sessions/= (both trees) untouched — archived session records are history.
+- Runs =workflow-integrity.py= and =sync-check.sh= afterward to prove no drift.
+
+* Execution
+
+Run once per file. For a family (a workflow plus its helper scripts), run it once per artifact; order doesn't matter because reference matching is token-bounded.
+
+#+begin_example
+scripts/rename-ai-artifact.sh OLD-BASENAME NEW-BASENAME
+
+# examples
+scripts/rename-ai-artifact.sh old-workflow.org new-workflow.org
+scripts/rename-ai-artifact.sh old-helper.py new-helper.py
+#+end_example
+
+After the last file in a family is renamed, review =git status= and the diff, then commit through the normal publish flow.
+
+* Reference
+
+** When to use
+
+Any time an =.ai/= workflow or script changes name. Also the right tool when only the *concept* is being renamed and you want every trigger phrase, =file:= link, and prose mention updated to match.
+
+** What it will not do
+
+- It won't touch =sessions/= records (history stays accurate to when it was written).
+- It won't rename across directories — the artifact keeps its home directory; only the basename changes.
+- It renames one artifact per call and refuses if the source is missing or the target name already exists.
+
+** If a verify step reports drift
+
+The script still completes the rename, then warns. Read the =workflow-integrity= / =sync-check= output, fix by hand (usually a stale INDEX row the stem rewrite didn't reach because the row used different wording), and re-run the checks before committing.
diff --git a/claude-templates/.ai/scripts/drill-deck-diff-ids.py b/claude-templates/.ai/scripts/flashcard-diff-ids.py
index bd2c4cc..152bb70 100755
--- a/claude-templates/.ai/scripts/drill-deck-diff-ids.py
+++ b/claude-templates/.ai/scripts/flashcard-diff-ids.py
@@ -9,7 +9,7 @@ IDs are usually fine (new cards added on purpose) but worth surfacing.
Exits 0 when clean, 1 when any IDs disappeared or appeared.
Usage:
- drill-deck-diff-ids.py <before.org> <after.org>
+ flashcard-diff-ids.py <before.org> <after.org>
"""
from __future__ import annotations
@@ -68,7 +68,7 @@ def main() -> int:
disappeared = before_ids - after_ids
appeared = after_ids - before_ids
- print(f"drill-deck-diff-ids: {before_path.name} → {after_path.name}")
+ print(f"flashcard-diff-ids: {before_path.name} → {after_path.name}")
print()
print(f"IDs in BEFORE: {len(before_ids)}")
print(f"IDs in AFTER: {len(after_ids)}")
diff --git a/claude-templates/.ai/scripts/drill-deck-stats.py b/claude-templates/.ai/scripts/flashcard-stats.py
index 04c3468..1fa5afb 100755
--- a/claude-templates/.ai/scripts/drill-deck-stats.py
+++ b/claude-templates/.ai/scripts/flashcard-stats.py
@@ -6,7 +6,7 @@ Reports counts and flags two tiers of issue.
Blocking WARNs (exit 1):
- PROPERTIES drawer count not matching card count
- Cards missing :ID: (risks SRS-state loss across rewrites)
-- `*** Answer` sub-headers (should be 0 per drill-deck-review.org)
+- `*** Answer` sub-headers (should be 0 per flashcard-review.org)
- Non-prompt headings (topic-as-heading not yet rewritten)
- #+TITLE missing, or carrying source-tool jargon ("org-drill")
- Answer leakage: a card whose question echoes most of its own answer
@@ -20,14 +20,14 @@ Non-blocking NOTEs (exit unaffected):
- Binary yes/no prompts (low retrieval effort — candidate to reformulate)
Exits 0 when no blocking warnings are present, 1 otherwise, 2 on bad usage.
-Use as a gate before regenerating the Anki deck or running drill-deck-sync.
+Use as a gate before regenerating the Anki deck or running flashcard-sync.
The fuzzy checks (leakage, duplicate, overloaded) are tuned by the LEAKAGE_*
and BACK_WORD_LIMIT constants below; loosen them if a real deck trips false
positives.
Usage:
- drill-deck-stats.py <file.org>
+ flashcard-stats.py <file.org>
"""
from __future__ import annotations
@@ -297,7 +297,7 @@ def main() -> int:
elif SOURCE_TOOL_RE.search(title):
warn(f"#+TITLE contains source-tool jargon ('{title}'); the deck name shows in Anki — drop 'Org-Drill' for a name that reads well on the consumption side")
if answer_count:
- warn(f"{answer_count} cards have *** Answer sub-headers (drop per drill-deck-review.org)")
+ warn(f"{answer_count} cards have *** Answer sub-headers (drop per flashcard-review.org)")
if prop_count != len(cards):
warn(f"PROPERTIES count {prop_count} does not match card count {len(cards)}")
if no_id:
diff --git a/.ai/scripts/drill-deck-sync b/claude-templates/.ai/scripts/flashcard-sync
index 8e51cdd..f5ba7fb 100755
--- a/.ai/scripts/drill-deck-sync
+++ b/claude-templates/.ai/scripts/flashcard-sync
@@ -1,23 +1,23 @@
#!/usr/bin/env bash
-# drill-deck-sync: stats check + regenerate Anki apkg + place at ~/sync/phone/anki/
+# flashcard-sync: stats check + regenerate Anki apkg + place at ~/sync/phone/anki/
#
-# Wraps drill-deck-stats.py + drill-to-anki.py (and optionally
-# drill-deck-diff-ids.py) for the canonical "rewrote the deck, now ship
-# it" step in the drill-deck-review workflow.
+# Wraps flashcard-stats.py + flashcard-to-anki.py (and optionally
+# flashcard-diff-ids.py) for the canonical "rewrote the deck, now ship
+# it" step in the flashcard-review workflow.
#
# Usage:
-# drill-deck-sync <source.org>
-# drill-deck-sync <source.org> --diff-against <previous-version.org>
+# flashcard-sync <source.org>
+# flashcard-sync <source.org> --diff-against <previous-version.org>
#
# Exits non-zero when the stats check warns, when --diff-against shows
-# any disappeared / appeared IDs, or when drill-to-anki.py fails. The
+# any disappeared / appeared IDs, or when flashcard-to-anki.py fails. The
# Anki apkg is not written when any gate fails.
set -euo pipefail
usage() {
cat >&2 <<'EOF'
-usage: drill-deck-sync <source.org> [--diff-against <previous-version.org>]
+usage: flashcard-sync <source.org> [--diff-against <previous-version.org>]
EOF
exit 2
}
@@ -53,9 +53,9 @@ if [[ ! -f "$SOURCE" ]]; then
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-STATS="$SCRIPT_DIR/drill-deck-stats.py"
-DIFF_IDS="$SCRIPT_DIR/drill-deck-diff-ids.py"
-TO_ANKI="$SCRIPT_DIR/drill-to-anki.py"
+STATS="$SCRIPT_DIR/flashcard-stats.py"
+DIFF_IDS="$SCRIPT_DIR/flashcard-diff-ids.py"
+TO_ANKI="$SCRIPT_DIR/flashcard-to-anki.py"
for helper in "$STATS" "$DIFF_IDS" "$TO_ANKI"; do
if [[ ! -f "$helper" ]]; then
@@ -64,12 +64,12 @@ for helper in "$STATS" "$DIFF_IDS" "$TO_ANKI"; do
fi
done
-echo "=== drill-deck-sync: $SOURCE ==="
+echo "=== flashcard-sync: $SOURCE ==="
echo
echo "--- stats ---"
if ! python3 "$STATS" "$SOURCE"; then
echo
- echo "stats check failed — fix warnings before sync, or call drill-to-anki.py directly to override" >&2
+ echo "stats check failed — fix warnings before sync, or call flashcard-to-anki.py directly to override" >&2
exit 1
fi
echo
diff --git a/.ai/scripts/drill-to-anki.py b/claude-templates/.ai/scripts/flashcard-to-anki.py
index 9fe954e..7227683 100755
--- a/.ai/scripts/drill-to-anki.py
+++ b/claude-templates/.ai/scripts/flashcard-to-anki.py
@@ -22,9 +22,9 @@ a mobile-Anki artifact the phone picks up from its sync dir, so it lands
there rather than next to the org source.
Usage:
- drill-to-anki.py <input.org>
- drill-to-anki.py <input.org> --deck "My Deck Name"
- drill-to-anki.py <input.org> --output /path/to/deck.apkg
+ flashcard-to-anki.py <input.org>
+ flashcard-to-anki.py <input.org> --deck "My Deck Name"
+ flashcard-to-anki.py <input.org> --output /path/to/deck.apkg
Requires genanki, which uv resolves automatically via the PEP 723
script metadata above. No venv or system install needed.
diff --git a/claude-templates/.ai/scripts/tests/drill-deck-sync.bats b/claude-templates/.ai/scripts/tests/flashcard-sync.bats
index e141cab..608a280 100644
--- a/claude-templates/.ai/scripts/tests/drill-deck-sync.bats
+++ b/claude-templates/.ai/scripts/tests/flashcard-sync.bats
@@ -1,11 +1,11 @@
#!/usr/bin/env bats
-# Tests for the drill-deck-sync wrapper: argument handling + the stats gate.
-# The clean end-to-end path runs drill-to-anki.py (uv-resolved genanki) and is
+# Tests for the flashcard-sync wrapper: argument handling + the stats gate.
+# The clean end-to-end path runs flashcard-to-anki.py (uv-resolved genanki) and is
# not exercised here; these cover the guard paths that stop before that step.
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
- SYNC="$SCRIPT_DIR/drill-deck-sync"
+ SYNC="$SCRIPT_DIR/flashcard-sync"
TMP="$(mktemp -d)"
}
@@ -13,17 +13,17 @@ teardown() {
rm -rf "$TMP"
}
-@test "drill-deck-sync: no args exits 2" {
+@test "flashcard-sync: no args exits 2" {
run "$SYNC"
[ "$status" -eq 2 ]
}
-@test "drill-deck-sync: missing source file exits 2" {
+@test "flashcard-sync: missing source file exits 2" {
run "$SYNC" "$TMP/nope.org"
[ "$status" -eq 2 ]
}
-@test "drill-deck-sync: stats gate failure exits 1 and writes no apkg" {
+@test "flashcard-sync: stats gate failure exits 1 and writes no apkg" {
cat > "$TMP/dirty.org" <<'EOF'
#+TITLE: DeepSat Org-Drill Flashcards
diff --git a/claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py b/claude-templates/.ai/scripts/tests/test_flashcard_diff_ids.py
index 15fb148..9554b48 100644
--- a/claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py
+++ b/claude-templates/.ai/scripts/tests/test_flashcard_diff_ids.py
@@ -1,4 +1,4 @@
-"""Tests for drill-deck-diff-ids.py: :ID: extraction + SRS-state diff CLI.
+"""Tests for flashcard-diff-ids.py: :ID: extraction + SRS-state diff CLI.
Plain python3 script (no third-party deps), so card_id_map imports directly;
the disappeared/appeared reporting is exercised through the CLI.
@@ -12,12 +12,12 @@ from pathlib import Path
import pytest
-SCRIPT = Path(__file__).resolve().parents[1] / "drill-deck-diff-ids.py"
+SCRIPT = Path(__file__).resolve().parents[1] / "flashcard-diff-ids.py"
@pytest.fixture(scope="module")
def diff_ids():
- spec = importlib.util.spec_from_file_location("drill_deck_diff_ids", SCRIPT)
+ spec = importlib.util.spec_from_file_location("flashcard_diff_ids", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
diff --git a/claude-templates/.ai/scripts/tests/test_drill_deck_stats.py b/claude-templates/.ai/scripts/tests/test_flashcard_stats.py
index d60084d..606f7c1 100644
--- a/claude-templates/.ai/scripts/tests/test_drill_deck_stats.py
+++ b/claude-templates/.ai/scripts/tests/test_flashcard_stats.py
@@ -1,4 +1,4 @@
-"""Tests for drill-deck-stats.py: prompt-form heuristic + CLI inventory/gate.
+"""Tests for flashcard-stats.py: prompt-form heuristic + CLI inventory/gate.
Plain python3 script (no third-party deps), so the pure helper imports directly;
the inventory/gate behavior is exercised through the CLI.
@@ -12,12 +12,12 @@ from pathlib import Path
import pytest
-SCRIPT = Path(__file__).resolve().parents[1] / "drill-deck-stats.py"
+SCRIPT = Path(__file__).resolve().parents[1] / "flashcard-stats.py"
@pytest.fixture(scope="module")
def stats():
- spec = importlib.util.spec_from_file_location("drill_deck_stats", SCRIPT)
+ spec = importlib.util.spec_from_file_location("flashcard_stats", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
diff --git a/claude-templates/.ai/scripts/tests/test_drill_to_anki.py b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py
index fc17817..058b0cd 100644
--- a/claude-templates/.ai/scripts/tests/test_drill_to_anki.py
+++ b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py
@@ -1,4 +1,4 @@
-"""Tests for drill-to-anki.py default-path and deck-name helpers.
+"""Tests for flashcard-to-anki.py default-path and deck-name helpers.
The script is a PEP 723 uv-run script that imports genanki, which uv resolves
at runtime but isn't installed in the test environment. The fixture stubs
@@ -14,14 +14,14 @@ from pathlib import Path
import pytest
-SCRIPT = Path(__file__).resolve().parents[1] / "drill-to-anki.py"
+SCRIPT = Path(__file__).resolve().parents[1] / "flashcard-to-anki.py"
@pytest.fixture(scope="module")
def drill():
# Only stub when genanki is genuinely absent, so a real install isn't shadowed.
sys.modules.setdefault("genanki", types.ModuleType("genanki"))
- spec = importlib.util.spec_from_file_location("drill_to_anki", SCRIPT)
+ spec = importlib.util.spec_from_file_location("flashcard_to_anki", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
diff --git a/claude-templates/.ai/workflows/INDEX.org b/claude-templates/.ai/workflows/INDEX.org
index c8554d4..157a4e7 100644
--- a/claude-templates/.ai/workflows/INDEX.org
+++ b/claude-templates/.ai/workflows/INDEX.org
@@ -86,14 +86,16 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e
- Triggers: "page me on signal", "signal me when X is done", "send a signal note about X"
- =cross-project-broadcast.org= — fan out a single message to every AI project's inbox via the discovery helper =cross-project-broadcast.py= + the existing =inbox-send.py=. Use sparingly for capability announcements and shared rule changes; not for project-specific handoffs.
- Triggers: "broadcast this to every project", "notify every project about X", "fan out this announcement", "let every project know X is available"
-- =drill-deck-review.org= — review an org-drill flashcard file, restructure cards to question-form headings (no answer hints), audit content accuracy against project source-of-truth via subagent, rewrite source preserving SRS state, regenerate the Anki =.apkg= to =~/sync/phone/anki/=. Person cards use "Who is X? Tell me about their Y."; talking-points cards stay as-is. Script behavior: =drill-to-anki.py= strips =:PROPERTIES:= drawers + =SCHEDULED:= / =DEADLINE:= planning lines from Anki output.
- - Triggers: "review the drill deck", "update the drill deck", "refresh the Anki cards", "let's run the drill-deck-review workflow"
+- =flashcard-review.org= — review an org-drill flashcard file, restructure cards to question-form headings (no answer hints), audit content accuracy against project source-of-truth via subagent, rewrite source preserving SRS state, regenerate the Anki =.apkg= to =~/sync/phone/anki/=. Person cards use "Who is X? Tell me about their Y."; talking-points cards stay as-is. Script behavior: =flashcard-to-anki.py= strips =:PROPERTIES:= drawers + =SCHEDULED:= / =DEADLINE:= planning lines from Anki output.
+ - Triggers: "review the flashcards", "update the flashcards", "review the drill deck", "update the drill deck", "refresh the Anki cards", "let's run the flashcard-review workflow"
- =page-me.org= — set a timed notification.
- Triggers: anything containing the word "page" used as a verb ("page me", "page me in 10 minutes", "page me at 3pm")
- =status-check.org= — proactive long-running-job updates.
- Triggers: "keep me posted on this", "provide status checks on this job", "let me know when it's done", "monitor this for me". Auto: any job estimated 10+ min.
- =create-workflow.org= — define a new workflow.
- Triggers: "let's create/define/design a workflow for [activity]", or unmatched workflow request after this index returns no hit.
+- =rename-artifact.org= — rename an =.ai/= workflow or script across the canonical + mirror trees, rewriting every reference on a token boundary and leaving =sessions/= history alone. Backed by =scripts/rename-ai-artifact.sh=, which runs =workflow-integrity= + =sync-check= after the move.
+ - Triggers: "rename this workflow", "rename the [X] workflow/script", "let's run the rename-artifact workflow".
- =no-approvals.org= — drop the interaction-level approval gates for a pre-agreed batch while keeping engineering-discipline gates (=/review-code=, =/voice personal=, tests, session-log updates, subagent reviews, destructive-action consent). Mode stays on until Craig turns it off, a real question arises, the queue empties, or the conversation switches topics.
- Triggers: "no-approvals mode", "no approvals", "no-approval", "no need for approval gates", "stop asking, just keep going", "I'll check back in when you're done or stuck", "do all =<selector>= with no-approval"
- =cross-agent-comms.org= — protocol for cross-project agent coordination via =inbox/from-agents/= (file-based IPC, GPG-signed, supports cross-machine over Tailscale). Auto: when =cross-agent-watch= detects a new inbound message, or when an agent decides to initiate a cross-project conversation. Operational scripts (=cross-agent-send=, =-recv=, =-watch=, =-status=, =-discover=, =-halt=, =-resume=) and their READMEs live at =.ai/scripts/cross-agent-comms/=.
diff --git a/claude-templates/.ai/workflows/drill-deck-review.org b/claude-templates/.ai/workflows/flashcard-review.org
index 390f296..31027b3 100644
--- a/claude-templates/.ai/workflows/drill-deck-review.org
+++ b/claude-templates/.ai/workflows/flashcard-review.org
@@ -6,9 +6,9 @@
Take an org-drill flashcard file and bring it into the canonical shape — every card a question that doesn't give the answer away, every fact current — then regenerate the Anki =.apkg= and drop it where the phone can sync it.
-The workflow has three substantive passes (question-form audit, content-accuracy audit, source rewrite) followed by a mechanical regenerate-and-place step. Content review is dispatched to a subagent because it's bounded research across project source-of-truth files; the structural rewrite stays in the main thread because it touches the SRS state we don't want to lose. Three helper scripts (=drill-deck-stats.py=, =drill-deck-diff-ids.py=, =drill-deck-sync=) automate the inventory, the safety check, and the regenerate-and-place.
+The workflow has three substantive passes (question-form audit, content-accuracy audit, source rewrite) followed by a mechanical regenerate-and-place step. Content review is dispatched to a subagent because it's bounded research across project source-of-truth files; the structural rewrite stays in the main thread because it touches the SRS state we don't want to lose. Three helper scripts (=flashcard-stats.py=, =flashcard-diff-ids.py=, =flashcard-sync=) automate the inventory, the safety check, and the regenerate-and-place.
-*Scheduling lives on the Anki side.* Desired retention and the FSRS scheduling model are per-deck Anki options set on the phone, never controlled by the org source or =drill-to-anki.py=. The pipeline's only scheduling job is keeping each card's identity (the =:ID:=-derived GUID) stable so Anki's review history survives a rewrite. Don't try to encode retention, intervals, or org-drill's SM-2 state into the Anki output — the two schedulers are separate, and the import carries only card content plus identity. (Anki's desired-retention default is 90%; see [[https://docs.ankiweb.net/deck-options.html][the deck-options manual]].)
+*Scheduling lives on the Anki side.* Desired retention and the FSRS scheduling model are per-deck Anki options set on the phone, never controlled by the org source or =flashcard-to-anki.py=. The pipeline's only scheduling job is keeping each card's identity (the =:ID:=-derived GUID) stable so Anki's review history survives a rewrite. Don't try to encode retention, intervals, or org-drill's SM-2 state into the Anki output — the two schedulers are separate, and the import carries only card content plus identity. (Anki's desired-retention default is 90%; see [[https://docs.ankiweb.net/deck-options.html][the deck-options manual]].)
* When to Use This Workflow
@@ -17,7 +17,7 @@ Trigger phrases:
- "Review the drill deck"
- "Update the drill deck"
- "Refresh the Anki cards"
-- "Let's run the drill-deck-review workflow"
+- "Let's run the flashcard-review workflow"
Typical timing:
@@ -36,7 +36,7 @@ Typical timing:
- Project-root =knowledge.org=, =status.org=, =notes.org=
- =todo.org= for the freshest signal on people / partnerships / projects
- =deepsat/assets/= (or equivalent) for meeting transcripts when a specific fact needs confirmation
-- *Output location*: =~/sync/phone/anki/<basename>.apkg= (the phone-sync target). Both =drill-to-anki.py= and the =drill-deck-sync= wrapper default there.
+- *Output location*: =~/sync/phone/anki/<basename>.apkg= (the phone-sync target). Both =flashcard-to-anki.py= and the =flashcard-sync= wrapper default there.
* Canonical Card Shape
@@ -47,9 +47,9 @@ The =#+TITLE:= line at the top of the source file drives two surfaces: the org-d
Good: =DeepSat Flashcards=, =Health Flashcards=, =Philosophy Flashcards=.
Bad: =DeepSat Org-Drill Flashcards=, =DeepSat Drill Deck=.
-=drill-deck-stats.py= flags any title containing =org-drill= (case-insensitive, hyphenated or spaced) as a workflow violation.
+=flashcard-stats.py= flags any title containing =org-drill= (case-insensitive, hyphenated or spaced) as a workflow violation.
-*Stable-ID caveat.* =drill-to-anki.py= derives the Anki deck ID from the deck name. Changing =#+TITLE:= changes the deck ID, so the next import lands as a new deck rather than updating the existing one. Two consequences worth flagging:
+*Stable-ID caveat.* =flashcard-to-anki.py= derives the Anki deck ID from the deck name. Changing =#+TITLE:= changes the deck ID, so the next import lands as a new deck rather than updating the existing one. Two consequences worth flagging:
- Any review history accumulated in Anki under the old deck name stays attached to the old deck — it doesn't migrate.
- On rename, delete the old deck from Anki to avoid having two decks with similar content.
@@ -90,12 +90,12 @@ Format: "Who is X? Tell me about their Y." where X is a role descriptor that doe
Note: pick a role descriptor that genuinely identifies one person. If multiple people share the role description, add a single distinguishing detail (e.g., "the one who works evenings", "the Vineti alum"). Don't pile on parentheticals.
- Splitting: the person card deliberately trades atomicity for narrative recall — one card carries identity plus several attributes. When a body bundles genuinely unrelated attributes (role, employment history, limitations, scope) rather than one coherent topic, split it into multiple cards. One inherits the existing =:ID:= (and its SRS history); each new sibling starts fresh and will correctly show in =drill-deck-diff-ids.py= as an appeared ID. The criterion: split when the body reads as a list of separate facts, keep it whole when it reads as one story. (Minimum-information principle — Wozniak rule 4, Matuschak "Focused".)
+ Splitting: the person card deliberately trades atomicity for narrative recall — one card carries identity plus several attributes. When a body bundles genuinely unrelated attributes (role, employment history, limitations, scope) rather than one coherent topic, split it into multiple cards. One inherits the existing =:ID:= (and its SRS history); each new sibling starts fresh and will correctly show in =flashcard-diff-ids.py= as an appeared ID. The criterion: split when the body reads as a list of separate facts, keep it whole when it reads as one story. (Minimum-information principle — Wozniak rule 4, Matuschak "Focused".)
*** Talking-points and directive cards
Already in prompt form ("Introduce Yourself", "Spell out these orbital regime acronyms", "What is DeepSat?"). Leave the heading alone. Still strip the =*** Answer= sub-header and audit the body content for staleness.
-The =drill-deck-stats.py= helper recognizes both =?=-form and imperative-verb form as valid prompts (verbs like Spell, Describe, Explain, Name, List, Give, Show, Tell, Define, Compare, Identify, Outline, Introduce, Walk, State, Recite, Recall, Summarize).
+The =flashcard-stats.py= helper recognizes both =?=-form and imperative-verb form as valid prompts (verbs like Spell, Describe, Explain, Name, List, Give, Show, Tell, Define, Compare, Identify, Outline, Introduce, Walk, State, Recite, Recall, Summarize).
** Body (the answer)
@@ -103,16 +103,16 @@ The =drill-deck-stats.py= helper recognizes both =?=-form and imperative-verb fo
- *Body opens by naming the topic.* "Air Force Research Laboratory. Air Force's R&D arm." or "Vrezh Mikayelyan. Armenia-based, full-time as of ..." The Anki back shows this directly under the front question; restating the topic makes the back read as a complete answer.
- *PROPERTIES drawer stays.* Org-drill needs the =:ID:=, =:DRILL_LAST_INTERVAL:=, =:DRILL_EASE:= etc. for SRS state. The Anki output strips it (see the script change).
- *=SCHEDULED:= / =DEADLINE:= planning lines stay.* Same reason. The Anki output strips them.
-- *Source citation goes at the very end, after two blank lines.* When a card cites a source, put a =Source: <label> — <url>= line at the end of the body, separated from the answer by two blank lines (two empty paragraphs) so it reads as a footer, not part of the answer. =drill-deck-stats.py= ignores =Source:= lines when checking for answer leakage, since a URL slug often repeats the question's words.
+- *Source citation goes at the very end, after two blank lines.* When a card cites a source, put a =Source: <label> — <url>= line at the end of the body, separated from the answer by two blank lines (two empty paragraphs) so it reads as a footer, not part of the answer. =flashcard-stats.py= ignores =Source:= lines when checking for answer leakage, since a URL slug often repeats the question's words.
- *No created/added date on the card.* Don't stamp a card with the date it was written. If a card body carries a =Created:= line (or a =:CREATED:= line outside the drawer), remove it during the rewrite. The Anki output strips =Created:= lines as a backstop, but they shouldn't be in the source either. Volatile facts get dated in the answer prose itself ("full-time as of April 2026"), never via a card-level timestamp.
* Card Authoring Principles
-The canonical shapes above are the house style; these are the reasons behind them, drawn from the spaced-repetition literature. =drill-deck-stats.py= checks the mechanical ones; the rest guide the rewrite and the content pass.
+The canonical shapes above are the house style; these are the reasons behind them, drawn from the spaced-repetition literature. =flashcard-stats.py= checks the mechanical ones; the rest guide the rewrite and the content pass.
-- *One fact per card (minimum information principle).* A card should test a single retrievable connection. A back that bundles several independent facts gets partially recalled and burns repetitions on the parts you already know. When a body covers unrelated attributes, split it into separate cards. =drill-deck-stats.py= flags long backs as a non-blocking NOTE.
+- *One fact per card (minimum information principle).* A card should test a single retrievable connection. A back that bundles several independent facts gets partially recalled and burns repetitions on the parts you already know. When a body covers unrelated attributes, split it into separate cards. =flashcard-stats.py= flags long backs as a non-blocking NOTE.
-- *Demand recall, not recognition (effortful retrieval).* Pulling the answer from memory is what strengthens it, so the question must not let you infer the answer from its own wording. This is why person headings never name the person, and why a question that restates its own answer is a defect. =drill-deck-stats.py= flags high front/back word overlap as answer leakage — excluding =Source:= citation lines, and exempting range/category cards whose answer recalls numbers the question doesn't give away.
+- *Demand recall, not recognition (effortful retrieval).* Pulling the answer from memory is what strengthens it, so the question must not let you infer the answer from its own wording. This is why person headings never name the person, and why a question that restates its own answer is a defect. =flashcard-stats.py= flags high front/back word overlap as answer leakage — excluding =Source:= citation lines, and exempting range/category cards whose answer recalls numbers the question doesn't give away.
- *Avoid binary prompts.* "Is X true?" and "A or B?" allow a coin-flip guess and produce shallow understanding. Reformulate open-ended — "How does X affect Y?" beats "Does X affect Y?" Flagged as a non-blocking NOTE.
@@ -120,7 +120,7 @@ The canonical shapes above are the house style; these are the reasons behind the
- *Make cues precise.* A vague question admits several reasonable answers, so you can't tell whether you knew the intended one. Include enough context that only the intended answer fits, without narrowing into provincial trivia.
-- *Combat interference.* Confusable cards inhibit each other; two near-identical fronts are the worst case. Disambiguate them with distinguishing context, or merge them. =drill-deck-stats.py= flags duplicate / near-duplicate fronts.
+- *Combat interference.* Confusable cards inhibit each other; two near-identical fronts are the worst case. Disambiguate them with distinguishing context, or merge them. =flashcard-stats.py= flags duplicate / near-duplicate fronts.
- *Understand before you memorize.* Cards are the last step, after the material is understood and structured. A card you can't explain is a leech waiting to happen.
@@ -130,10 +130,10 @@ Sources: Wozniak's [[https://www.supermemo.com/en/blog/twenty-rules-of-formulati
** Phase A: Question-form + title audit (per card and per file)
-Run =drill-deck-stats.py= on the source first to get the structural inventory:
+Run =flashcard-stats.py= on the source first to get the structural inventory:
#+begin_src bash
-.ai/scripts/drill-deck-stats.py <source.org>
+.ai/scripts/flashcard-stats.py <source.org>
#+end_src
The script reports the deck title from =#+TITLE:= (and flags it if it contains source-tool jargon like "Org-Drill"), card count, PROPERTIES-drawer count, =*** Answer= sub-header count, cards missing =:ID:=, and cards whose heading is neither =?=-form nor an imperative-verb prompt. It also flags possible answer leakage and duplicate / near-duplicate fronts (both blocking), and surfaces non-blocking NOTEs for overloaded, list-shaped, or binary cards. Each surfaced card is a candidate for the rewrite, plus the title itself if flagged.
@@ -196,27 +196,27 @@ For the file as a whole, use a single =Write= rather than per-card =Edit= calls.
** Phase D: Regenerate the Anki deck
-Use the =drill-deck-sync= wrapper — it runs the stats check, optionally the ID-preservation check, then regenerates the apkg and places it at =~/sync/phone/anki/=:
+Use the =flashcard-sync= wrapper — it runs the stats check, optionally the ID-preservation check, then regenerates the apkg and places it at =~/sync/phone/anki/=:
#+begin_src bash
-.ai/scripts/drill-deck-sync <source.org> --diff-against <previous-version.org>
+.ai/scripts/flashcard-sync <source.org> --diff-against <previous-version.org>
#+end_src
The =--diff-against= flag is recommended on any rewrite where you want to confirm zero card IDs disappeared (zero SRS-state loss). The "previous version" is typically the file as it was before this run; grab it from git with =git show HEAD~1:<path> > /tmp/<name>-prerewrite.org=. Skip =--diff-against= on a first run when there's no previous version to compare against.
If the stats check or ID-preservation check fails, the wrapper exits non-zero and the apkg is not written. Fix the warnings, then re-run.
-To bypass the safety gates (rare, only when you know what you're doing), call =drill-to-anki.py= directly:
+To bypass the safety gates (rare, only when you know what you're doing), call =flashcard-to-anki.py= directly:
#+begin_src bash
-.ai/scripts/drill-to-anki.py <source.org> --output ~/sync/phone/anki/<basename>.apkg
+.ai/scripts/flashcard-to-anki.py <source.org> --output ~/sync/phone/anki/<basename>.apkg
#+end_src
The script writes the =.apkg= with stable deck/model IDs derived from the deck name, so re-importing into Anki updates existing cards rather than duplicating them.
** Phase E: Verify
-The =drill-deck-sync= wrapper covers the structural verify automatically (stats + diff-ids if =--diff-against= was passed). After it succeeds, do a quick visual spot-check:
+The =flashcard-sync= wrapper covers the structural verify automatically (stats + diff-ids if =--diff-against= was passed). After it succeeds, do a quick visual spot-check:
- Confirm the apkg size matches expectations. Significant changes are expected on a big rewrite; a wildly smaller file may mean the parser dropped cards.
- Open the source in Emacs (or =head -100=) and confirm a few cards visually: question heading, no =*** Answer=, PROPERTIES preserved, body opens with topic name.
@@ -225,8 +225,8 @@ The =drill-deck-sync= wrapper covers the structural verify automatically (stats
For ad-hoc verification on either side of a rewrite, run the individual scripts:
#+begin_src bash
-.ai/scripts/drill-deck-stats.py <source.org>
-.ai/scripts/drill-deck-diff-ids.py <before.org> <after.org>
+.ai/scripts/flashcard-stats.py <source.org>
+.ai/scripts/flashcard-diff-ids.py <before.org> <after.org>
#+end_src
** Phase F: Commit
@@ -234,7 +234,7 @@ For ad-hoc verification on either side of a rewrite, run the individual scripts:
Two clusters:
- *Source rewrite*: the org file (e.g., =deepsat.org=). Commit subject: =chore(drill): restructure cards to question-form headings + content refresh=. Body lists the content-update categories (Vrezh full-time, DCVC passed, etc.) and notes that =*** Answer= sub-headers were dropped.
-- *Workflow / script changes* (if any): if this run prompted updates to =drill-deck-review.org= or the helper scripts in the rulesets repo, commit those separately with =chore(workflows):= or =chore(scripts):= subjects.
+- *Workflow / script changes* (if any): if this run prompted updates to =flashcard-review.org= or the helper scripts in the rulesets repo, commit those separately with =chore(workflows):= or =chore(scripts):= subjects.
Push both. The =.apkg= itself lives under =~/sync/phone/= which is outside the repo — no commit needed there; Syncthing (or whatever sync mechanism) handles propagation.
@@ -242,39 +242,39 @@ Push both. The =.apkg= itself lives under =~/sync/phone/= which is outside the r
Three scripts under =.ai/scripts/= (canonical lives in =rulesets/claude-templates/.ai/scripts/=):
-** =drill-to-anki.py=
+** =flashcard-to-anki.py=
The core converter. Reads an org-drill source file, emits a stable-ID Anki =.apkg=. Strips =:PROPERTIES:= drawers and =SCHEDULED:= / =DEADLINE:= / =CLOSED:= planning lines from card bodies before rendering. Front = heading text without =:drill:=. Back = cleaned body, HTML-escaped, joined with =<br>=. Deck and model IDs derived from the deck name + a salt, so re-imports update existing cards rather than duplicating.
-** =drill-deck-stats.py=
+** =flashcard-stats.py=
-Inventory + authoring-quality checks for a single deck source. Counts cards, PROPERTIES drawers, =*** Answer= sub-headers, cards missing =:ID:=, and cards whose heading is neither =?=-form nor an imperative-verb prompt. It also checks authoring quality: answer leakage (front/back content-word overlap) and duplicate / near-duplicate fronts are blocking WARNs; overloaded backs, list-shaped backs, and binary prompts are non-blocking NOTEs. Exits 0 when no blocking warning is present, 1 otherwise, so it gates =drill-deck-sync=. The leakage check ignores =Source:= and created-date lines and exempts range/category cards whose answer recalls numbers the question doesn't give away.
+Inventory + authoring-quality checks for a single deck source. Counts cards, PROPERTIES drawers, =*** Answer= sub-headers, cards missing =:ID:=, and cards whose heading is neither =?=-form nor an imperative-verb prompt. It also checks authoring quality: answer leakage (front/back content-word overlap) and duplicate / near-duplicate fronts are blocking WARNs; overloaded backs, list-shaped backs, and binary prompts are non-blocking NOTEs. Exits 0 when no blocking warning is present, 1 otherwise, so it gates =flashcard-sync=. The leakage check ignores =Source:= and created-date lines and exempts range/category cards whose answer recalls numbers the question doesn't give away.
Imperative-verb allowlist: Spell, Describe, Explain, Name, List, Give, Show, Tell, Define, Compare, Identify, Outline, Introduce, Walk, State, Recite, Recall, Summarize.
The fuzzy checks (leakage ratio, overloaded word count) are tuned by the =LEAKAGE_*= and =BACK_WORD_LIMIT= constants at the top of the script. Loosen them if a real deck trips false positives.
-** =drill-deck-diff-ids.py=
+** =flashcard-diff-ids.py=
SRS-state preservation check between two versions of a deck. Extracts every =:ID:= from each, reports IDs that disappeared (lost SRS state — worst-case bug) or appeared (new cards). Exits 0 when clean, 1 when any disappeared/appeared.
-** =drill-deck-sync= (bash wrapper)
+** =flashcard-sync= (bash wrapper)
-Single command for the canonical "rewrote the deck, now ship it" step. Runs =drill-deck-stats=, optionally =drill-deck-diff-ids= (with =--diff-against=), then =drill-to-anki= writing to =~/sync/phone/anki/<basename>.apkg=. Exits non-zero if any gate fails; the apkg is not written when a gate fails.
+Single command for the canonical "rewrote the deck, now ship it" step. Runs =flashcard-stats=, optionally =flashcard-diff-ids= (with =--diff-against=), then =flashcard-to-anki= writing to =~/sync/phone/anki/<basename>.apkg=. Exits non-zero if any gate fails; the apkg is not written when a gate fails.
Usage:
#+begin_src bash
-drill-deck-sync <source.org>
-drill-deck-sync <source.org> --diff-against <previous-version.org>
+flashcard-sync <source.org>
+flashcard-sync <source.org> --diff-against <previous-version.org>
#+end_src
* Anki Script Behavior
-The =drill-to-anki.py= script has these contracts that this workflow depends on:
+The =flashcard-to-anki.py= script has these contracts that this workflow depends on:
1. *Strips =:PROPERTIES:= drawers* from the card body before rendering. Org-drill needs them in source; Anki cards shouldn't show them.
2. *Strips =SCHEDULED:= / =DEADLINE:= / =CLOSED:= planning lines and =Created:= / =:CREATED:= date lines* from the card body. Same reason — and a created date never belongs on a card.
-3. *Does NOT strip =*** Answer= sub-headers.* If the source still has them, the Anki cards will show them. This workflow's Phase C removes them at the source. =drill-deck-stats.py= flags any remaining as a workflow violation.
+3. *Does NOT strip =*** Answer= sub-headers.* If the source still has them, the Anki cards will show them. This workflow's Phase C removes them at the source. =flashcard-stats.py= flags any remaining as a workflow violation.
4. *Front of each Anki card* = the heading text without the =:drill:= tag.
5. *Back of each Anki card* = the cleaned body (after #1 and #2), joined with =<br>= and HTML-escaped.
6. *Stable IDs* derived from the deck name + a salt, so re-importing the same deck name updates cards rather than duplicating.
@@ -283,22 +283,22 @@ If you find the script doing something else, update the script before regenerati
* Output Path Convention
-- Default in =drill-to-anki.py=: =~/sync/phone/anki/<basename>.apkg=.
-- Default in =drill-deck-sync=: =~/sync/phone/anki/<basename>.apkg= (same target; the wrapper passes =--output= explicitly).
+- Default in =flashcard-to-anki.py=: =~/sync/phone/anki/<basename>.apkg=.
+- Default in =flashcard-sync=: =~/sync/phone/anki/<basename>.apkg= (same target; the wrapper passes =--output= explicitly).
=~/sync/org/drill/= holds the org sources and their symlinks; =~/sync/phone/anki/= holds the =.apkg= the phone consumes. Both tools write the =.apkg= to the phone dir by default, so a deck lands where Anki picks it up without an =--output= override.
* Common Mistakes
1. *Per-card =Edit= calls instead of one =Write=.* Multiplies tool calls and risks drift between cards. Read once, rewrite in memory, write once.
-2. *Dropping the PROPERTIES drawer in source.* Org-drill stores SRS state there; losing it resets every card's review history. =drill-deck-diff-ids.py= is the safety net.
+2. *Dropping the PROPERTIES drawer in source.* Org-drill stores SRS state there; losing it resets every card's review history. =flashcard-diff-ids.py= is the safety net.
3. *Rewriting person headings to include the name.* "Who is Vrezh Mikayelyan?" gives away the answer. The whole point is to test name recall from a role description.
-4. *Forgetting to strip =*** Answer= sub-headers.* The Anki output will show them as visible card content. =drill-deck-stats.py= catches this.
+4. *Forgetting to strip =*** Answer= sub-headers.* The Anki output will show them as visible card content. =flashcard-stats.py= catches this.
5. *Skipping the content-accuracy pass.* The structural rewrite alone leaves stale facts in place. The drill cards become a memorization tool for the wrong information.
6. *Treating subagent output as gospel.* Medium- and low-confidence findings need human review before baking. The subagent surfaces; the main thread decides.
-7. *Running =drill-deck-sync= without =--diff-against=.* The stats check still runs, but the SRS-state preservation check doesn't. On a rewrite of any size, pass =--diff-against /tmp/<name>-prerewrite.org= (grab from git first).
-8. *Answer leakage.* A question that restates its own answer tests recognition, not recall — the card looks learned when it isn't. =drill-deck-stats.py= flags high front/back word overlap.
-9. *Encoding scheduling in the source.* Retention, intervals, and FSRS state are Anki-side options; the org files and =drill-to-anki.py= carry only card content plus identity. See the scheduling note in the Overview.
+7. *Running =flashcard-sync= without =--diff-against=.* The stats check still runs, but the SRS-state preservation check doesn't. On a rewrite of any size, pass =--diff-against /tmp/<name>-prerewrite.org= (grab from git first).
+8. *Answer leakage.* A question that restates its own answer tests recognition, not recall — the card looks learned when it isn't. =flashcard-stats.py= flags high front/back word overlap.
+9. *Encoding scheduling in the source.* Retention, intervals, and FSRS state are Anki-side options; the org files and =flashcard-to-anki.py= carry only card content plus identity. See the scheduling note in the Overview.
* Living Document
@@ -307,7 +307,7 @@ Update this workflow as patterns emerge. Specifically:
- New card family beyond acronym / person / talking-point → document the heading shape for it.
- New source-of-truth doc beyond the standard set → add to Phase B's dispatch contract.
- Script behavior changes → mirror them in the "Anki Script Behavior" section.
-- New imperative-verb prompt forms → add the verb to =drill-deck-stats.py=' s allowlist.
+- New imperative-verb prompt forms → add the verb to =flashcard-stats.py=' s allowlist.
** Updates and Learnings
@@ -315,13 +315,13 @@ Update this workflow as patterns emerge. Specifically:
Built against =deepsat.org= after Craig flagged that the existing apkg surfaced PROPERTIES drawers + =*** Answer= headers on the back of every card, and that the person-card content (Vrezh in particular) had drifted. The Phase B subagent surfaced 8 high-confidence content updates plus several medium-confidence enrichments. Validated by running the rewrite and regenerating =deepsat.apkg= to =~/sync/phone/anki/=.
*** 2026-05-30: Helper scripts added (same day)
-After the first run, scripted the safety-net checks into three helpers: =drill-deck-stats.py= (inventory + warnings), =drill-deck-diff-ids.py= (SRS-state preservation between versions), and =drill-deck-sync= (single-command wrapper). Stats check on the deepsat rewrite flushed a heuristic bug — directive prompts ("Spell out these orbital regime acronyms", "Introduce Yourself") were flagged as non-question. Fix: =drill-deck-stats.py= now accepts =?=-form OR imperative-verb-start (Spell, Describe, Explain, ..., Recall) as valid prompt forms.
+After the first run, scripted the safety-net checks into three helpers: =flashcard-stats.py= (inventory + warnings), =flashcard-diff-ids.py= (SRS-state preservation between versions), and =flashcard-sync= (single-command wrapper). Stats check on the deepsat rewrite flushed a heuristic bug — directive prompts ("Spell out these orbital regime acronyms", "Introduce Yourself") were flagged as non-question. Fix: =flashcard-stats.py= now accepts =?=-form OR imperative-verb-start (Spell, Describe, Explain, ..., Recall) as valid prompt forms.
*** 2026-05-30: Title-audit added (same day)
-Craig noticed the Anki deck name still showed as "DeepSat Org-Drill Flashcards" because the source =#+TITLE:= leaks tool-name jargon into Anki. Added a "Deck title" subsection under Canonical Card Shape, expanded Phase A to audit the title, and extended =drill-deck-stats.py= to flag any title matching =org[-\s]?drill= (case-insensitive). Stable-ID caveat documented: renaming the deck changes the Anki deck ID, so the next import lands as a new deck and the old one needs deleting from Anki.
+Craig noticed the Anki deck name still showed as "DeepSat Org-Drill Flashcards" because the source =#+TITLE:= leaks tool-name jargon into Anki. Added a "Deck title" subsection under Canonical Card Shape, expanded Phase A to audit the title, and extended =flashcard-stats.py= to flag any title matching =org[-\s]?drill= (case-insensitive). Stable-ID caveat documented: renaming the deck changes the Anki deck ID, so the next import lands as a new deck and the old one needs deleting from Anki.
*** 2026-05-30: Authoring-quality checks + Card Authoring section (same day)
-Researched flashcard / spaced-repetition best practices (Wozniak's twenty rules, Matuschak's prompt-writing guide, Nielsen, the Anki manual, the FSRS docs) and folded the findings in. =drill-deck-stats.py= gained answer-leakage and duplicate-front checks (blocking), plus non-blocking NOTEs for overloaded backs, list-shaped backs, and binary prompts. Added a "Card Authoring Principles" section (the why behind the canonical shapes), a person-card splitting path, a Phase B cost-benefit-removal + leech-feedback disposition, and a scheduling-is-Anki-side note in the Overview. Deliberately not adopted, with reasons: cloze cards (would need a second note type and an authoring convention), per-card tractability targeting and FSRS-retention encoding (Anki-side telemetry that never flows back to the source), on-face source-stamping (the converter strips those drawers by design; provenance stays in the org layer).
+Researched flashcard / spaced-repetition best practices (Wozniak's twenty rules, Matuschak's prompt-writing guide, Nielsen, the Anki manual, the FSRS docs) and folded the findings in. =flashcard-stats.py= gained answer-leakage and duplicate-front checks (blocking), plus non-blocking NOTEs for overloaded backs, list-shaped backs, and binary prompts. Added a "Card Authoring Principles" section (the why behind the canonical shapes), a person-card splitting path, a Phase B cost-benefit-removal + leech-feedback disposition, and a scheduling-is-Anki-side note in the Overview. Deliberately not adopted, with reasons: cloze cards (would need a second note type and an authoring convention), per-card tractability targeting and FSRS-retention encoding (Anki-side telemetry that never flows back to the source), on-face source-stamping (the converter strips those drawers by design; provenance stays in the org layer).
*** 2026-05-30: Leakage false-positive fixes + source/created-date conventions (same day)
-Health ran the leakage check on a 43-card deck and hit two false-positive classes. Fixed both in =drill-deck-stats.py=: =Source:= citation lines are stripped before the overlap is computed (a URL slug repeats the question's words), and range/category cards whose answer carries numeric ranges or thresholds the question lacks are exempted (the recalled content is the numbers, which aren't given away). Codified two body conventions: a =Source:= citation sits at the end of the card after two blank lines, and no created/added date goes on a card. =drill-to-anki.py= now strips =Created:= / =:CREATED:= lines from the back as a backstop, and Phase C removes them from the source during the rewrite.
+Health ran the leakage check on a 43-card deck and hit two false-positive classes. Fixed both in =flashcard-stats.py=: =Source:= citation lines are stripped before the overlap is computed (a URL slug repeats the question's words), and range/category cards whose answer carries numeric ranges or thresholds the question lacks are exempted (the recalled content is the numbers, which aren't given away). Codified two body conventions: a =Source:= citation sits at the end of the card after two blank lines, and no created/added date goes on a card. =flashcard-to-anki.py= now strips =Created:= / =:CREATED:= lines from the back as a backstop, and Phase C removes them from the source during the rewrite.
diff --git a/claude-templates/.ai/workflows/rename-artifact.org b/claude-templates/.ai/workflows/rename-artifact.org
new file mode 100644
index 0000000..7b9f15b
--- /dev/null
+++ b/claude-templates/.ai/workflows/rename-artifact.org
@@ -0,0 +1,44 @@
+#+TITLE: Rename an .ai Artifact
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-05-31
+
+* Summary
+
+Rename a workflow or a script that lives in the =.ai/= tree, without leaving the canonical copy, the mirror copy, the INDEX, or any reference behind. The work is mechanical and easy to do incompletely by hand — a missed reference or a forgotten mirror copy is the classic failure. The =rename-ai-artifact.sh= script does it the same way every time, so reach for the script rather than renaming files by hand.
+
+Quick contract — what it does:
+- Moves the artifact in both =claude-templates/.ai/= (canonical) and =.ai/= (mirror), in lockstep.
+- Rewrites every reference to the artifact's stem, repo-wide, on a token boundary so renaming =foo= can't corrupt =foobar= or =foo-bar=.
+- Rewrites the underscore module-name variant too — a hyphenated script imported as =foo_bar= via importlib, not just the =foo-bar.py= path.
+- Leaves =sessions/= (both trees) untouched — archived session records are history.
+- Runs =workflow-integrity.py= and =sync-check.sh= afterward to prove no drift.
+
+* Execution
+
+Run once per file. For a family (a workflow plus its helper scripts), run it once per artifact; order doesn't matter because reference matching is token-bounded.
+
+#+begin_example
+scripts/rename-ai-artifact.sh OLD-BASENAME NEW-BASENAME
+
+# examples
+scripts/rename-ai-artifact.sh old-workflow.org new-workflow.org
+scripts/rename-ai-artifact.sh old-helper.py new-helper.py
+#+end_example
+
+After the last file in a family is renamed, review =git status= and the diff, then commit through the normal publish flow.
+
+* Reference
+
+** When to use
+
+Any time an =.ai/= workflow or script changes name. Also the right tool when only the *concept* is being renamed and you want every trigger phrase, =file:= link, and prose mention updated to match.
+
+** What it will not do
+
+- It won't touch =sessions/= records (history stays accurate to when it was written).
+- It won't rename across directories — the artifact keeps its home directory; only the basename changes.
+- It renames one artifact per call and refuses if the source is missing or the target name already exists.
+
+** If a verify step reports drift
+
+The script still completes the rename, then warns. Read the =workflow-integrity= / =sync-check= output, fix by hand (usually a stale INDEX row the stem rewrite didn't reach because the row used different wording), and re-run the checks before committing.
diff --git a/scripts/rename-ai-artifact.sh b/scripts/rename-ai-artifact.sh
new file mode 100755
index 0000000..9af6326
--- /dev/null
+++ b/scripts/rename-ai-artifact.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+# Rename an .ai artifact (a workflow or a script) across the canonical + mirror
+# trees, rewriting every reference to it and leaving archived session records
+# alone. Encodes the rename discipline so it isn't re-derived by hand each time:
+#
+# - canonical (claude-templates/.ai/) and mirror (.ai/) move in lockstep
+# - every reference to the artifact's stem is rewritten, repo-wide
+# - .ai/sessions/ (both trees) is history — never edited
+# - references match on a token boundary, so renaming `foo` can't corrupt
+# `foobar` or `foo-bar`
+# - the underscore module-name variant is rewritten too (a hyphenated script
+# imported as `foo_bar` via importlib), not just the hyphenated path
+# - workflow-integrity.py + sync-check.sh run at the end to prove no drift
+#
+# Usage: rename-ai-artifact.sh OLD-BASENAME NEW-BASENAME
+# e.g. rename-ai-artifact.sh old-workflow.org new-workflow.org
+# rename-ai-artifact.sh old-helper.py new-helper.py
+#
+# Renames ONE artifact per call; run it once per file in a family. Order within
+# a family doesn't matter — token-boundary matching keeps shared prefixes apart.
+#
+# Exit: 0 renamed (verify may still warn); 1 usage / not-found / target-exists.
+
+set -euo pipefail
+
+OLD="${1:-}"
+NEW="${2:-}"
+
+if [ -z "$OLD" ] || [ -z "$NEW" ]; then
+ echo "usage: rename-ai-artifact.sh OLD-BASENAME NEW-BASENAME" >&2
+ exit 1
+fi
+if [ "$OLD" = "$NEW" ]; then
+ echo "rename: OLD and NEW are identical ($OLD)" >&2
+ exit 1
+fi
+
+REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+if [ -z "$REPO_ROOT" ]; then
+ echo "rename: not inside a git repository" >&2
+ exit 1
+fi
+CANON="$REPO_ROOT/claude-templates/.ai"
+MIRROR="$REPO_ROOT/.ai"
+
+# Locate OLD under the canonical tree (the source of truth). Exactly one match.
+mapfile -t hits < <(find "$CANON" -type f -name "$OLD" 2>/dev/null)
+if [ "${#hits[@]}" -eq 0 ]; then
+ echo "rename: artifact not found under canonical tree: $OLD" >&2
+ exit 1
+fi
+if [ "${#hits[@]}" -gt 1 ]; then
+ echo "rename: $OLD is ambiguous (${#hits[@]} matches under $CANON):" >&2
+ printf ' %s\n' "${hits[@]}" >&2
+ exit 1
+fi
+canon_old="${hits[0]}"
+relpath="${canon_old#"$CANON"/}" # e.g. workflows/foo.org
+reldir="$(dirname "$relpath")"
+canon_new="$CANON/$reldir/$NEW"
+mirror_old="$MIRROR/$relpath"
+mirror_new="$MIRROR/$reldir/$NEW"
+
+if [ ! -f "$mirror_old" ]; then
+ echo "rename: canonical has $relpath but the mirror copy is missing: $mirror_old" >&2
+ exit 1
+fi
+for n in "$canon_new" "$mirror_new"; do
+ if [ -e "$n" ]; then
+ echo "rename: target already exists: $n" >&2
+ exit 1
+ fi
+done
+
+# Stems drive reference rewriting: foo.org -> foo, foo-helper.py -> foo-helper,
+# a no-extension script keeps its whole name. The stem replacement also covers
+# the extensioned form (foo.org), since the "." after the stem is a boundary.
+old_stem="${OLD%.*}"
+new_stem="${NEW%.*}"
+# Python imports a hyphenated script under an underscored module name
+# (importlib.spec_from_file_location("foo_bar", "foo-bar.py")). Rewrite that
+# variant too so the module-name reference isn't left behind.
+old_us="${old_stem//-/_}"
+new_us="${new_stem//-/_}"
+
+echo "rename: $relpath"
+echo " $OLD -> $NEW (stem: $old_stem -> $new_stem)"
+
+git -C "$REPO_ROOT" mv "$canon_old" "$canon_new"
+git -C "$REPO_ROOT" mv "$mirror_old" "$mirror_new"
+echo " moved in canonical and mirror"
+
+# Rewrite references repo-wide across tracked files, except archived session
+# records (history) and the two files just renamed (already carry the new name
+# in their path; their bodies get the same stem rewrite as everything else).
+rewritten=0
+while IFS= read -r f; do
+ case "$f" in
+ */sessions/*) continue ;; # history — never edit
+ esac
+ # Token-boundary replace: not preceded/followed by an identifier char or '-'.
+ # Rewrite the hyphenated stem, then the underscored variant when it differs.
+ PERL_OLD="$old_stem" PERL_NEW="$new_stem" perl -i -pe \
+ 's/(?<![A-Za-z0-9_-])\Q$ENV{PERL_OLD}\E(?![A-Za-z0-9_-])/$ENV{PERL_NEW}/g' "$f" 2>/dev/null || true
+ if [ "$old_us" != "$old_stem" ]; then
+ PERL_OLD="$old_us" PERL_NEW="$new_us" perl -i -pe \
+ 's/(?<![A-Za-z0-9_-])\Q$ENV{PERL_OLD}\E(?![A-Za-z0-9_-])/$ENV{PERL_NEW}/g' "$f" 2>/dev/null || true
+ fi
+done < <(git -C "$REPO_ROOT" grep -lI --untracked -e "$old_stem" -e "$old_us" 2>/dev/null || true)
+
+# Count files that still changed (git sees them as modified).
+rewritten="$(git -C "$REPO_ROOT" status --porcelain | grep -c '^ *M' || true)"
+echo " rewrote references (${rewritten} file(s) modified, sessions/ left as history)"
+
+# Verify — best-effort; skips cleanly when the checkers aren't present (tests).
+status=0
+if [ -f "$REPO_ROOT/scripts/workflow-integrity.py" ]; then
+ echo " verify: workflow-integrity"
+ python3 "$REPO_ROOT/scripts/workflow-integrity.py" || status=$?
+fi
+if [ -f "$REPO_ROOT/scripts/sync-check.sh" ]; then
+ echo " verify: sync-check"
+ bash "$REPO_ROOT/scripts/sync-check.sh" || status=$?
+fi
+if [ "$status" -ne 0 ]; then
+ echo "rename: done, but a verify step reported drift — review before committing." >&2
+fi
+echo "rename: done."
+exit 0
diff --git a/scripts/tests/rename-ai-artifact.bats b/scripts/tests/rename-ai-artifact.bats
new file mode 100644
index 0000000..f00c92f
--- /dev/null
+++ b/scripts/tests/rename-ai-artifact.bats
@@ -0,0 +1,119 @@
+#!/usr/bin/env bats
+#
+# Tests for scripts/rename-ai-artifact.sh — rename an .ai artifact (workflow or
+# script) across the canonical + mirror trees, rewriting every reference and
+# leaving archived session records (history) untouched.
+#
+# Strategy: build a synthetic git repo mirroring the canonical/mirror layout in
+# a temp dir, copy the real script in, and run it there. The script resolves
+# the repo root from git, so it operates entirely on the fixture. The verify
+# step (workflow-integrity / sync-check) is best-effort and skips when those
+# scripts are absent, which they are in the fixture.
+
+REAL_REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+SCRIPT_SRC="$REAL_REPO/scripts/rename-ai-artifact.sh"
+
+setup() {
+ REPO="$(mktemp -d -t rename-bats.XXXXXX)"
+ mkdir -p "$REPO/scripts"
+ cp "$SCRIPT_SRC" "$REPO/scripts/rename-ai-artifact.sh"
+ # Canonical + mirror trees.
+ for base in "$REPO/claude-templates/.ai" "$REPO/.ai"; do
+ mkdir -p "$base/workflows" "$base/scripts" "$base/sessions"
+ printf '* Summary\nThe foo workflow.\nTriggers: "run the foo workflow"\n' > "$base/workflows/foo.org"
+ printf '#+TITLE: Index\n- =foo.org= — the foo workflow.\n- =foobar.org= — unrelated, must survive.\n' > "$base/workflows/INDEX.org"
+ printf '* Summary\nThe foobar workflow (must not be touched by a foo rename).\n' > "$base/workflows/foobar.org"
+ printf '#!/usr/bin/env python3\n# foo-helper for the foo workflow\nprint("foo-helper")\n' > "$base/scripts/foo-helper.py"
+ printf 'Old session mentioning foo and foo.org — this is history.\n' > "$base/sessions/2026-01-01-old.org"
+ done
+ printf 'See foo.org and foo-helper.py. Also foobar.org stays.\n' > "$REPO/notes.org"
+ ( cd "$REPO" && git init -q && git add -A && git -c user.email=t@t -c user.name=t commit -qm init )
+}
+
+teardown() {
+ rm -rf "$REPO"
+}
+
+run_rename() { # OLD NEW
+ ( cd "$REPO" && bash scripts/rename-ai-artifact.sh "$1" "$2" )
+}
+
+# --- Normal: workflow rename across both trees ---
+
+@test "rename: moves a workflow in both canonical and mirror" {
+ run run_rename foo.org bar.org
+ [ "$status" -eq 0 ]
+ [ -f "$REPO/claude-templates/.ai/workflows/bar.org" ]
+ [ -f "$REPO/.ai/workflows/bar.org" ]
+ [ ! -e "$REPO/claude-templates/.ai/workflows/foo.org" ]
+ [ ! -e "$REPO/.ai/workflows/foo.org" ]
+}
+
+@test "rename: rewrites references in the index and prose" {
+ run_rename foo.org bar.org
+ grep -q "=bar.org=" "$REPO/.ai/workflows/INDEX.org"
+ grep -q "run the bar workflow" "$REPO/.ai/workflows/bar.org"
+ grep -q "bar.org" "$REPO/notes.org"
+ ! grep -q "foo.org" "$REPO/notes.org"
+}
+
+# --- The kernel: history is untouched, near-miss names survive ---
+
+@test "rename: archived session records are left as history" {
+ run_rename foo.org bar.org
+ grep -q "mentioning foo and foo.org" "$REPO/.ai/sessions/2026-01-01-old.org"
+ grep -q "mentioning foo and foo.org" "$REPO/claude-templates/.ai/sessions/2026-01-01-old.org"
+}
+
+@test "rename: a longer name sharing the prefix is not corrupted" {
+ run_rename foo.org bar.org
+ # foobar.org and its index row must be intact — token-boundary matching.
+ [ -f "$REPO/.ai/workflows/foobar.org" ]
+ grep -q "=foobar.org=" "$REPO/.ai/workflows/INDEX.org"
+ grep -q "foobar.org stays" "$REPO/notes.org"
+}
+
+# --- Script artifact (no .org extension to strip cleanly) ---
+
+@test "rename: renames a script artifact and its references" {
+ run run_rename foo-helper.py baz-helper.py
+ [ "$status" -eq 0 ]
+ [ -f "$REPO/.ai/scripts/baz-helper.py" ]
+ [ -f "$REPO/claude-templates/.ai/scripts/baz-helper.py" ]
+ grep -q "baz-helper.py" "$REPO/notes.org"
+ ! grep -q "foo-helper" "$REPO/notes.org"
+}
+
+@test "rename: also rewrites the underscore module-name variant" {
+ # A python test imports the hyphenated script under an underscored module
+ # name, the way importlib.spec_from_file_location does. Renaming the script
+ # must update both the hyphen path and the underscore module name.
+ for base in "$REPO/claude-templates/.ai" "$REPO/.ai"; do
+ printf 'spec_from_file_location("foo_helper", "foo-helper.py")\n' \
+ > "$base/scripts/uses-helper.py"
+ done
+ ( cd "$REPO" && git add -A && git -c user.email=t@t -c user.name=t commit -qm add )
+ run run_rename foo-helper.py baz-helper.py
+ [ "$status" -eq 0 ]
+ grep -q 'spec_from_file_location("baz_helper", "baz-helper.py")' "$REPO/.ai/scripts/uses-helper.py"
+ ! grep -q "foo_helper" "$REPO/.ai/scripts/uses-helper.py"
+}
+
+# --- Errors ---
+
+@test "rename: refuses when the source artifact does not exist" {
+ run run_rename nope.org bar.org
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"not found"* ]]
+}
+
+@test "rename: refuses when the target already exists" {
+ run run_rename foo.org foobar.org
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"exists"* ]]
+}
+
+@test "rename: refuses bad usage (missing args)" {
+ run bash "$REPO/scripts/rename-ai-artifact.sh" only-one-arg
+ [ "$status" -ne 0 ]
+}