aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ai/protocols.org2
-rw-r--r--.ai/sessions/2026-06-22-01-33-spec-review-fold-coverage-fix-inbox-triage.org57
-rw-r--r--claude-rules/commits.md4
-rw-r--r--claude-templates/.ai/protocols.org2
-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
-rw-r--r--todo.org36
11 files changed, 770 insertions, 3 deletions
diff --git a/.ai/protocols.org b/.ai/protocols.org
index 05f889b..da6928f 100644
--- a/.ai/protocols.org
+++ b/.ai/protocols.org
@@ -427,7 +427,7 @@ When creating commits:
- Keep messages clear and informative
3. **No Claude-tooling artifacts**: Commit messages describe project changes only — the meta-process of how work got shipped stays out of public git history.
- - **ABSOLUTELY NO** mentions of =notes.org=, =session-context.org=, =.ai/sessions/=, =todo.org=, "session wrap-up", or session timestamps (e.g., "Session YYYY-MM-DD HH:MM → ...")
+ - **ABSOLUTELY NO** mentions of =notes.org=, =session-context.org=, =.ai/= (including =.ai/sessions/=), =.claude/=, =CLAUDE.md=, =todo.org=, "session wrap-up", or session timestamps (e.g., "Session YYYY-MM-DD HH:MM → ..."), except when one of those files is itself the change — then name what changed by category, not the surrounding tooling layer
- Subject lines must NEVER start with =session:= as a conventional-commit type — use =docs:=, =refactor:=, =fix:=, =feat:=, =chore:=, etc. (real change categories)
- When a wrap-up commit bundles many changes from a session, describe what /shipped/ (e.g., =refactor: extract RAID logic + add bats testing infrastructure=), not that a session happened
- Same spirit as the no-Claude-attribution rule: the tooling stays invisible in =git log=
diff --git a/.ai/sessions/2026-06-22-01-33-spec-review-fold-coverage-fix-inbox-triage.org b/.ai/sessions/2026-06-22-01-33-spec-review-fold-coverage-fix-inbox-triage.org
new file mode 100644
index 0000000..98e745e
--- /dev/null
+++ b/.ai/sessions/2026-06-22-01-33-spec-review-fold-coverage-fix-inbox-triage.org
@@ -0,0 +1,57 @@
+#+TITLE: Session Context
+#+DATE: 2026-06-21
+
+* Summary
+
+** Active Goal
+
+Process the rulesets inbox — 10 logical proposals across three groups (spec-workflow tightening, emacs-wttrin coverage/rules, home flashcard family) plus two archsetup handoffs. Built Groups A and B; filed Group C + archsetup as backlog tasks; wrapped.
+
+** Decisions
+
+- Spec review/response: incorporate the review INTO the spec. Findings are now =* Review findings= TODO tasks with a =[/]= cookie (mirroring =* Decisions=), =:blocking:= marks high-priority; the responder completes each in place. This supersedes the proposal's keep-vs-delete-review-file fork (no file at all). A1 (rerun readiness rubric on scope-expanding response) folded into the spec-response Phase 4 gate; A3 (roles explicit) + A4 (source external-dep checks) added as spec-review principles.
+- coverage-summary.el: applied the generated-package-file exclusion bugfix (=-autoloads.el=/=-pkg.el=); did NOT adopt emacs-wttrin's header rewrite (=.claude/scripts/= → tracked =scripts/=) — flagged as a separate install-location question.
+- no-attribution tightening: option 1 (documented scan discipline + exemptions, no hook). Accepted proposal changes 1-3; modified change 4 (rigid token-grep → documented Before-Committing scan) because a blanket grep false-positives on legit subject mentions, file-is-the-change commits, and private repos. Both new rules carry file-is-the-change + private-single-user-repo exemptions, framed around public/shared-remote repos.
+- Group C (flashcard) + archsetup: filed as detailed, prioritized backlog tasks rather than built (Craig paused).
+
+** Data Collected / Findings
+
+- Pre-existing committed drift in inbox-zero.org (canonical = new two-inbox version, mirror = old) — fixed by mirror sync (98ebb2f). This also satisfied the roam "inbox zero should check two places" item (already gone from roam by session end).
+- review-code (Step 1) caught that the spec refold left spec-create.org + INDEX.org still describing the old review-file convention — fixed in the same commit.
+- coverage-summary ERT tests already shipped (b46619c, 12 cs-* tests), so the "add tests" proposal was mostly pre-satisfied. Added 3 (autoloads-exclusion + =--source-files= non-recursive + =--under-dir= filter/rekey); 15 cs-* tests now.
+- Open: coverage-summary.el installs to =.claude/scripts/= (gitignored in code projects) so CI can't run =make coverage-summary= — filed [#C].
+- Roam inbox evolved mid-session: new "rulesets: multiple agent source improvements" item (naming the agent, Codex-friendly workflow wording, multi-LLM ai-term) — left for a future session.
+
+** Files Modified
+
+Committed + pushed (origin 0/0): 98ebb2f (chore: inbox-zero mirror sync), ed27e3c (refactor: spec review fold — spec-review/response/create + INDEX, canonical+mirror), fb86736 (fix: coverage-summary autoloads exclusion), 0751b3c (test: coverage-summary source-files + under-dir), 91217d9 (docs: no-attribution tooling-path tightening — commits.md + protocols.org).
+
+Wrap-up commit (this session's tail): todo.org (6 new tasks), 6 docs/design files (anki-titlefix bundle, apkg buildreq, refutation proposal, host-identity proposal), session archive.
+
+Handoffs sent: home (spec-workflow reconcile; flashcard items filed), emacs-wttrin (coverage cluster; no-attribution tightening), archsetup (host-identity filed).
+
+** Next Steps
+
+- Flashcard cluster coordination: "Anki deck name from #+TITLE" [#B, ready code], "Reconcile flashcard multi-tag tooling" (:315), and "flashcard-stats refutation mode" [#C] all edit flashcard-to-anki.py / flashcard-stats.py — build together to avoid conflicting edits.
+- apkg → org-drill converter [#C], host-identity guard [#C], coverage-summary install location [#C], warn-only enum hook [#D] — all filed.
+- New roam item "rulesets: multiple agent source improvements" awaits a future session.
+
+KB: promoted 0 / consulted yes (session was process/workflow edits — no durable cross-project facts to promote).
+
+* Session Log
+
+** Startup + inbox triage
+
+Clean startup (no crash anchor). Inbox held 12 files / 10 logical proposals + (mid-session) 2 archsetup handoffs. Grouped into A (spec-workflow), B (emacs-wttrin), C (flashcard). Walked group by group per Craig.
+
+** Group A — spec review folded into the spec
+
+Read both canonical workflows + diffed home's edited spec-review.org. Four candidate changes (A1 readiness rerun, A2 review-file retention, A3 roles-explicit, A4 source-checks). Craig chose to incorporate the review into the spec (option 1), reusing the decisions-as-tasks machinery. Applied all edits to spec-review.org + spec-response.org; review-code caught spec-create.org + INDEX.org stragglers (fixed). make test green ×2. Commits 98ebb2f + ed27e3c, pushed. Replied to home; deleted 3 inbox files.
+
+** Group B — coverage-summary cluster + no-attribution tightening
+
+Found canonical coverage-summary.el is the elisp bundle and tests already existed. Applied the autoloads bugfix via TDD (red→green, fb86736) + 3 tests (0751b3c). Did not adopt the header relocation — flagged install-location as a follow-up. No-attribution tightening: option 1, edited commits.md (2 spots) + protocols.org item 3 (91217d9). Replied to emacs-wttrin twice; deleted 3 inbox files.
+
+** Pause + inbox zero
+
+Filed Group C (3 flashcard) + archsetup host-identity as 4 backlog tasks, plus 2 session follow-ups (coverage-summary location, enum hook) — 6 tasks total under Rulesets Open Work, properly prioritized. Preserved the anki edited code + all proposals in docs/design/. Replied to home + archsetup; project inbox empty. Then wrapped.
diff --git a/claude-rules/commits.md b/claude-rules/commits.md
index a3ec0f2..5fe8f1b 100644
--- a/claude-rules/commits.md
+++ b/claude-rules/commits.md
@@ -185,6 +185,8 @@ Don't write "per `testing.md`, integration tests must hit a real DB" or "the rul
Edge case: when one of these files *is* the change (a commit in the rulesets repo, an edit to a project's `CLAUDE.md`), describe what changed and why without invoking the wider personal-rules layer around it. The commit can absolutely say "tighten testing rule for legacy code". It shouldn't say "per the personal-rules layer this file is loaded into…".
+**Tooling-path enumeration is the same leak.** Citing a rule as authority isn't the only way the tooling layer leaks into history. A commit whose *content* must name these paths — a `.gitignore` adding `.claude/`, `CLAUDE.md`, `.ai/` — has unavoidable, correct file content, but its *message prose* must not enumerate them ("chore: ignore .claude tooling, CLAUDE.md, and session files"). On a public or shared-remote repo that enumeration exposes the tooling layer's structure in the log just as a citation would. Name the category instead: "chore: extend gitignore for local tooling and build artifacts". The same holds for any incidental mention, not only `.gitignore` commits. Two exemptions: a commit whose change *is* one of these files (the edge case above), and private single-user repos with no shared remote, where the history is the project and there's no third party to leak to.
+
Different artifact types carry different content. Don't duplicate.
**PR descriptions:** four sections, in order.
@@ -452,7 +454,7 @@ independent gate.
## Before Committing
1. Check author identity: `git log -1 --format='%an <%ae>'` — should be the user.
-2. Scan the message for AI-attribution language (including emojis and footers).
+2. Scan the message for AI-attribution language (including emojis and footers), and on a public or shared-remote repo for tooling-path enumeration — prose that lists `CLAUDE.md`, `.claude/`, `.ai/`, `todo.org`, `notes.org`, or `session-context`. Name the category, not the paths. Exempt: a commit whose change is one of those files, and private single-user repos.
3. Review the diff — only intended changes staged; no unrelated files.
4. Confirm staged files belong in the repo: nothing that the project's policy keeps untracked (the personal-tooling set in gitignore-mode projects), and in repos with a canonical/mirror split, the edit is on the canonical side — a mirror-only edit gets reverted by the next sync.
5. Run tests and linters (see `verification.md`).
diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org
index 05f889b..da6928f 100644
--- a/claude-templates/.ai/protocols.org
+++ b/claude-templates/.ai/protocols.org
@@ -427,7 +427,7 @@ When creating commits:
- Keep messages clear and informative
3. **No Claude-tooling artifacts**: Commit messages describe project changes only — the meta-process of how work got shipped stays out of public git history.
- - **ABSOLUTELY NO** mentions of =notes.org=, =session-context.org=, =.ai/sessions/=, =todo.org=, "session wrap-up", or session timestamps (e.g., "Session YYYY-MM-DD HH:MM → ...")
+ - **ABSOLUTELY NO** mentions of =notes.org=, =session-context.org=, =.ai/= (including =.ai/sessions/=), =.claude/=, =CLAUDE.md=, =todo.org=, "session wrap-up", or session timestamps (e.g., "Session YYYY-MM-DD HH:MM → ..."), except when one of those files is itself the change — then name what changed by category, not the surrounding tooling layer
- Subject lines must NEVER start with =session:= as a conventional-commit type — use =docs:=, =refactor:=, =fix:=, =feat:=, =chore:=, etc. (real change categories)
- When a wrap-up commit bundles many changes from a session, describe what /shipped/ (e.g., =refactor: extract RAID logic + add bats testing infrastructure=), not that a session happened
- Same spirit as the no-Claude-attribution rule: the tooling stays invisible in =git log=
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.
diff --git a/todo.org b/todo.org
index d7101bb..0312f32 100644
--- a/todo.org
+++ b/todo.org
@@ -34,6 +34,42 @@ Tags are assigned and refreshed by =task-audit=; =task-review= keeps them honest
* Rulesets Open Work
+** TODO [#B] Anki deck name from #+TITLE :bug:
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:END:
+flashcard-to-anki.py's =default_deck_name= returns =input_path.stem= (the filename), so every deck built through =flashcard-sync= (which passes no =--deck=) is named after the file, not the curated =#+TITLE=. =flashcard-review.org= already documents the intended behavior ("the #+TITLE line drives the Anki deck name"); the script never matched it. Fix: =default_deck_name(input_path, org_text)= scans for a =#+TITLE:= line (case-insensitive, trimmed) and returns it, basename fallback when absent; =main()= passes the already-read =org_text=. Edited script + test ready (validated, 29 pass): [[file:docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py][script]], [[file:docs/design/2026-06-21-anki-titlefix-test.py][test]], rationale [[file:docs/design/2026-06-21-anki-titlefix-proposal.org][proposal]]. Apply to both =.ai/scripts/= and =claude-templates/.ai/scripts/=, sync-check + make test. Migration caveat: deck ID derives from the name, so decks previously built without =--deck= land as new decks on next import (old basename-named decks keep history, delete by hand). Coordinate with "Reconcile flashcard multi-tag tooling into canonical" below — both edit =flashcard-to-anki.py=, build together to avoid conflicting edits. Shared-asset, review-gated. From home 2026-06-21.
+
+** TODO [#C] apkg → org-drill converter :feature:
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:END:
+Inverse of =flashcard-to-anki.py=: read an Anki =.apkg= (zip → =collection.anki2=/=.anki21= sqlite) and emit an org-drill =.org= in the house canonical shape. Recovers orphaned decks (=deepsat-fundamentals.apkg= has no saved =.org= source) and enables phone→org round-trip. Mapping: deck name → =#+TITLE=; each note → =** <Front> :drill:= with Back as body; card tag → =* Section= grouping (best-effort); Back HTML → org (=<br>= → newlines, unescape entities, strip =<hr id="answer">=); fresh =:ID:= UUID per card. Edge cases for tests: multiple decks per apkg, non-basic note types (skip/warn), HTML entities, empty back, media refs, =.anki2= vs =.anki21= schema. Lives beside the flashcard-* family in =claude-templates/.ai/scripts/= (a new file must be built in canonical — downstream =.ai/scripts/= is wiped by startup =--delete=). PEP 723 uv-run, stdlib =zipfile= + =sqlite3= (no genanki for reading). Acceptance: round-trip a known org-drill source through =flashcard-to-anki.py= then back, assert cards match. Build request: [[file:docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org][buildreq]]. Backlog, not urgent. From home 2026-06-21.
+
+** TODO [#C] flashcard-stats refutation / claim-prompt mode :feature:
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:END:
+A refutation card (heading is a bare false claim, body is the rebuttal) is valid org-drill but trips two BLOCKING =flashcard-stats.py= checks as false positives: non-prompt-heading (a declarative claim has no =?= or imperative verb) and answer-leakage (claim words reappear in the rebuttal). =flashcard-sync='s gate then blocks the whole deck. Fix (pick one): a file-level =#+DECK_KIND: refutation= keyword that skips those two checks for the file, or a per-card =:claim:= tag exempting individual cards. Option 1 is simpler and matches how the deck works (the whole file is one family). Also document the family in =flashcard-review.org= and add tests (refutation-marked file passes despite declarative headings + claim/answer overlap). Edits =flashcard-stats.py= — coordinate with the multi-tag reconcile, same file. Proposal: [[file:docs/design/2026-06-21-flashcard-stats-refutation-proposal.org][proposal]]. Backlog. From home 2026-06-21.
+
+** TODO [#C] Guard against hardcoded host identity in synced files :feature:
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:END:
+A =CLAUDE.md= / notes file that asserts mutable environment identity as a fixed fact ("This machine is ratio", a current OS, an IP, "the laptop") is false on every machine the synced/tracked file lands on but one. It bit a real archsetup session: a stale "this machine is ratio" line made the agent reason backwards all session while on velox. Proposal: a claude-rule — don't assert mutable host/env identity as a fixed fact in a tracked/synced project file; derive it at runtime and name the command (=uname -n= for host; the =hostname= binary is often absent). Optionally a codify- or startup-time lint flagging "this machine is <name>" / "the current host is" style claims. Decide rule-only vs rule+lint. Proposal: [[file:docs/design/2026-06-21-host-identity-guard-proposal.org][proposal]]. From archsetup 2026-06-21.
+
+** TODO [#C] coverage-summary.el install location vs CI reachability :bug:
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:END:
+The elisp bundle installs =coverage-summary.el= into =.claude/scripts/=, which is gitignored in code projects, so CI can't run =make coverage-summary= against it. emacs-wttrin flagged this (its copy's header was rewritten to claim a tracked =scripts/= home). Decide: ship =coverage-summary.el= to a tracked =scripts/= dir so CI reaches it, or keep =.claude/scripts/= and document it as a local-only helper. If moved, reconcile the bundle install path + the =make coverage-summary= fragment + the script's header comment. Surfaced 2026-06-21 during the coverage-summary autoloads bugfix (commit fb86736).
+
+** TODO [#D] Warn-only pre-commit hook for tooling-path enumeration :feature:
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:END:
+Optional enforcement teeth for the no-attribution / no-tooling-artifacts tightening landed 2026-06-22 (commit 91217d9), which is documentation-only. A warn-only (not blocking) pre-commit hook could scan the commit subject + body for tooling-path enumeration (=CLAUDE.md=, =.claude/=, =.ai/=, =todo.org=, =notes.org=, =session-context=) and AI-attribution language, with the two exemptions baked in: a commit whose change IS one of those files, and private single-user repos. Must warn, not block — a rigid grep false-positives on legit subject mentions. Deferred: Craig chose docs-only for now.
+
** VERIFY [#B] Helper-instance support — concurrent same-project Claude :feature:spec:
:PROPERTIES:
:CREATED: [2026-06-11 Thu]