aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-22 01:35:53 -0400
committerCraig Jennings <c@cjennings.net>2026-06-22 01:35:53 -0400
commitd961c783d18c6178751b338ef1d8dd6a72db9f20 (patch)
tree0b3ac2a1a9eea1a38a46da6cd361d9c766c8fd41 /docs
parent91217d9b7f176e8a051d36672519552b7d5352b7 (diff)
downloadrulesets-d961c783d18c6178751b338ef1d8dd6a72db9f20.tar.gz
rulesets-d961c783d18c6178751b338ef1d8dd6a72db9f20.zip
docs: file flashcard, host-identity, and coverage-location backlog tasks
Files the remaining inbox proposals as prioritized rulesets tasks, with their design docs preserved under docs/design: the Anki #+TITLE deck-name bug (edited code ready), the apkg to org-drill converter, flashcard-stats refutation mode, an archsetup host-identity guard, the coverage-summary install-location question, and an optional tooling-path enumeration hook.
Diffstat (limited to 'docs')
-rwxr-xr-xdocs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py246
-rw-r--r--docs/design/2026-06-21-anki-titlefix-proposal.org57
-rw-r--r--docs/design/2026-06-21-anki-titlefix-test.py190
-rw-r--r--docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org68
-rw-r--r--docs/design/2026-06-21-flashcard-stats-refutation-proposal.org57
-rw-r--r--docs/design/2026-06-21-host-identity-guard-proposal.org54
6 files changed, 672 insertions, 0 deletions
diff --git a/docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py b/docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py
new file mode 100755
index 0000000..ca4c70b
--- /dev/null
+++ b/docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+# "genanki>=0.13",
+# ]
+# ///
+"""Convert an org-drill file into an Anki .apkg deck.
+
+Parses org-drill structure:
+ - Top-level "* Section" headings become tags on every card under them.
+ - Each "** Card name :drill:" entry becomes a card. Front = heading
+ text (sans :drill: tag). Back = entry body with newlines converted
+ to <br>.
+
+Deck name defaults to the org #+TITLE: (so the phone deck reads as the
+curated title), falling back to the input basename when the source has
+no #+TITLE. Deck and model IDs are derived from the deck name via stable
+hash so re-importing the same deck updates existing cards instead of
+duplicating them.
+
+Output defaults to ~/sync/phone/anki/<input-basename>.apkg. The .apkg is
+a mobile-Anki artifact the phone picks up from its sync dir, so it lands
+there rather than next to the org source.
+
+Usage:
+ flashcard-to-anki.py <input.org>
+ flashcard-to-anki.py <input.org> --deck "My Deck Name"
+ flashcard-to-anki.py <input.org> --output /path/to/deck.apkg
+
+Requires genanki, which uv resolves automatically via the PEP 723
+script metadata above. No venv or system install needed.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import re
+import sys
+from pathlib import Path
+
+import genanki
+
+# 32-bit integer space genanki accepts. Start above the conventional
+# "user model" floor so collisions with hand-written decks stay
+# unlikely.
+ID_BASE = 1_500_000_000
+ID_RANGE = 500_000_000
+
+
+def stable_id(name: str, salt: str) -> int:
+ """Derive a deterministic 32-bit id from `name` and a `salt`.
+
+ Same (name, salt) pair always returns the same id, so re-running
+ against the same source produces a stable deck/model id pair and
+ Anki imports update existing cards in place rather than duplicating.
+ """
+ h = hashlib.sha256(f"{salt}:{name}".encode()).hexdigest()
+ return ID_BASE + (int(h[:8], 16) % ID_RANGE)
+
+
+def make_model(deck_name: str) -> genanki.Model:
+ return genanki.Model(
+ stable_id(deck_name, "model"),
+ f"{deck_name} (Craig)",
+ fields=[{"name": "Front"}, {"name": "Back"}],
+ templates=[
+ {
+ "name": "Card 1",
+ "qfmt": "{{Front}}",
+ "afmt": '{{FrontSide}}<hr id="answer">{{Back}}',
+ }
+ ],
+ css=(
+ ".card { font-family: sans-serif; font-size: 18px; "
+ "color: #222; background: #fafafa; line-height: 1.45; }\n"
+ "hr#answer { margin: 14px 0; }\n"
+ ),
+ )
+
+
+def section_to_tag(title: str) -> str:
+ return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
+
+
+def escape_html(s: str) -> str:
+ return (
+ s.replace("&", "&amp;")
+ .replace("<", "&lt;")
+ .replace(">", "&gt;")
+ )
+
+
+def strip_org_metadata(body_lines: list[str]) -> list[str]:
+ """Drop :PROPERTIES: drawers, planning lines, and created-date lines.
+
+ Org-drill needs these in the source file (SRS state lives in the
+ PROPERTIES drawer; SCHEDULED carries the next-review date), but they
+ are noise on the back of an Anki card. A created/added date never
+ belongs on a card, so a stray "Created:" or ":CREATED:" body line is
+ dropped too.
+ """
+ cleaned: list[str] = []
+ in_drawer = False
+ planning_re = re.compile(r"^\s*(SCHEDULED|DEADLINE|CLOSED):\s")
+ created_re = re.compile(r"^\s*:?created:?\s", re.IGNORECASE)
+ drawer_start_re = re.compile(r"^\s*:PROPERTIES:\s*$")
+ drawer_end_re = re.compile(r"^\s*:END:\s*$")
+ for line in body_lines:
+ if in_drawer:
+ if drawer_end_re.match(line):
+ in_drawer = False
+ continue
+ if drawer_start_re.match(line):
+ in_drawer = True
+ continue
+ if planning_re.match(line) or created_re.match(line):
+ continue
+ cleaned.append(line)
+ return cleaned
+
+
+def parse(org_text: str) -> list[tuple[str, str, str]]:
+ """Return [(front, back_html, tag), ...] for every :drill: card."""
+ cards: list[tuple[str, str, str]] = []
+ current_section: str | None = None
+
+ section_re = re.compile(r"^\*\s+(.+?)\s*$")
+ card_re = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$")
+
+ lines = org_text.splitlines()
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+
+ sec = section_re.match(line)
+ if sec:
+ current_section = sec.group(1).strip()
+ i += 1
+ continue
+
+ card = card_re.match(line)
+ if card:
+ front = card.group(1).strip()
+ body_lines: list[str] = []
+ i += 1
+ while i < len(lines):
+ nxt = lines[i]
+ if nxt.startswith("* ") or card_re.match(nxt):
+ break
+ body_lines.append(nxt)
+ i += 1
+ body_lines = strip_org_metadata(body_lines)
+ while body_lines and not body_lines[0].strip():
+ body_lines.pop(0)
+ while body_lines and not body_lines[-1].strip():
+ body_lines.pop()
+ back_html = "<br>".join(escape_html(ln) for ln in body_lines)
+ tag = section_to_tag(current_section) if current_section else "drill"
+ cards.append((front, back_html, tag))
+ continue
+
+ i += 1
+
+ return cards
+
+
+def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck:
+ deck = genanki.Deck(stable_id(deck_name, "deck"), deck_name)
+ model = make_model(deck_name)
+ for front, back, tag in cards:
+ note = genanki.Note(
+ model=model,
+ fields=[front, back],
+ tags=[tag],
+ guid=genanki.guid_for(front),
+ )
+ deck.add_note(note)
+ return deck
+
+
+def default_deck_name(input_path: Path, org_text: str) -> str:
+ """Deck name defaults to the org #+TITLE:, falling back to the basename.
+
+ The #+TITLE drives both the org-drill display in Emacs and the Anki
+ deck name on the phone, so the consumed deck reads as the curated
+ title ("Refutations") rather than the filename slug
+ ("refutation-drill"). Falls back to the input basename (case
+ preserved) when the source has no non-empty #+TITLE line.
+ """
+ for line in org_text.splitlines():
+ m = re.match(r"^#\+TITLE:\s*(.*\S)\s*$", line, re.IGNORECASE)
+ if m:
+ return m.group(1).strip()
+ return input_path.stem
+
+
+def default_output_path(input_path: Path) -> Path:
+ anki_dir = Path.home() / "sync" / "phone" / "anki"
+ return anki_dir / f"{input_path.stem}.apkg"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Convert an org-drill file into an Anki .apkg deck.",
+ )
+ parser.add_argument(
+ "input",
+ type=Path,
+ help="Path to the org-drill source file.",
+ )
+ parser.add_argument(
+ "--deck",
+ help="Deck name. Defaults to the org #+TITLE, or the input basename.",
+ )
+ parser.add_argument(
+ "--output",
+ type=Path,
+ help="Output .apkg path. Defaults to "
+ "~/sync/phone/anki/<input-basename>.apkg.",
+ )
+ args = parser.parse_args()
+
+ input_path: Path = args.input.expanduser().resolve()
+ if not input_path.is_file():
+ print(f"error: {input_path} not found", file=sys.stderr)
+ return 1
+
+ org_text = input_path.read_text(encoding="utf-8")
+ deck_name = args.deck or default_deck_name(input_path, org_text)
+ output_path: Path = (args.output or default_output_path(input_path)).expanduser().resolve()
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ cards = parse(org_text)
+ if not cards:
+ print(f"error: no :drill: cards found in {input_path}", file=sys.stderr)
+ return 1
+
+ deck = build(cards, deck_name)
+ genanki.Package(deck).write_to_file(str(output_path))
+ print(f"wrote {output_path} ({len(cards)} cards, deck '{deck_name}')")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/docs/design/2026-06-21-anki-titlefix-proposal.org b/docs/design/2026-06-21-anki-titlefix-proposal.org
new file mode 100644
index 0000000..08b8c13
--- /dev/null
+++ b/docs/design/2026-06-21-anki-titlefix-proposal.org
@@ -0,0 +1,57 @@
+#+TITLE: Proposal — flashcard-to-anki.py deck name should come from #+TITLE
+
+From: home session, 2026-06-21. Two attached files are the edited
+canonical scripts (flashcard-to-anki.py + its test). Applied locally in
+home as a stopgap; this is the durable proposal for the rulesets
+canonical. Please reconcile and re-sync.
+
+* The bug (longstanding)
+
+flashcard-to-anki.py's default_deck_name returned input_path.stem (the
+filename), so every deck generated through flashcard-sync (which passes no
+--deck) was named after the file, e.g. "personal-drill" / "health-drill"
+/ "kit", not the curated #+TITLE.
+
+flashcard-review.org already documents the intended behavior: "The
+#+TITLE line drives ... the Anki deck name on the phone" and "derives the
+Anki deck ID from the deck name." The script never matched the doc.
+deepsat only looked correct because its first run used an explicit
+--deck "DeepSat Flashcards".
+
+* The fix
+
+default_deck_name(input_path, org_text) now scans for a #+TITLE: line
+(case-insensitive, surrounding whitespace trimmed) and returns it; falls
+back to input_path.stem when there's no non-empty #+TITLE. main() passes
+the already-read org_text. Help text + module docstring updated.
+
+TDD: the two old deck-name tests asserted the buggy basename behavior —
+rewrote them. New tests cover title-driven naming, trimming,
+case-insensitive #+title, basename fallback (no title), and basename
+fallback (blank title). Full file: 29 pass.
+
+No companion script changes needed: flashcard-sync passes no --deck so it
+picks up the new default automatically, and flashcard-stats.py already
+reads #+TITLE. flashcard-review.org needs no change (the script now
+matches what it already says).
+
+* Migration caveat (worth a line in the doc if you want)
+
+Deck ID derives from the deck name, so this fix changes the ID for any
+deck previously generated without --deck. On next import those land as
+new decks; the old basename-named decks keep their review history and
+must be deleted by hand. The workflow's existing "Stable-ID caveat"
+already covers the mechanics. In home this affected personal-drill,
+health-drill, kit (regenerated this session as Personal / Health / KIT,
+with titles also stripped of "Flashcards"/"Drill" per Craig). deepsat is
+unaffected (already title-named).
+
+* Related idea (separate, not in these files) — apkg → org-drill converter
+
+deepsat-fundamentals.apkg (100-card DeepSat subset, made once with
+--deck "DeepSat Fundamentals") has no saved .org source anywhere. Craig
+wants an apkg → org-drill converter — the inverse of flashcard-to-anki.py
+— to recover orphaned decks and pull phone-authored cards back into the
+org source-of-truth. Flagging as a candidate rulesets tool alongside the
+flashcard-* family; deepsat-fundamentals is the concrete first use case.
+Not built yet; raising for the backlog.
diff --git a/docs/design/2026-06-21-anki-titlefix-test.py b/docs/design/2026-06-21-anki-titlefix-test.py
new file mode 100644
index 0000000..87008a8
--- /dev/null
+++ b/docs/design/2026-06-21-anki-titlefix-test.py
@@ -0,0 +1,190 @@
+"""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
+genanki in sys.modules so the module loads; the pure helpers under test never
+call into it.
+"""
+from __future__ import annotations
+
+import importlib.util
+import sys
+import types
+from pathlib import Path
+
+import pytest
+
+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("flashcard_to_anki", SCRIPT)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_default_output_path_targets_phone_anki_dir(drill):
+ """The .apkg is a phone artifact, so it defaults under sync/phone/anki/."""
+ result = drill.default_output_path(Path("/home/x/projects/health/health-drill.org"))
+ assert result == Path.home() / "sync" / "phone" / "anki" / "health-drill.apkg"
+
+
+def test_default_deck_name_uses_org_title(drill):
+ """The #+TITLE drives the Anki deck name, not the filename slug."""
+ org = "#+TITLE: Refutations\n* Section\n** Q? :drill:\na\n"
+ assert drill.default_deck_name(Path("/x/refutation-drill.org"), org) == "Refutations"
+
+
+def test_default_deck_name_title_is_trimmed(drill):
+ """Surrounding whitespace on the #+TITLE value is stripped."""
+ org = "#+TITLE: DeepSat Flashcards \n"
+ assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "DeepSat Flashcards"
+
+
+def test_default_deck_name_title_match_is_case_insensitive(drill):
+ """A lowercase #+title: keyword is still recognized."""
+ org = "#+title: Health Flashcards\n"
+ assert drill.default_deck_name(Path("/x/health-drill.org"), org) == "Health Flashcards"
+
+
+def test_default_deck_name_falls_back_to_basename_without_title(drill):
+ """No #+TITLE line falls back to the input basename, case preserved."""
+ org = "* Section\n** Q? :drill:\na\n"
+ assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "deepsat"
+
+
+def test_default_deck_name_blank_title_falls_back_to_basename(drill):
+ """An empty #+TITLE value is ignored in favour of the basename."""
+ assert drill.default_deck_name(Path("/x/health-drill.org"), "#+TITLE: \n") == "health-drill"
+
+
+# --- section_to_tag (pure) ---
+
+def test_section_to_tag_slugifies_words(drill):
+ assert drill.section_to_tag("Orbital Regimes") == "orbital-regimes"
+
+
+def test_section_to_tag_strips_leading_and_trailing_nonalnum(drill):
+ assert drill.section_to_tag(" People & Roles! ") == "people-roles"
+
+
+def test_section_to_tag_empty_string(drill):
+ assert drill.section_to_tag("") == ""
+
+
+# --- escape_html (pure) ---
+
+def test_escape_html_escapes_amp_lt_gt(drill):
+ assert drill.escape_html("a & b < c > d") == "a &amp; b &lt; c &gt; d"
+
+
+def test_escape_html_plain_text_unchanged(drill):
+ assert drill.escape_html("plain text") == "plain text"
+
+
+def test_escape_html_escapes_amp_first_so_existing_entity_is_literal(drill):
+ # & is replaced before < / >, so a literal "&lt;" becomes "&amp;lt;",
+ # not silently treated as an already-escaped entity.
+ assert drill.escape_html("&lt;") == "&amp;lt;"
+
+
+def test_escape_html_empty_string(drill):
+ assert drill.escape_html("") == ""
+
+
+# --- stable_id (pure) ---
+
+def test_stable_id_is_deterministic(drill):
+ assert drill.stable_id("DeepSat", "deck") == drill.stable_id("DeepSat", "deck")
+
+
+def test_stable_id_salt_changes_the_result(drill):
+ assert drill.stable_id("DeepSat", "deck") != drill.stable_id("DeepSat", "model")
+
+
+def test_stable_id_stays_within_the_reserved_range(drill):
+ value = drill.stable_id("anything", "deck")
+ assert drill.ID_BASE <= value < drill.ID_BASE + drill.ID_RANGE
+
+
+# --- strip_org_metadata (pure) ---
+
+def test_strip_org_metadata_drops_properties_drawer(drill):
+ body = [":PROPERTIES:", ":ID: x", ":END:", "real content"]
+ assert drill.strip_org_metadata(body) == ["real content"]
+
+
+def test_strip_org_metadata_drops_planning_lines(drill):
+ body = ["SCHEDULED: <2026-05-30>", "DEADLINE: <2026-06-01>",
+ "CLOSED: [2026-05-29]", "body"]
+ assert drill.strip_org_metadata(body) == ["body"]
+
+
+def test_strip_org_metadata_leaves_plain_body_unchanged(drill):
+ body = ["line one", "line two"]
+ assert drill.strip_org_metadata(body) == ["line one", "line two"]
+
+
+def test_strip_org_metadata_empty_list(drill):
+ assert drill.strip_org_metadata([]) == []
+
+
+def test_strip_org_metadata_unclosed_drawer_swallows_the_rest(drill):
+ # An unterminated :PROPERTIES: drawer consumes everything after it.
+ body = [":PROPERTIES:", ":ID: x", "still in drawer"]
+ assert drill.strip_org_metadata(body) == []
+
+
+def test_strip_org_metadata_drops_created_date_line(drill):
+ # A created/added date never belongs on a card back.
+ assert drill.strip_org_metadata(["Created: 2026-05-30", "real answer"]) == ["real answer"]
+
+
+# --- parse (pure, core parser) ---
+
+SECTIONED = """* Orbital Regimes
+** What is LEO? :drill:
+Low Earth Orbit.
+** What is GEO? :drill:
+Geostationary Earth Orbit.
+"""
+
+
+def test_parse_returns_front_back_tag_per_card(drill):
+ cards = drill.parse(SECTIONED)
+ assert len(cards) == 2
+ assert cards[0] == ("What is LEO?", "Low Earth Orbit.", "orbital-regimes")
+ assert cards[1][0] == "What is GEO?"
+
+
+def test_parse_card_without_a_section_gets_the_drill_tag(drill):
+ assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", "drill")]
+
+
+def test_parse_strips_properties_drawer_from_back(drill):
+ text = "** Q? :drill:\n:PROPERTIES:\n:ID: abc\n:END:\nThe answer.\n"
+ assert drill.parse(text) == [("Q?", "The answer.", "drill")]
+
+
+def test_parse_trims_leading_and_trailing_blank_body_lines(drill):
+ cards = drill.parse("** Q? :drill:\n\n\nanswer\n\n\n")
+ assert cards[0][1] == "answer"
+
+
+def test_parse_card_with_only_a_drawer_has_empty_back(drill):
+ text = "** Q? :drill:\n:PROPERTIES:\n:ID: x\n:END:\n"
+ assert drill.parse(text) == [("Q?", "", "drill")]
+
+
+def test_parse_joins_multiline_body_with_br(drill):
+ cards = drill.parse("** Q? :drill:\nline one\nline two\n")
+ assert cards[0][1] == "line one<br>line two"
+
+
+def test_parse_no_drill_cards_returns_empty(drill):
+ assert drill.parse("* Section\nno drill cards here\n") == []
diff --git a/docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org b/docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org
new file mode 100644
index 0000000..37a866f
--- /dev/null
+++ b/docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org
@@ -0,0 +1,68 @@
+#+TITLE: Build request — apkg → org-drill converter (inverse of flashcard-to-anki.py)
+
+From: home session, 2026-06-21. Craig wants this built (backlogged, not
+urgent). Standalone build request — the earlier anki-title-fix-proposal
+only mentioned it in passing; this is the real ask.
+
+* Why
+
+The flashcard pipeline is one-directional (org-drill → apkg). Decks
+authored or curated on the phone, and orphaned apkgs whose .org source
+was never saved, can't get back into the org source-of-truth. Concrete
+case: deepsat-fundamentals.apkg — a 100-card DeepSat subset generated
+once with --deck "DeepSat Fundamentals" — has no .org source anywhere on
+ratio, velox, or in work git history. The converter recovers it and makes
+phone → org round-tripping possible.
+
+* What — contract (inverse of flashcard-to-anki.py)
+
+Input: an Anki =.apkg= (a zip containing collection.anki2 / .anki21
+sqlite, plus a media blob).
+Output: an org-drill =.org= file in the house canonical shape that
+flashcard-stats.py / flashcard-to-anki.py already agree on.
+
+Mapping (mirror flashcard-to-anki.py's parse/build):
+- Deck name (from the apkg) → =#+TITLE:=.
+- Each note → =** <Front> :drill:= with the Back as the body.
+- Card tag → top-level =* Section= grouping (inverse of section_to_tag;
+ cards sharing a tag collect under one section; the slug won't round-trip
+ to the exact original section title, so this is best-effort — emit the
+ tag as the section heading and let a human retitle).
+- Back HTML → org: convert =<br>= back to newlines; unescape
+ =&amp;/&lt;/&gt;=; strip the =<hr id="answer">= the card template adds
+ (the Back field itself shouldn't contain it, but guard anyway).
+- Generate a fresh =:ID:= UUID per card in a =:PROPERTIES:= drawer so the
+ output is immediately org-drill-valid and round-trips back through
+ flashcard-to-anki.py. (Note: GUIDs in flashcard-to-anki.py are derived
+ from the front text, not the :ID:, so a regenerated apkg still matches
+ existing phone cards by front — call that out in the docstring.)
+
+Edge cases to cover in tests (Normal/Boundary/Error):
+- Multiple decks in one apkg (emit one file per deck, or error asking for
+ a deck filter — pick one and document it).
+- Notes with multiple fields / non-basic note types (the pipeline only
+ models Front/Back — skip or warn on others, don't silently drop).
+- HTML entities, embedded =<br>=, and any =Source:= footer surviving
+ round-trip.
+- Empty back; media references (flag, since org side has no media path).
+- collection.anki2 vs .anki21 schema differences.
+
+* Where it lives
+
+Rulesets-owned, beside the flashcard-* family
+(=claude-templates/.ai/scripts/=): suggest =anki-to-flashcard.py= (or
+=apkg-to-orgdrill.py= — your naming call). Add tests under
+=scripts/tests/=. A new file can't be built downstream — home/.ai/scripts/
+is wiped to match the template by the startup =--delete= rsync — so this
+has to be built in the rulesets canonical. PEP 723 uv-run script like its
+sibling; genanki isn't needed for reading (stdlib =zipfile= + =sqlite3=
+suffice), so it has no runtime deps.
+
+* Acceptance
+
+Round-trip test: take a known org-drill source, run it through
+flashcard-to-anki.py, run the result back through this converter, and
+assert the cards (front/back/section) match the original (modulo
+regenerated :ID:s and best-effort section titles). Plus: run it on the
+real deepsat-fundamentals.apkg and hand the recovered .org back so its
+source can be filed (work project).
diff --git a/docs/design/2026-06-21-flashcard-stats-refutation-proposal.org b/docs/design/2026-06-21-flashcard-stats-refutation-proposal.org
new file mode 100644
index 0000000..bbbe175
--- /dev/null
+++ b/docs/design/2026-06-21-flashcard-stats-refutation-proposal.org
@@ -0,0 +1,57 @@
+#+TITLE: Proposal — flashcard-stats.py refutation / claim-prompt mode
+
+From: home session, 2026-06-21. Backlog, not urgent. Relates to the
+refutation-drill deck being built in the home project.
+
+* Problem
+
+A new card family doesn't fit the linter: the *refutation / claim-prompt*
+card. Its heading is a bare false claim ("The earth is flat.") and its
+body is the rebuttal. This is a legit org-drill simple card (org-drill is
+happy), but flashcard-stats.py — built for Q&A decks — trips two BLOCKING
+checks on every such card, both false positives:
+
+- *non-prompt heading*: a declarative claim has no '?' and no
+ imperative verb, so it reads as "topic-as-heading not yet rewritten".
+ But for this family the declarative claim IS the intended prompt.
+- *answer leakage*: the claim's words necessarily reappear in the
+ refutation, so front/back overlap is high. But the answer (the rebuttal)
+ is not given away by the claim — there's no actual leakage.
+
+Concrete: the home refutation-drill.org (6 cards) reports 6 non-prompt
+headings + 1 leakage WARN, so flashcard-sync's gate blocks it entirely.
+The deck currently has to be generated with the flashcard-to-anki.py
+override, losing the safety net.
+
+* Proposed fix
+
+A per-deck opt-in marker that switches the two checks off for that file
+only. Two options (your call):
+
+1. A file-level keyword: =#+DECK_KIND: refutation= near the top. When
+ present, flashcard-stats skips the non-prompt-heading check and the
+ answer-leakage check for the whole file (keeps the others:
+ missing-:ID:, *** Answer sub-headers, duplicate fronts, the
+ non-blocking NOTEs).
+2. A per-card tag: cards tagged =:claim:= (alongside =:drill:=) are
+ exempted from those two checks individually.
+
+Option 1 is simpler and matches how this deck works (the whole file is
+one family). Option 2 is finer-grained if a deck ever mixes families.
+
+Either way: document the new card family in flashcard-review.org (a
+"Refutation / claim-prompt cards" subsection under Canonical Card Shape —
+heading is the bare claim, body is snap-response + backups + named-fallacy
++ restate, Source footer), and note that flashcard-sync then works
+normally on these decks.
+
+* Affected files
+- =flashcard-stats.py= — the check skip + (option 1) keyword parse / (option 2) tag check.
+- =flashcard-review.org= — document the family + the marker.
+- =flashcard-to-anki.py= / =flashcard-sync= — no change needed (they don't gate on heading form).
+- Tests: add cases for a refutation-marked file passing despite declarative headings + claim/answer overlap.
+
+* Companion context
+The home deck's card format and the org-drill-fine / Anki-linter-fights
+finding are written up in home:refutation-drill-sources.org (Tooling
+note). The override command is documented there too.
diff --git a/docs/design/2026-06-21-host-identity-guard-proposal.org b/docs/design/2026-06-21-host-identity-guard-proposal.org
new file mode 100644
index 0000000..f389825
--- /dev/null
+++ b/docs/design/2026-06-21-host-identity-guard-proposal.org
@@ -0,0 +1,54 @@
+#+TITLE: From archsetup — hardcoded machine identity in CLAUDE.md (consider fleet-wide)
+#+DATE: 2026-06-21
+
+* What we did
+
+Built a Super+F Dirvish popup in the archsetup/dotfiles + .emacs.d projects,
+modeled on the existing Super+Shift+N org-capture popup (launcher script names an
+emacsclient frame, Hyprland window rules float it, an Emacs command runs in the
+frame and q closes it). Cross-project: dotfiles half committed from archsetup,
+Emacs half handed off to .emacs.d's inbox.
+
+* The bug it surfaced
+
+While stowing on this machine, =make stow hyprland= pulled the *velox* host tier,
+and =uname -n= returned =velox=. But archsetup's CLAUDE.md asserted, as a fixed
+fact, "This machine is **ratio**." It was simply wrong on velox — a stale
+identity baked into a per-project doc that travels to every machine via git.
+
+I'd been reasoning from that line all session (e.g. "the touchpad-auto reminder
+is velox-only, and we're on ratio, so skip it") — exactly backwards. A hardcoded
+"this machine is X" in a synced/tracked project file is a latent trap on any
+multi-machine setup: the file is identical on every host, so the claim is false
+on every host but one.
+
+* The fix (this project)
+
+Replaced the fixed identity with a runtime instruction. The attached CLAUDE.md
+now reads, in the Notes section:
+
+ Never assume which machine this is — always run =uname -n= to find the hostname
+ (the =hostname= binary is absent, so =uname -n= is the source of truth;
+ =uname -r= is the kernel release, not the host). The fleet is ratio
+ (workstation) and velox (laptop), both Hyprland (Wayland)...
+
+(Craig initially said =uname -r=; that's the kernel release. =uname -n= is the
+nodename/hostname, which is what the stow host-tier logic already keys on.)
+
+* Why this is a rulesets concern
+
+This isn't an archsetup-only quirk. Any project whose CLAUDE.md / notes get
+synced or cloned across machines can hardcode environment identity — current
+host, current OS, "the laptop", an IP, a display name — and be wrong everywhere
+the doc lands but the origin. rulesets governs how every project's CLAUDE.md and
+rules are shaped, so it's the right layer to consider a general guard:
+
+- A rule (claude-rules) along the lines of: don't assert mutable
+ environment/host identity as a fixed fact in a tracked/synced project file;
+ derive it at runtime (=uname -n= for host, etc.) and name the command.
+- Possibly a startup or codify-time lint that flags "this machine is <name>" /
+ "the current host is" style claims in CLAUDE.md.
+
+Sending the edited CLAUDE.md (attached separately) plus this note so the rulesets
+session can decide whether to codify the broader pattern. Proposal, not a
+directive — your value gate applies.