diff options
| -rw-r--r-- | .ai/protocols.org | 2 | ||||
| -rw-r--r-- | .ai/sessions/2026-06-22-01-33-spec-review-fold-coverage-fix-inbox-triage.org | 57 | ||||
| -rw-r--r-- | claude-rules/commits.md | 4 | ||||
| -rw-r--r-- | claude-templates/.ai/protocols.org | 2 | ||||
| -rwxr-xr-x | docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py | 246 | ||||
| -rw-r--r-- | docs/design/2026-06-21-anki-titlefix-proposal.org | 57 | ||||
| -rw-r--r-- | docs/design/2026-06-21-anki-titlefix-test.py | 190 | ||||
| -rw-r--r-- | docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org | 68 | ||||
| -rw-r--r-- | docs/design/2026-06-21-flashcard-stats-refutation-proposal.org | 57 | ||||
| -rw-r--r-- | docs/design/2026-06-21-host-identity-guard-proposal.org | 54 | ||||
| -rw-r--r-- | todo.org | 36 |
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("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + + +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 & b < c > 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 "<" becomes "&lt;", + # not silently treated as an already-escaped entity. + assert drill.escape_html("<") == "&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 + =&/</>=; 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. @@ -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] |
