diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-30 13:17:47 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-30 13:17:47 -0500 |
| commit | 0234e52b727b34ade93961eb05b5638685f4406f (patch) | |
| tree | b7ee5f66a9fceb3fd4d9b1d2ba8c44e89dde76c5 | |
| parent | 038d59b7e548d2323f43dcd92ba14cba876d840d (diff) | |
| download | rulesets-0234e52b727b34ade93961eb05b5638685f4406f.tar.gz rulesets-0234e52b727b34ade93961eb05b5638685f4406f.zip | |
chore(scripts): add drill-deck stats, diff-ids, and sync wrapper
I incorporated the flashcard-tooling bundle from the work project's deck-review workflow, validated there against a 93-card deck. Three scripts now live under .ai/scripts/: drill-deck-stats.py (pre-rewrite inventory plus a gate that warns on stray *** Answer headers, missing :ID:, non-prompt headings, and #+TITLE jargon like "org-drill"), drill-deck-diff-ids.py (SRS-state preservation check that flags any :ID: lost across a rewrite), and drill-deck-sync (bash wrapper chaining stats, optional diff-ids, then drill-to-anki, writing to ~/sync/phone/anki/ only when the gates pass).
The drill-deck-review.org workflow gains a Helper Scripts section and references the scripts from its phases. I reconciled its output-path prose with the drill-to-anki default that just moved to ~/sync/phone/anki/, so it no longer claims the script still defaults to ~/sync/org/drill/. I added tests for both Python scripts (pure logic plus CLI gate behavior) and a bats suite for the wrapper's guard paths. The clean end-to-end sync path stays uncovered since it needs uv-resolved genanki.
| -rwxr-xr-x | .ai/scripts/drill-deck-diff-ids.py | 99 | ||||
| -rwxr-xr-x | .ai/scripts/drill-deck-stats.py | 151 | ||||
| -rwxr-xr-x | .ai/scripts/drill-deck-sync | 98 | ||||
| -rw-r--r-- | .ai/scripts/tests/drill-deck-sync.bats | 38 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_drill_deck_diff_ids.py | 88 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_drill_deck_stats.py | 96 | ||||
| -rw-r--r-- | .ai/workflows/drill-deck-review.org | 124 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/drill-deck-diff-ids.py | 99 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/drill-deck-stats.py | 151 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/drill-deck-sync | 98 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/drill-deck-sync.bats | 38 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py | 88 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_drill_deck_stats.py | 96 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/drill-deck-review.org | 124 |
14 files changed, 1340 insertions, 48 deletions
diff --git a/.ai/scripts/drill-deck-diff-ids.py b/.ai/scripts/drill-deck-diff-ids.py new file mode 100755 index 0000000..bd2c4cc --- /dev/null +++ b/.ai/scripts/drill-deck-diff-ids.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""SRS-state preservation check between two versions of an org-drill deck. + +Extracts every :ID: from each version and reports IDs that disappeared +or appeared. Disappeared IDs lose org-drill SRS state (review history, +ease, intervals) and are the worst-case bug from a deck rewrite. Appeared +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> +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") +ID_RE = re.compile(r"^\s*:ID:\s+(\S+)\s*$") + + +def card_id_map(path: Path) -> dict[str, str]: + """Return {id -> heading} for every :drill: card in path.""" + result: dict[str, str] = {} + lines = path.read_text(encoding="utf-8").splitlines() + i = 0 + while i < len(lines): + m = CARD_RE.match(lines[i]) + if m: + heading = m.group(1).strip() + i += 1 + while i < len(lines): + line = lines[i] + if line.startswith("* ") or CARD_RE.match(line): + break + mid = ID_RE.match(line) + if mid: + result[mid.group(1)] = heading + break + i += 1 + continue + i += 1 + return result + + +def main() -> int: + if len(sys.argv) != 3: + print(f"usage: {sys.argv[0]} <before.org> <after.org>", file=sys.stderr) + return 2 + + before_path = Path(sys.argv[1]).expanduser().resolve() + after_path = Path(sys.argv[2]).expanduser().resolve() + + for p in (before_path, after_path): + if not p.is_file(): + print(f"error: {p} not found", file=sys.stderr) + return 2 + + before = card_id_map(before_path) + after = card_id_map(after_path) + + before_ids = set(before) + after_ids = set(after) + + preserved = before_ids & after_ids + disappeared = before_ids - after_ids + appeared = after_ids - before_ids + + print(f"drill-deck-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)}") + print(f"Preserved: {len(preserved)}") + print(f"Disappeared: {len(disappeared)}") + print(f"Appeared: {len(appeared)}") + print() + + warnings = 0 + if disappeared: + warnings += 1 + print(f"WARN: {len(disappeared)} card IDs disappeared (SRS state lost)") + for cid in sorted(disappeared): + print(f" - {cid} (was: {before[cid]!r})") + if appeared: + warnings += 1 + print(f"NOTE: {len(appeared)} new card IDs appeared") + for cid in sorted(appeared): + print(f" - {cid} (now: {after[cid]!r})") + + if warnings == 0: + print("clean — SRS state preserved") + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.ai/scripts/drill-deck-stats.py b/.ai/scripts/drill-deck-stats.py new file mode 100755 index 0000000..72d1cde --- /dev/null +++ b/.ai/scripts/drill-deck-stats.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Inventory + workflow-violation warnings for an org-drill deck source file. + +Reports counts and flags violations: +- Total cards (depth-2 `:drill:` headings) +- PROPERTIES drawer count (should match card count) +- `*** Answer` sub-header count (should be 0 per drill-deck-review.org) +- Cards missing :ID: (loses identity across versions, risks SRS-state loss) +- Cards whose heading lacks `?` (likely a topic-as-heading not yet rewritten) + +Exits 0 when clean, 1 when any warnings are present. Use as a gate before +regenerating the Anki deck or running drill-deck-sync. + +Usage: + drill-deck-stats.py <file.org> +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") +ANSWER_RE = re.compile(r"^\*\*\*\s+Answer\b") +PROP_START_RE = re.compile(r"^\s*:PROPERTIES:\s*$") +PROP_END_RE = re.compile(r"^\s*:END:\s*$") +ID_RE = re.compile(r"^\s*:ID:\s+(\S+)\s*$") +TITLE_RE = re.compile(r"^#\+TITLE:\s*(.+?)\s*$", re.IGNORECASE) +SOURCE_TOOL_RE = re.compile(r"\borg[-\s]?drill\b", re.IGNORECASE) + +# A heading qualifies as "prompt form" if it contains `?` or starts with +# one of these imperative verbs (directive prompts like "Spell these out" +# and "Introduce yourself" are valid even without `?`). +IMPERATIVE_VERBS = frozenset({ + "spell", "describe", "explain", "name", "list", "give", + "show", "tell", "define", "compare", "identify", "outline", + "introduce", "walk", "state", "recite", "recall", "summarize", +}) + + +def is_prompt_form(heading: str) -> bool: + """True if the heading reads as a question or imperative prompt.""" + if "?" in heading: + return True + first_word = heading.split(None, 1)[0].lower().rstrip(":,;") + return first_word in IMPERATIVE_VERBS + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} <file.org>", file=sys.stderr) + return 2 + + path = Path(sys.argv[1]).expanduser().resolve() + if not path.is_file(): + print(f"error: {path} not found", file=sys.stderr) + return 2 + + lines = path.read_text(encoding="utf-8").splitlines() + + title: str | None = None + for line in lines[:20]: + m = TITLE_RE.match(line) + if m: + title = m.group(1).strip() + break + + cards: list[tuple[str, bool, bool]] = [] # (heading, has_id, has_answer_subheader) + answer_count = 0 + prop_count = 0 + + i = 0 + while i < len(lines): + m = CARD_RE.match(lines[i]) + if m: + heading = m.group(1).strip() + i += 1 + has_id = False + has_answer = False + in_drawer = False + while i < len(lines): + line = lines[i] + if line.startswith("* ") or CARD_RE.match(line): + break + if PROP_START_RE.match(line): + prop_count += 1 + in_drawer = True + elif in_drawer and PROP_END_RE.match(line): + in_drawer = False + elif in_drawer and ID_RE.match(line): + has_id = True + elif ANSWER_RE.match(line): + answer_count += 1 + has_answer = True + i += 1 + cards.append((heading, has_id, has_answer)) + continue + i += 1 + + not_prompt = [h for h, _, _ in cards if not is_prompt_form(h)] + no_id = [h for h, has_id, _ in cards if not has_id] + + print(f"{path.name} — drill deck stats") + print() + title_display = title if title else "(no #+TITLE)" + print(f"Deck title: {title_display}") + print(f"Cards: {len(cards)}") + drawer_status = "match" if prop_count == len(cards) else f"mismatch (expected {len(cards)})" + print(f"PROPERTIES drawers: {prop_count} ({drawer_status})") + answer_status = "clean" if answer_count == 0 else "workflow violation" + print(f"*** Answer sub-headers: {answer_count} ({answer_status})") + print(f"Cards missing :ID:: {len(no_id)}") + print(f"Cards with non-prompt heading: {len(not_prompt)}") + print() + + warnings = 0 + if title is None: + warnings += 1 + print("WARN: no #+TITLE: line found; deck name will fall back to the file basename") + elif SOURCE_TOOL_RE.search(title): + warnings += 1 + print(f"WARN: #+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: + warnings += 1 + print(f"WARN: {answer_count} cards have *** Answer sub-headers (drop per drill-deck-review.org)") + if prop_count != len(cards): + warnings += 1 + print(f"WARN: PROPERTIES count {prop_count} does not match card count {len(cards)}") + if no_id: + warnings += 1 + print(f"WARN: {len(no_id)} cards missing :ID:; losing identity risks SRS-state loss across rewrites") + for h in no_id[:5]: + print(f" - {h}") + if len(no_id) > 5: + print(f" - ... and {len(no_id) - 5} more") + if not_prompt: + warnings += 1 + print(f"WARN: {len(not_prompt)} cards have non-prompt headings (no '?' and no imperative-verb start); likely topic-as-heading not yet rewritten") + for h in not_prompt[:5]: + print(f" - {h}") + if len(not_prompt) > 5: + print(f" - ... and {len(not_prompt) - 5} more") + + if warnings == 0: + print("clean") + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.ai/scripts/drill-deck-sync b/.ai/scripts/drill-deck-sync new file mode 100755 index 0000000..8e51cdd --- /dev/null +++ b/.ai/scripts/drill-deck-sync @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# drill-deck-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. +# +# Usage: +# drill-deck-sync <source.org> +# drill-deck-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 +# 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>] +EOF + exit 2 +} + +if [[ $# -lt 1 ]]; then + usage +fi + +SOURCE="$1" +shift + +DIFF_AGAINST="" +while [[ $# -gt 0 ]]; do + case "$1" in + --diff-against) + [[ $# -ge 2 ]] || usage + DIFF_AGAINST="$2" + shift 2 + ;; + -h|--help) + usage + ;; + *) + echo "unknown arg: $1" >&2 + usage + ;; + esac +done + +if [[ ! -f "$SOURCE" ]]; then + echo "error: $SOURCE not found" >&2 + exit 2 +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" + +for helper in "$STATS" "$DIFF_IDS" "$TO_ANKI"; do + if [[ ! -f "$helper" ]]; then + echo "error: helper $helper not found" >&2 + exit 2 + fi +done + +echo "=== drill-deck-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 + exit 1 +fi +echo + +if [[ -n "$DIFF_AGAINST" ]]; then + if [[ ! -f "$DIFF_AGAINST" ]]; then + echo "error: $DIFF_AGAINST not found" >&2 + exit 2 + fi + echo "--- ID preservation ---" + if ! python3 "$DIFF_IDS" "$DIFF_AGAINST" "$SOURCE"; then + echo + echo "ID preservation check failed — SRS state may have been lost" >&2 + exit 1 + fi + echo +fi + +BASENAME="$(basename "$SOURCE" .org)" +OUTPUT="$HOME/sync/phone/anki/${BASENAME}.apkg" + +echo "--- regenerate apkg ---" +mkdir -p "$(dirname "$OUTPUT")" +"$TO_ANKI" "$SOURCE" --output "$OUTPUT" +echo +echo "deck synced to $OUTPUT" diff --git a/.ai/scripts/tests/drill-deck-sync.bats b/.ai/scripts/tests/drill-deck-sync.bats new file mode 100644 index 0000000..e141cab --- /dev/null +++ b/.ai/scripts/tests/drill-deck-sync.bats @@ -0,0 +1,38 @@ +#!/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 +# 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" + TMP="$(mktemp -d)" +} + +teardown() { + rm -rf "$TMP" +} + +@test "drill-deck-sync: no args exits 2" { + run "$SYNC" + [ "$status" -eq 2 ] +} + +@test "drill-deck-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" { + cat > "$TMP/dirty.org" <<'EOF' +#+TITLE: DeepSat Org-Drill Flashcards + +* Section +** DeepSat :drill: +*** Answer +A satellite company. +EOF + run "$SYNC" "$TMP/dirty.org" + [ "$status" -eq 1 ] + [ ! -f "$HOME/sync/phone/anki/dirty.apkg" ] +} diff --git a/.ai/scripts/tests/test_drill_deck_diff_ids.py b/.ai/scripts/tests/test_drill_deck_diff_ids.py new file mode 100644 index 0000000..9cd8305 --- /dev/null +++ b/.ai/scripts/tests/test_drill_deck_diff_ids.py @@ -0,0 +1,88 @@ +"""Tests for drill-deck-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. +""" +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "drill-deck-diff-ids.py" + + +@pytest.fixture(scope="module") +def diff_ids(): + spec = importlib.util.spec_from_file_location("drill_deck_diff_ids", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +DECK_A = """* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: id-1 +:END: +Body. +** Who founded it? :drill: +:PROPERTIES: +:ID: id-2 +:END: +Body. +""" + +# id-2 dropped, id-3 added relative to DECK_A +DECK_B = """* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: id-1 +:END: +Body. +** When was it founded? :drill: +:PROPERTIES: +:ID: id-3 +:END: +Body. +""" + + +def test_card_id_map_extracts_id_to_heading(diff_ids, tmp_path): + f = tmp_path / "a.org" + f.write_text(DECK_A) + m = diff_ids.card_id_map(f) + assert set(m) == {"id-1", "id-2"} + assert m["id-1"] == "What is DeepSat?" + + +def _run(before, after): + return subprocess.run( + [sys.executable, str(SCRIPT), str(before), str(after)], + capture_output=True, text=True, + ) + + +def test_cli_identical_decks_exit_zero(tmp_path): + a = tmp_path / "a.org" + a.write_text(DECK_A) + b = tmp_path / "b.org" + b.write_text(DECK_A) + r = _run(a, b) + assert r.returncode == 0 + assert "preserved" in r.stdout.lower() + + +def test_cli_dropped_id_warns_and_exits_one(tmp_path): + a = tmp_path / "a.org" + a.write_text(DECK_A) + b = tmp_path / "b.org" + b.write_text(DECK_B) + r = _run(a, b) + assert r.returncode == 1 + assert "disappeared" in r.stdout.lower() + assert "id-2" in r.stdout diff --git a/.ai/scripts/tests/test_drill_deck_stats.py b/.ai/scripts/tests/test_drill_deck_stats.py new file mode 100644 index 0000000..02d9c4e --- /dev/null +++ b/.ai/scripts/tests/test_drill_deck_stats.py @@ -0,0 +1,96 @@ +"""Tests for drill-deck-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. +""" +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "drill-deck-stats.py" + + +@pytest.fixture(scope="module") +def stats(): + spec = importlib.util.spec_from_file_location("drill_deck_stats", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# --- is_prompt_form (pure) --- + +def test_is_prompt_form_question_mark(stats): + assert stats.is_prompt_form("What is DeepSat?") is True + + +def test_is_prompt_form_imperative_verb(stats): + assert stats.is_prompt_form("Spell out the orbital regimes") is True + + +def test_is_prompt_form_imperative_is_case_insensitive(stats): + assert stats.is_prompt_form("introduce yourself") is True + + +def test_is_prompt_form_topic_heading_is_not_a_prompt(stats): + assert stats.is_prompt_form("DeepSat") is False + + +def test_is_prompt_form_strips_trailing_punctuation_off_first_word(stats): + assert stats.is_prompt_form("List: the founders") is True + + +# --- CLI inventory + gate (integration) --- + +CLEAN_DECK = """#+TITLE: DeepSat Flashcards + +* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: card-1 +:END: +A satellite company. +""" + +DIRTY_DECK = """#+TITLE: DeepSat Org-Drill Flashcards + +* Section +** DeepSat :drill: +*** Answer +A satellite company. +""" + + +def _run(path): + return subprocess.run( + [sys.executable, str(SCRIPT), str(path)], + capture_output=True, text=True, + ) + + +def test_cli_clean_deck_exits_zero(tmp_path): + f = tmp_path / "clean.org" + f.write_text(CLEAN_DECK) + r = _run(f) + assert r.returncode == 0 + assert "clean" in r.stdout + + +def test_cli_dirty_deck_warns_and_exits_one(tmp_path): + f = tmp_path / "dirty.org" + f.write_text(DIRTY_DECK) + r = _run(f) + assert r.returncode == 1 + assert "WARN" in r.stdout + assert "org-drill" in r.stdout.lower() # title-jargon audit fired + + +def test_cli_missing_file_exits_two(tmp_path): + r = _run(tmp_path / "nope.org") + assert r.returncode == 2 diff --git a/.ai/workflows/drill-deck-review.org b/.ai/workflows/drill-deck-review.org index a891f62..7e9eed5 100644 --- a/.ai/workflows/drill-deck-review.org +++ b/.ai/workflows/drill-deck-review.org @@ -6,7 +6,7 @@ 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. +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. * When to Use This Workflow @@ -34,10 +34,26 @@ 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). The script's default is =~/sync/org/drill/=; override with =--output= per this workflow. +- *Output location*: =~/sync/phone/anki/<basename>.apkg= (the phone-sync target). Both =drill-to-anki.py= and the =drill-deck-sync= wrapper default there. * Canonical Card Shape +** Deck title (=#+TITLE:= line) + +The =#+TITLE:= line at the top of the source file drives two surfaces: the org-drill display in Emacs and the Anki deck name on the phone. Pick a title that reads well in Anki — drop tool-name jargon like "Org-Drill" / "Drill" that's meaningful in Emacs but noise on the consumption side. + +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. + +*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: + +- 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. + +For most decks (especially on first deployment), this is a one-time event. The rename is cheap to do early. + ** Heading (the question) Every card heading is a question that doesn't reveal the answer. Not the topic name, not the acronym, not the person's name — a question that tests recall. @@ -72,8 +88,10 @@ 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. -*** Talking-points cards -Already in question form ("Introduce Yourself", "What is DeepSat?", "What do you do at DeepSat?"). Leave the heading alone. Still strip the =*** Answer= sub-header and audit the body content for staleness. +*** 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). ** Body (the answer) @@ -84,15 +102,17 @@ Already in question form ("Introduce Yourself", "What is DeepSat?", "What do you * Approach: Phases -** Phase A: Question-form audit (per card) +** Phase A: Question-form + title audit (per card and per file) -Walk every =** ... :drill:= card and flag the ones that don't already match the canonical shape: +Run =drill-deck-stats.py= on the source first to get the structural inventory: + +#+begin_src bash +.ai/scripts/drill-deck-stats.py <source.org> +#+end_src -- Heading is the topic / acronym / person's name → flag for rewrite. -- Heading is already a question but the body still has a =*** Answer= sub-header → flag for sub-header removal. -- Heading is a question /and/ the body is clean → no action. +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. Each surfaced card is a candidate for the rewrite, plus the title itself if flagged. -Output of Phase A: a list of cards needing rewrite, with the proposed new heading for each. For person cards, this means proposing the role descriptor up front so Phase C is mechanical. +For each candidate, propose the new heading in advance so Phase C is mechanical. For person cards, the proposal is the role descriptor + topical anchor pair. For acronym/concept cards, the proposal is the existing body question promoted to the heading. ** Phase B: Content-accuracy audit (subagent) @@ -145,6 +165,18 @@ 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/=: + +#+begin_src bash +.ai/scripts/drill-deck-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: + #+begin_src bash .ai/scripts/drill-to-anki.py <source.org> --output ~/sync/phone/anki/<basename>.apkg #+end_src @@ -153,48 +185,85 @@ The script writes the =.apkg= with stable deck/model IDs derived from the deck n ** Phase E: Verify -- Spot-check the new =.apkg= size against the prior version. Significant size changes are expected when many cards were rewritten; a wildly smaller file may mean the parser dropped cards (PROPERTIES drawer mishandling, etc.). -- Open the source in Emacs (or read with =head -100=) and confirm a few cards visually: question heading, no =*** Answer=, PROPERTIES preserved, body opens with topic name. -- If the user keeps an org-drill session open, mention they'll want to revert the buffer to pick up the rewrite. +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: + +- 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. +- If you keep an org-drill session open, revert the buffer to pick up the rewrite. + +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> +#+end_src ** Phase F: Commit 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 =drill-to-anki.py= 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 =drill-deck-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. +* Helper Scripts + +Three scripts under =.ai/scripts/= (canonical lives in =rulesets/claude-templates/.ai/scripts/=): + +** =drill-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= + +Inventory + workflow-violation warnings 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. Exits 0 when clean, 1 when warnings present, so it gates =drill-deck-sync=. + +Imperative-verb allowlist: Spell, Describe, Explain, Name, List, Give, Show, Tell, Define, Compare, Identify, Outline, Introduce, Walk, State, Recite, Recall, Summarize. + +** =drill-deck-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) + +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. + +Usage: +#+begin_src bash +drill-deck-sync <source.org> +drill-deck-sync <source.org> --diff-against <previous-version.org> +#+end_src + * Anki Script Behavior -The =drill-to-anki.py= script (under =.ai/scripts/=) has these contracts that this workflow depends on: +The =drill-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* from the card body. Same reason. -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. +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. 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. -If you find the script doing something else (e.g., not stripping PROPERTIES), update the script before regenerating. Don't work around a script bug in the source rewrite — the next deck will hit the same problem. +If you find the script doing something else, update the script before regenerating. Don't work around a script bug in the source rewrite — the next deck will hit the same problem. * Output Path Convention -- Default in the script: =~/sync/org/drill/<basename>.apkg= (matches the convention where org sources live in project repos and symlink into =~/sync/org/drill/=). -- Default in this workflow: =~/sync/phone/anki/<basename>.apkg= (the phone-syncable Anki target). Override the script default with =--output= every time. +- 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). -Both paths can coexist. The =~/sync/org/drill/= dir holds Anki exports alongside the org sources (build-artifact convention); =~/sync/phone/anki/= holds the version that syncs to the phone (consumption-target convention). For most decks, only the =/sync/phone/= copy is actually consumed, so this workflow writes there directly and skips the intermediate. +=~/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. +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. 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. The source rewrite must drop them. +4. *Forgetting to strip =*** Answer= sub-headers.* The Anki output will show them as visible card content. =drill-deck-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. *Outputting to the script's default path.* The phone won't pick up =~/sync/org/drill/<deck>.apkg=. Always pass =--output ~/sync/phone/anki/<deck>.apkg=. -7. *Treating subagent output as gospel.* Medium- and low-confidence findings need human review before baking. The subagent surfaces; the main thread decides. +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). * Living Document @@ -203,8 +272,15 @@ 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. ** Updates and Learnings *** 2026-05-30: First run 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. + +*** 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. diff --git a/claude-templates/.ai/scripts/drill-deck-diff-ids.py b/claude-templates/.ai/scripts/drill-deck-diff-ids.py new file mode 100755 index 0000000..bd2c4cc --- /dev/null +++ b/claude-templates/.ai/scripts/drill-deck-diff-ids.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""SRS-state preservation check between two versions of an org-drill deck. + +Extracts every :ID: from each version and reports IDs that disappeared +or appeared. Disappeared IDs lose org-drill SRS state (review history, +ease, intervals) and are the worst-case bug from a deck rewrite. Appeared +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> +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") +ID_RE = re.compile(r"^\s*:ID:\s+(\S+)\s*$") + + +def card_id_map(path: Path) -> dict[str, str]: + """Return {id -> heading} for every :drill: card in path.""" + result: dict[str, str] = {} + lines = path.read_text(encoding="utf-8").splitlines() + i = 0 + while i < len(lines): + m = CARD_RE.match(lines[i]) + if m: + heading = m.group(1).strip() + i += 1 + while i < len(lines): + line = lines[i] + if line.startswith("* ") or CARD_RE.match(line): + break + mid = ID_RE.match(line) + if mid: + result[mid.group(1)] = heading + break + i += 1 + continue + i += 1 + return result + + +def main() -> int: + if len(sys.argv) != 3: + print(f"usage: {sys.argv[0]} <before.org> <after.org>", file=sys.stderr) + return 2 + + before_path = Path(sys.argv[1]).expanduser().resolve() + after_path = Path(sys.argv[2]).expanduser().resolve() + + for p in (before_path, after_path): + if not p.is_file(): + print(f"error: {p} not found", file=sys.stderr) + return 2 + + before = card_id_map(before_path) + after = card_id_map(after_path) + + before_ids = set(before) + after_ids = set(after) + + preserved = before_ids & after_ids + disappeared = before_ids - after_ids + appeared = after_ids - before_ids + + print(f"drill-deck-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)}") + print(f"Preserved: {len(preserved)}") + print(f"Disappeared: {len(disappeared)}") + print(f"Appeared: {len(appeared)}") + print() + + warnings = 0 + if disappeared: + warnings += 1 + print(f"WARN: {len(disappeared)} card IDs disappeared (SRS state lost)") + for cid in sorted(disappeared): + print(f" - {cid} (was: {before[cid]!r})") + if appeared: + warnings += 1 + print(f"NOTE: {len(appeared)} new card IDs appeared") + for cid in sorted(appeared): + print(f" - {cid} (now: {after[cid]!r})") + + if warnings == 0: + print("clean — SRS state preserved") + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/claude-templates/.ai/scripts/drill-deck-stats.py b/claude-templates/.ai/scripts/drill-deck-stats.py new file mode 100755 index 0000000..72d1cde --- /dev/null +++ b/claude-templates/.ai/scripts/drill-deck-stats.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Inventory + workflow-violation warnings for an org-drill deck source file. + +Reports counts and flags violations: +- Total cards (depth-2 `:drill:` headings) +- PROPERTIES drawer count (should match card count) +- `*** Answer` sub-header count (should be 0 per drill-deck-review.org) +- Cards missing :ID: (loses identity across versions, risks SRS-state loss) +- Cards whose heading lacks `?` (likely a topic-as-heading not yet rewritten) + +Exits 0 when clean, 1 when any warnings are present. Use as a gate before +regenerating the Anki deck or running drill-deck-sync. + +Usage: + drill-deck-stats.py <file.org> +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") +ANSWER_RE = re.compile(r"^\*\*\*\s+Answer\b") +PROP_START_RE = re.compile(r"^\s*:PROPERTIES:\s*$") +PROP_END_RE = re.compile(r"^\s*:END:\s*$") +ID_RE = re.compile(r"^\s*:ID:\s+(\S+)\s*$") +TITLE_RE = re.compile(r"^#\+TITLE:\s*(.+?)\s*$", re.IGNORECASE) +SOURCE_TOOL_RE = re.compile(r"\borg[-\s]?drill\b", re.IGNORECASE) + +# A heading qualifies as "prompt form" if it contains `?` or starts with +# one of these imperative verbs (directive prompts like "Spell these out" +# and "Introduce yourself" are valid even without `?`). +IMPERATIVE_VERBS = frozenset({ + "spell", "describe", "explain", "name", "list", "give", + "show", "tell", "define", "compare", "identify", "outline", + "introduce", "walk", "state", "recite", "recall", "summarize", +}) + + +def is_prompt_form(heading: str) -> bool: + """True if the heading reads as a question or imperative prompt.""" + if "?" in heading: + return True + first_word = heading.split(None, 1)[0].lower().rstrip(":,;") + return first_word in IMPERATIVE_VERBS + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} <file.org>", file=sys.stderr) + return 2 + + path = Path(sys.argv[1]).expanduser().resolve() + if not path.is_file(): + print(f"error: {path} not found", file=sys.stderr) + return 2 + + lines = path.read_text(encoding="utf-8").splitlines() + + title: str | None = None + for line in lines[:20]: + m = TITLE_RE.match(line) + if m: + title = m.group(1).strip() + break + + cards: list[tuple[str, bool, bool]] = [] # (heading, has_id, has_answer_subheader) + answer_count = 0 + prop_count = 0 + + i = 0 + while i < len(lines): + m = CARD_RE.match(lines[i]) + if m: + heading = m.group(1).strip() + i += 1 + has_id = False + has_answer = False + in_drawer = False + while i < len(lines): + line = lines[i] + if line.startswith("* ") or CARD_RE.match(line): + break + if PROP_START_RE.match(line): + prop_count += 1 + in_drawer = True + elif in_drawer and PROP_END_RE.match(line): + in_drawer = False + elif in_drawer and ID_RE.match(line): + has_id = True + elif ANSWER_RE.match(line): + answer_count += 1 + has_answer = True + i += 1 + cards.append((heading, has_id, has_answer)) + continue + i += 1 + + not_prompt = [h for h, _, _ in cards if not is_prompt_form(h)] + no_id = [h for h, has_id, _ in cards if not has_id] + + print(f"{path.name} — drill deck stats") + print() + title_display = title if title else "(no #+TITLE)" + print(f"Deck title: {title_display}") + print(f"Cards: {len(cards)}") + drawer_status = "match" if prop_count == len(cards) else f"mismatch (expected {len(cards)})" + print(f"PROPERTIES drawers: {prop_count} ({drawer_status})") + answer_status = "clean" if answer_count == 0 else "workflow violation" + print(f"*** Answer sub-headers: {answer_count} ({answer_status})") + print(f"Cards missing :ID:: {len(no_id)}") + print(f"Cards with non-prompt heading: {len(not_prompt)}") + print() + + warnings = 0 + if title is None: + warnings += 1 + print("WARN: no #+TITLE: line found; deck name will fall back to the file basename") + elif SOURCE_TOOL_RE.search(title): + warnings += 1 + print(f"WARN: #+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: + warnings += 1 + print(f"WARN: {answer_count} cards have *** Answer sub-headers (drop per drill-deck-review.org)") + if prop_count != len(cards): + warnings += 1 + print(f"WARN: PROPERTIES count {prop_count} does not match card count {len(cards)}") + if no_id: + warnings += 1 + print(f"WARN: {len(no_id)} cards missing :ID:; losing identity risks SRS-state loss across rewrites") + for h in no_id[:5]: + print(f" - {h}") + if len(no_id) > 5: + print(f" - ... and {len(no_id) - 5} more") + if not_prompt: + warnings += 1 + print(f"WARN: {len(not_prompt)} cards have non-prompt headings (no '?' and no imperative-verb start); likely topic-as-heading not yet rewritten") + for h in not_prompt[:5]: + print(f" - {h}") + if len(not_prompt) > 5: + print(f" - ... and {len(not_prompt) - 5} more") + + if warnings == 0: + print("clean") + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/claude-templates/.ai/scripts/drill-deck-sync b/claude-templates/.ai/scripts/drill-deck-sync new file mode 100755 index 0000000..8e51cdd --- /dev/null +++ b/claude-templates/.ai/scripts/drill-deck-sync @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# drill-deck-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. +# +# Usage: +# drill-deck-sync <source.org> +# drill-deck-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 +# 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>] +EOF + exit 2 +} + +if [[ $# -lt 1 ]]; then + usage +fi + +SOURCE="$1" +shift + +DIFF_AGAINST="" +while [[ $# -gt 0 ]]; do + case "$1" in + --diff-against) + [[ $# -ge 2 ]] || usage + DIFF_AGAINST="$2" + shift 2 + ;; + -h|--help) + usage + ;; + *) + echo "unknown arg: $1" >&2 + usage + ;; + esac +done + +if [[ ! -f "$SOURCE" ]]; then + echo "error: $SOURCE not found" >&2 + exit 2 +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" + +for helper in "$STATS" "$DIFF_IDS" "$TO_ANKI"; do + if [[ ! -f "$helper" ]]; then + echo "error: helper $helper not found" >&2 + exit 2 + fi +done + +echo "=== drill-deck-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 + exit 1 +fi +echo + +if [[ -n "$DIFF_AGAINST" ]]; then + if [[ ! -f "$DIFF_AGAINST" ]]; then + echo "error: $DIFF_AGAINST not found" >&2 + exit 2 + fi + echo "--- ID preservation ---" + if ! python3 "$DIFF_IDS" "$DIFF_AGAINST" "$SOURCE"; then + echo + echo "ID preservation check failed — SRS state may have been lost" >&2 + exit 1 + fi + echo +fi + +BASENAME="$(basename "$SOURCE" .org)" +OUTPUT="$HOME/sync/phone/anki/${BASENAME}.apkg" + +echo "--- regenerate apkg ---" +mkdir -p "$(dirname "$OUTPUT")" +"$TO_ANKI" "$SOURCE" --output "$OUTPUT" +echo +echo "deck synced to $OUTPUT" diff --git a/claude-templates/.ai/scripts/tests/drill-deck-sync.bats b/claude-templates/.ai/scripts/tests/drill-deck-sync.bats new file mode 100644 index 0000000..e141cab --- /dev/null +++ b/claude-templates/.ai/scripts/tests/drill-deck-sync.bats @@ -0,0 +1,38 @@ +#!/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 +# 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" + TMP="$(mktemp -d)" +} + +teardown() { + rm -rf "$TMP" +} + +@test "drill-deck-sync: no args exits 2" { + run "$SYNC" + [ "$status" -eq 2 ] +} + +@test "drill-deck-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" { + cat > "$TMP/dirty.org" <<'EOF' +#+TITLE: DeepSat Org-Drill Flashcards + +* Section +** DeepSat :drill: +*** Answer +A satellite company. +EOF + run "$SYNC" "$TMP/dirty.org" + [ "$status" -eq 1 ] + [ ! -f "$HOME/sync/phone/anki/dirty.apkg" ] +} diff --git a/claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py b/claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py new file mode 100644 index 0000000..9cd8305 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_drill_deck_diff_ids.py @@ -0,0 +1,88 @@ +"""Tests for drill-deck-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. +""" +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "drill-deck-diff-ids.py" + + +@pytest.fixture(scope="module") +def diff_ids(): + spec = importlib.util.spec_from_file_location("drill_deck_diff_ids", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +DECK_A = """* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: id-1 +:END: +Body. +** Who founded it? :drill: +:PROPERTIES: +:ID: id-2 +:END: +Body. +""" + +# id-2 dropped, id-3 added relative to DECK_A +DECK_B = """* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: id-1 +:END: +Body. +** When was it founded? :drill: +:PROPERTIES: +:ID: id-3 +:END: +Body. +""" + + +def test_card_id_map_extracts_id_to_heading(diff_ids, tmp_path): + f = tmp_path / "a.org" + f.write_text(DECK_A) + m = diff_ids.card_id_map(f) + assert set(m) == {"id-1", "id-2"} + assert m["id-1"] == "What is DeepSat?" + + +def _run(before, after): + return subprocess.run( + [sys.executable, str(SCRIPT), str(before), str(after)], + capture_output=True, text=True, + ) + + +def test_cli_identical_decks_exit_zero(tmp_path): + a = tmp_path / "a.org" + a.write_text(DECK_A) + b = tmp_path / "b.org" + b.write_text(DECK_A) + r = _run(a, b) + assert r.returncode == 0 + assert "preserved" in r.stdout.lower() + + +def test_cli_dropped_id_warns_and_exits_one(tmp_path): + a = tmp_path / "a.org" + a.write_text(DECK_A) + b = tmp_path / "b.org" + b.write_text(DECK_B) + r = _run(a, b) + assert r.returncode == 1 + assert "disappeared" in r.stdout.lower() + assert "id-2" in r.stdout diff --git a/claude-templates/.ai/scripts/tests/test_drill_deck_stats.py b/claude-templates/.ai/scripts/tests/test_drill_deck_stats.py new file mode 100644 index 0000000..02d9c4e --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_drill_deck_stats.py @@ -0,0 +1,96 @@ +"""Tests for drill-deck-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. +""" +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "drill-deck-stats.py" + + +@pytest.fixture(scope="module") +def stats(): + spec = importlib.util.spec_from_file_location("drill_deck_stats", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# --- is_prompt_form (pure) --- + +def test_is_prompt_form_question_mark(stats): + assert stats.is_prompt_form("What is DeepSat?") is True + + +def test_is_prompt_form_imperative_verb(stats): + assert stats.is_prompt_form("Spell out the orbital regimes") is True + + +def test_is_prompt_form_imperative_is_case_insensitive(stats): + assert stats.is_prompt_form("introduce yourself") is True + + +def test_is_prompt_form_topic_heading_is_not_a_prompt(stats): + assert stats.is_prompt_form("DeepSat") is False + + +def test_is_prompt_form_strips_trailing_punctuation_off_first_word(stats): + assert stats.is_prompt_form("List: the founders") is True + + +# --- CLI inventory + gate (integration) --- + +CLEAN_DECK = """#+TITLE: DeepSat Flashcards + +* Section +** What is DeepSat? :drill: +:PROPERTIES: +:ID: card-1 +:END: +A satellite company. +""" + +DIRTY_DECK = """#+TITLE: DeepSat Org-Drill Flashcards + +* Section +** DeepSat :drill: +*** Answer +A satellite company. +""" + + +def _run(path): + return subprocess.run( + [sys.executable, str(SCRIPT), str(path)], + capture_output=True, text=True, + ) + + +def test_cli_clean_deck_exits_zero(tmp_path): + f = tmp_path / "clean.org" + f.write_text(CLEAN_DECK) + r = _run(f) + assert r.returncode == 0 + assert "clean" in r.stdout + + +def test_cli_dirty_deck_warns_and_exits_one(tmp_path): + f = tmp_path / "dirty.org" + f.write_text(DIRTY_DECK) + r = _run(f) + assert r.returncode == 1 + assert "WARN" in r.stdout + assert "org-drill" in r.stdout.lower() # title-jargon audit fired + + +def test_cli_missing_file_exits_two(tmp_path): + r = _run(tmp_path / "nope.org") + assert r.returncode == 2 diff --git a/claude-templates/.ai/workflows/drill-deck-review.org b/claude-templates/.ai/workflows/drill-deck-review.org index a891f62..7e9eed5 100644 --- a/claude-templates/.ai/workflows/drill-deck-review.org +++ b/claude-templates/.ai/workflows/drill-deck-review.org @@ -6,7 +6,7 @@ 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. +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. * When to Use This Workflow @@ -34,10 +34,26 @@ 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). The script's default is =~/sync/org/drill/=; override with =--output= per this workflow. +- *Output location*: =~/sync/phone/anki/<basename>.apkg= (the phone-sync target). Both =drill-to-anki.py= and the =drill-deck-sync= wrapper default there. * Canonical Card Shape +** Deck title (=#+TITLE:= line) + +The =#+TITLE:= line at the top of the source file drives two surfaces: the org-drill display in Emacs and the Anki deck name on the phone. Pick a title that reads well in Anki — drop tool-name jargon like "Org-Drill" / "Drill" that's meaningful in Emacs but noise on the consumption side. + +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. + +*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: + +- 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. + +For most decks (especially on first deployment), this is a one-time event. The rename is cheap to do early. + ** Heading (the question) Every card heading is a question that doesn't reveal the answer. Not the topic name, not the acronym, not the person's name — a question that tests recall. @@ -72,8 +88,10 @@ 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. -*** Talking-points cards -Already in question form ("Introduce Yourself", "What is DeepSat?", "What do you do at DeepSat?"). Leave the heading alone. Still strip the =*** Answer= sub-header and audit the body content for staleness. +*** 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). ** Body (the answer) @@ -84,15 +102,17 @@ Already in question form ("Introduce Yourself", "What is DeepSat?", "What do you * Approach: Phases -** Phase A: Question-form audit (per card) +** Phase A: Question-form + title audit (per card and per file) -Walk every =** ... :drill:= card and flag the ones that don't already match the canonical shape: +Run =drill-deck-stats.py= on the source first to get the structural inventory: + +#+begin_src bash +.ai/scripts/drill-deck-stats.py <source.org> +#+end_src -- Heading is the topic / acronym / person's name → flag for rewrite. -- Heading is already a question but the body still has a =*** Answer= sub-header → flag for sub-header removal. -- Heading is a question /and/ the body is clean → no action. +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. Each surfaced card is a candidate for the rewrite, plus the title itself if flagged. -Output of Phase A: a list of cards needing rewrite, with the proposed new heading for each. For person cards, this means proposing the role descriptor up front so Phase C is mechanical. +For each candidate, propose the new heading in advance so Phase C is mechanical. For person cards, the proposal is the role descriptor + topical anchor pair. For acronym/concept cards, the proposal is the existing body question promoted to the heading. ** Phase B: Content-accuracy audit (subagent) @@ -145,6 +165,18 @@ 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/=: + +#+begin_src bash +.ai/scripts/drill-deck-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: + #+begin_src bash .ai/scripts/drill-to-anki.py <source.org> --output ~/sync/phone/anki/<basename>.apkg #+end_src @@ -153,48 +185,85 @@ The script writes the =.apkg= with stable deck/model IDs derived from the deck n ** Phase E: Verify -- Spot-check the new =.apkg= size against the prior version. Significant size changes are expected when many cards were rewritten; a wildly smaller file may mean the parser dropped cards (PROPERTIES drawer mishandling, etc.). -- Open the source in Emacs (or read with =head -100=) and confirm a few cards visually: question heading, no =*** Answer=, PROPERTIES preserved, body opens with topic name. -- If the user keeps an org-drill session open, mention they'll want to revert the buffer to pick up the rewrite. +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: + +- 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. +- If you keep an org-drill session open, revert the buffer to pick up the rewrite. + +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> +#+end_src ** Phase F: Commit 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 =drill-to-anki.py= 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 =drill-deck-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. +* Helper Scripts + +Three scripts under =.ai/scripts/= (canonical lives in =rulesets/claude-templates/.ai/scripts/=): + +** =drill-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= + +Inventory + workflow-violation warnings 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. Exits 0 when clean, 1 when warnings present, so it gates =drill-deck-sync=. + +Imperative-verb allowlist: Spell, Describe, Explain, Name, List, Give, Show, Tell, Define, Compare, Identify, Outline, Introduce, Walk, State, Recite, Recall, Summarize. + +** =drill-deck-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) + +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. + +Usage: +#+begin_src bash +drill-deck-sync <source.org> +drill-deck-sync <source.org> --diff-against <previous-version.org> +#+end_src + * Anki Script Behavior -The =drill-to-anki.py= script (under =.ai/scripts/=) has these contracts that this workflow depends on: +The =drill-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* from the card body. Same reason. -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. +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. 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. -If you find the script doing something else (e.g., not stripping PROPERTIES), update the script before regenerating. Don't work around a script bug in the source rewrite — the next deck will hit the same problem. +If you find the script doing something else, update the script before regenerating. Don't work around a script bug in the source rewrite — the next deck will hit the same problem. * Output Path Convention -- Default in the script: =~/sync/org/drill/<basename>.apkg= (matches the convention where org sources live in project repos and symlink into =~/sync/org/drill/=). -- Default in this workflow: =~/sync/phone/anki/<basename>.apkg= (the phone-syncable Anki target). Override the script default with =--output= every time. +- 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). -Both paths can coexist. The =~/sync/org/drill/= dir holds Anki exports alongside the org sources (build-artifact convention); =~/sync/phone/anki/= holds the version that syncs to the phone (consumption-target convention). For most decks, only the =/sync/phone/= copy is actually consumed, so this workflow writes there directly and skips the intermediate. +=~/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. +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. 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. The source rewrite must drop them. +4. *Forgetting to strip =*** Answer= sub-headers.* The Anki output will show them as visible card content. =drill-deck-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. *Outputting to the script's default path.* The phone won't pick up =~/sync/org/drill/<deck>.apkg=. Always pass =--output ~/sync/phone/anki/<deck>.apkg=. -7. *Treating subagent output as gospel.* Medium- and low-confidence findings need human review before baking. The subagent surfaces; the main thread decides. +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). * Living Document @@ -203,8 +272,15 @@ 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. ** Updates and Learnings *** 2026-05-30: First run 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. + +*** 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. |
