summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 20:05:24 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 20:05:24 +0200
commit9e1e892de95ac1573a99bda316b306a56e17fc8a (patch)
tree6f871a6dc18048720456c5963923eaddae9d3c58 /docs
parent0dd76139abf2e9a0f1ec362424d68ba8ae5d8c8f (diff)
downloadlectio-9e1e892de95ac1573a99bda316b306a56e17fc8a.tar.gz
lectio-9e1e892de95ac1573a99bda316b306a56e17fc8a.zip
docs: implementation plan for national-bibles corpora
6 TDD tasks: sidecars+meta parser, user-dir discovery+override registry, reading_lang/reading_version config+resolver, --corpus-check validator, wire resolver into rendering + surface user corpora, scripts+Makefile. Code verified against internal/ini ([]Section) and configPath() helper.
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-07-27-lectio-national-bibles.md699
1 files changed, 699 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-27-lectio-national-bibles.md b/docs/superpowers/plans/2026-07-27-lectio-national-bibles.md
new file mode 100644
index 0000000..c07b254
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-27-lectio-national-bibles.md
@@ -0,0 +1,699 @@
+# National Bibles Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let anyone add their nation's Bible as a `<code>.tsv` + `<code>.ini` pair — dropped into `~/.config/lectio/corpora/` at runtime or embedded via a validated Makefile step — so offline readings render in any language.
+
+**Architecture:** A corpus is a file pair. Built-ins live in `internal/bible/corpora/` (glob-embedded); user corpora in `~/.config/lectio/corpora/` override built-ins of the same code. Reading-text selection is config-driven (`reading_version` > `reading_lang` match > `ui_language` match > Latin `vul`). One validator (`lectio --corpus-check`) gates both delivery paths.
+
+**Tech Stack:** Go (stdlib + `internal/ini`), 6-column TSV, INI sidecars, `go:embed`, Makefile + POSIX `sh`.
+
+**Spec:** `docs/superpowers/specs/2026-07-27-lectio-national-bibles-design.md`
+
+## Global Constraints
+
+- **Corpus text format:** exactly 6 tab-separated columns, UTF-8, one verse per row: `Book · Abbrev · BookNum · Chapter · Verse · Text`. Loader uses columns 1/4/5/6; columns 2/3 are informational.
+- **`Book` column:** canonical English name; must be one of the 73 keys in `internal/bible/books.ini`.
+- **Metadata sidecar `<code>.ini`:** keys `lang` (required), `name` (required), `psalm_system` (required, one of `vulgate`/`hebrew`/`drb`), `sigla` (optional). Parsed with `internal/ini` (full-line comments only).
+- **User corpus dir:** `~/.config/lectio/corpora/`. A user `<code>` overrides the embedded one; new codes add.
+- **Selection order:** `reading_version` (explicit code) → corpus whose `lang` == `reading_lang` → corpus whose `lang` == `ui_language` → `""` (caller uses `vul`).
+- **`traditional_lang` is not touched** and is never read by the corpus resolver.
+- **`psalm_system` is metadata only** in this feature — captured + validated, never applied (no offline psalm parts exist yet).
+- **Purity:** `internal/bible` imports only stdlib + `internal/ini`. No network, no new third-party deps.
+- **Non-breaking:** `--ref`, version comparison, TUI/web daily view, and the scraper path keep working unchanged.
+- **Every task ends green:** `gofmt -l` clean and `go test ./...` passing before commit.
+
+---
+
+### Task 1: Built-in sidecars + metadata parser
+
+**Files:**
+- Create: `internal/bible/corpora/vul.ini`, `drb.ini`, `grb.ini`, `wuj.ini`
+- Modify: `internal/bible/bible.go` (embed directive)
+- Create: `internal/bible/corpusmeta.go`
+- Test: `internal/bible/corpusmeta_test.go`
+
+**Interfaces:**
+- Produces: `type CorpusMeta struct { Code, Lang, Name, PsalmSystem, Sigla string }`; `func parseCorpusMeta(code string, data []byte) CorpusMeta`; `func embeddedCorpusMeta(code string) (CorpusMeta, bool)`.
+
+- [ ] **Step 1: Write the sidecars.** Each is four lines. `internal/bible/corpora/vul.ini`:
+
+```ini
+; lectio corpus metadata
+lang = la
+name = Vulgate (Latin)
+psalm_system = vulgate
+```
+
+`drb.ini`: `lang = en`, `name = Douay-Rheims (English)`, `psalm_system = drb`.
+`grb.ini`: `lang = el`, `name = Greek`, `psalm_system = vulgate`.
+`wuj.ini`: `lang = pl`, `name = Wujek (Polish)`, `psalm_system = vulgate`.
+
+- [ ] **Step 2: Widen the embed** in `internal/bible/bible.go` — replace the explicit `//go:embed corpora/wuj.tsv corpora/vul.tsv corpora/grb.tsv corpora/drb.tsv` with:
+
+```go
+//go:embed corpora
+var corporaFS embed.FS
+```
+
+(embedding the whole dir picks up both `*.tsv` and `*.ini`; existing `corporaFS.ReadFile("corpora/"+version+".tsv")` calls are unchanged.)
+
+- [ ] **Step 3: Write the failing test** `internal/bible/corpusmeta_test.go`:
+
+```go
+package bible
+
+import "testing"
+
+func TestEmbeddedCorpusMeta(t *testing.T) {
+ m, ok := embeddedCorpusMeta("drb")
+ if !ok {
+ t.Fatal("drb sidecar not found")
+ }
+ if m.Lang != "en" || m.PsalmSystem != "drb" || m.Name == "" {
+ t.Fatalf("bad drb meta: %+v", m)
+ }
+}
+```
+
+- [ ] **Step 4: Run it, verify it fails** — `go test ./internal/bible/ -run TestEmbeddedCorpusMeta` → FAIL (undefined `embeddedCorpusMeta`).
+
+- [ ] **Step 5: Implement** `internal/bible/corpusmeta.go`:
+
+```go
+package bible
+
+import "github.com/lukaszkasprzak/lectio/internal/ini"
+
+// CorpusMeta is the sidecar metadata for a bible corpus (<code>.ini).
+type CorpusMeta struct {
+ Code, Lang, Name, PsalmSystem, Sigla string
+}
+
+// parseCorpusMeta reads a section-less sidecar. ini.Parse returns
+// []ini.Section{Name, Pairs}; keys before any [section] land in the section
+// whose Name == "" (verified against internal/ini).
+func parseCorpusMeta(code string, data []byte) CorpusMeta {
+ m := CorpusMeta{Code: code}
+ secs, err := ini.Parse(data)
+ if err != nil {
+ return m
+ }
+ for _, s := range secs {
+ if s.Name != "" {
+ continue
+ }
+ for _, p := range s.Pairs {
+ switch p.Key {
+ case "lang":
+ m.Lang = p.Val
+ case "name":
+ m.Name = p.Val
+ case "psalm_system":
+ m.PsalmSystem = p.Val
+ case "sigla":
+ m.Sigla = p.Val
+ }
+ }
+ }
+ return m
+}
+
+func embeddedCorpusMeta(code string) (CorpusMeta, bool) {
+ data, err := corporaFS.ReadFile("corpora/" + code + ".ini")
+ if err != nil {
+ return CorpusMeta{}, false
+ }
+ return parseCorpusMeta(code, data), true
+}
+```
+
+- [ ] **Step 6: Run tests** — `go test ./internal/bible/` → PASS. Then `gofmt -l internal/bible/`.
+
+- [ ] **Step 7: Commit** — `git add -A && git commit -m "feat(bible): corpus metadata sidecars + parser"`
+
+---
+
+### Task 2: Corpus registry, user-dir discovery, override
+
+**Files:**
+- Modify: `internal/bible/bible.go` (registry + `load` lookup)
+- Create: `internal/bible/registry.go`
+- Modify: `internal/config/config.go` (add `CorporaDir()`)
+- Test: `internal/bible/registry_test.go`
+
+**Interfaces:**
+- Consumes: `CorpusMeta`, `embeddedCorpusMeta` (Task 1).
+- Produces: `func SetUserCorporaDir(dir string)`; `func Corpora() []CorpusMeta`; `func Meta(code string) (CorpusMeta, bool)`; `func CorporaForLang(lang string) []CorpusMeta`. `load(code)` reads the user file when present, else embed.
+- `config.CorporaDir() (string, error)` → `~/.config/lectio/corpora/`.
+
+- [ ] **Step 1: Add the config path helper** to `internal/config/config.go`, mirroring the existing `CalendarsDir()`:
+
+```go
+// CorporaDir is the user directory for drop-in bible corpora (<code>.tsv +
+// <code>.ini), alongside calendars/ under the lectio config dir. Mirrors
+// CalendarsDir exactly (verified: it uses configPath() then filepath.Dir).
+func CorporaDir() (string, error) {
+ p, _, err := configPath()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(filepath.Dir(p), "corpora"), nil
+}
+```
+
+- [ ] **Step 2: Write the failing test** `internal/bible/registry_test.go`:
+
+```go
+package bible
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestUserCorpusOverride(t *testing.T) {
+ dir := t.TempDir()
+ os.WriteFile(filepath.Join(dir, "drb.tsv"), []byte("Genesis\tGn\t1\t1\t1\tUSER TEXT\n"), 0o644)
+ os.WriteFile(filepath.Join(dir, "drb.ini"), []byte("lang = en\nname = User DRB\npsalm_system = drb\n"), 0o644)
+ SetUserCorporaDir(dir)
+ t.Cleanup(func() { SetUserCorporaDir("") })
+
+ if got := Verses("drb", "Genesis", 1); len(got) == 0 || got[0].Text != "USER TEXT" {
+ t.Fatalf("user drb.tsv did not override embed: %+v", got)
+ }
+ m, _ := Meta("drb")
+ if m.Name != "User DRB" {
+ t.Fatalf("user sidecar not used: %+v", m)
+ }
+}
+
+func TestUserCorpusNewCode(t *testing.T) {
+ dir := t.TempDir()
+ os.WriteFile(filepath.Join(dir, "fr-x.tsv"), []byte("Genesis\tGn\t1\t1\t1\tAu commencement\n"), 0o644)
+ os.WriteFile(filepath.Join(dir, "fr-x.ini"), []byte("lang = fr\nname = Test FR\npsalm_system = vulgate\n"), 0o644)
+ SetUserCorporaDir(dir)
+ t.Cleanup(func() { SetUserCorporaDir("") })
+
+ if l := CorporaForLang("fr"); len(l) != 1 || l[0].Code != "fr-x" {
+ t.Fatalf("new fr corpus not registered: %+v", l)
+ }
+}
+```
+
+- [ ] **Step 3: Run it, verify it fails** — `go test ./internal/bible/ -run TestUserCorpus` → FAIL (undefined symbols).
+
+- [ ] **Step 4: Implement** `internal/bible/registry.go`:
+
+```go
+package bible
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+)
+
+var (
+ regMu sync.Mutex
+ userDir string
+ regBuilt bool
+ regMeta map[string]CorpusMeta // code -> meta (user overrides embed)
+ regUserTSV map[string]string // code -> user .tsv path (override source)
+)
+
+// SetUserCorporaDir sets the drop-in dir and invalidates the registry cache.
+// Empty string disables user corpora (used by tests).
+func SetUserCorporaDir(dir string) {
+ regMu.Lock()
+ defer regMu.Unlock()
+ userDir = dir
+ regBuilt = false
+ corpora = map[string]*corpus{} // drop cached loads so overrides take effect
+}
+
+func buildRegistry() {
+ if regBuilt {
+ return
+ }
+ regMeta = map[string]CorpusMeta{}
+ regUserTSV = map[string]string{}
+ // embedded first
+ ents, _ := corporaFS.ReadDir("corpora")
+ for _, e := range ents {
+ if code, ok := strings.CutSuffix(e.Name(), ".ini"); ok {
+ if m, ok := embeddedCorpusMeta(code); ok {
+ regMeta[code] = m
+ }
+ }
+ }
+ // user dir overrides
+ if userDir != "" {
+ ents, _ := os.ReadDir(userDir)
+ for _, e := range ents {
+ name := e.Name()
+ if code, ok := strings.CutSuffix(name, ".tsv"); ok {
+ regUserTSV[code] = filepath.Join(userDir, name)
+ if _, have := regMeta[code]; !have {
+ regMeta[code] = CorpusMeta{Code: code, PsalmSystem: "vulgate"}
+ }
+ }
+ }
+ for _, e := range ents {
+ if code, ok := strings.CutSuffix(e.Name(), ".ini"); ok {
+ data, err := os.ReadFile(filepath.Join(userDir, e.Name()))
+ if err == nil {
+ regMeta[code] = parseCorpusMeta(code, data)
+ }
+ }
+ }
+ }
+ regBuilt = true
+}
+
+// Corpora returns all known corpora, sorted by code.
+func Corpora() []CorpusMeta {
+ regMu.Lock()
+ defer regMu.Unlock()
+ buildRegistry()
+ out := make([]CorpusMeta, 0, len(regMeta))
+ for _, m := range regMeta {
+ out = append(out, m)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
+ return out
+}
+
+// Meta returns a corpus's metadata.
+func Meta(code string) (CorpusMeta, bool) {
+ regMu.Lock()
+ defer regMu.Unlock()
+ buildRegistry()
+ m, ok := regMeta[code]
+ return m, ok
+}
+
+// CorporaForLang returns corpora whose lang == lang, sorted by code.
+func CorporaForLang(lang string) []CorpusMeta {
+ var out []CorpusMeta
+ for _, m := range Corpora() {
+ if m.Lang == lang {
+ out = append(out, m)
+ }
+ }
+ return out
+}
+
+// userTSVPath returns the override .tsv path for code, or "".
+func userTSVPath(code string) string {
+ regMu.Lock()
+ defer regMu.Unlock()
+ buildRegistry()
+ return regUserTSV[code]
+}
+```
+
+- [ ] **Step 5: Route `load()` through the override** in `internal/bible/bible.go` — change the read source:
+
+```go
+func load(version string) *corpus {
+ corporaMu.Lock()
+ defer corporaMu.Unlock()
+ if c, ok := corpora[version]; ok {
+ return c
+ }
+ var data []byte
+ if p := userTSVPath(version); p != "" {
+ data, _ = os.ReadFile(p)
+ }
+ if data == nil {
+ data, _ = corporaFS.ReadFile("corpora/" + version + ".tsv")
+ }
+ if data == nil {
+ corpora[version] = &corpus{books: map[string]map[int][]Verse{}}
+ return corpora[version]
+ }
+ // ... existing parse loop unchanged, using `data` ...
+}
+```
+
+(Add `"os"` to the imports if not present. `userTSVPath` takes `regMu`; `load` holds `corporaMu` — different locks, no deadlock. `SetUserCorporaDir` resets `corpora` under `regMu`; guard the reset by also taking `corporaMu` if the race detector flags it — see Step 7.)
+
+- [ ] **Step 6: Run tests** — `go test ./internal/bible/ -run TestUserCorpus` → PASS.
+
+- [ ] **Step 7: Race check** — `go test -race ./internal/bible/`. If `SetUserCorporaDir`'s `corpora = ...` reset races with `load`, take `corporaMu` around the reset inside `SetUserCorporaDir`. Re-run until clean.
+
+- [ ] **Step 8: Full suite + gofmt + commit** — `go test ./...`; `gofmt -l internal/`; `git commit -m "feat(bible): user corpus discovery + override registry"`.
+
+---
+
+### Task 3: Config fields + `ReadingCorpus` resolver
+
+**Files:**
+- Modify: `internal/config/config.go` (struct fields, setField, renderConfigINI, config header)
+- Test: `internal/config/config_test.go`
+
+**Interfaces:**
+- Consumes: `bible.Corpora`, `bible.Meta` (Task 2).
+- Produces: `Config.ReadingLang`, `Config.ReadingVersion` (strings); `func (c Config) ReadingCorpus() string`.
+
+- [ ] **Step 1: Write the failing test** in `internal/config/config_test.go`:
+
+```go
+func TestReadingCorpusResolution(t *testing.T) {
+ // drb is an embedded corpus with lang=en.
+ c := Config{ReadingVersion: "drb"}
+ if got := c.ReadingCorpus(); got != "drb" {
+ t.Fatalf("explicit reading_version: got %q", got)
+ }
+ c = Config{ReadingLang: "en"}
+ if got := c.ReadingCorpus(); got != "drb" {
+ t.Fatalf("reading_lang match: got %q", got)
+ }
+ c = Config{UILanguage: "en"}
+ if got := c.ReadingCorpus(); got != "drb" {
+ t.Fatalf("ui_language fallback: got %q", got)
+ }
+ c = Config{UILanguage: "pl"} // no pl corpus embedded
+ if got := c.ReadingCorpus(); got != "" {
+ t.Fatalf("no match should be empty: got %q", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run it, verify it fails** — `go test ./internal/config/ -run TestReadingCorpus` → FAIL.
+
+- [ ] **Step 3: Add the struct fields** to `Config` in `internal/config/config.go` (near `UILanguage`):
+
+```go
+ ReadingLang string `toml:"reading_lang"`
+ ReadingVersion string `toml:"reading_version"`
+```
+
+- [ ] **Step 4: Add setField cases** in the `switch key` block:
+
+```go
+ case "reading_lang":
+ cfg.ReadingLang = val
+ case "reading_version":
+ cfg.ReadingVersion = val
+```
+
+- [ ] **Step 5: Implement the resolver** (new method, `internal/config/config.go`):
+
+```go
+// ReadingCorpus resolves which bible corpus renders offline reading text:
+// an explicit reading_version, else the first corpus matching reading_lang,
+// else the first matching ui_language, else "" (caller uses the Latin vul).
+func (c Config) ReadingCorpus() string {
+ if c.ReadingVersion != "" {
+ if _, ok := bible.Meta(c.ReadingVersion); ok {
+ return c.ReadingVersion
+ }
+ }
+ for _, lang := range []string{c.ReadingLang, NormalizeUILanguage(c.UILanguage)} {
+ if lang == "" {
+ continue
+ }
+ if m := bible.CorporaForLang(lang); len(m) > 0 {
+ return m[0].Code
+ }
+ }
+ return ""
+}
+```
+
+(Add the `internal/bible` import to config. Confirm no import cycle: `bible` must not import `config`. Task 2's `CorporaDir()` lives in `config`, and `bible` gets the dir via `SetUserCorporaDir` called from `main`/CLI — so `bible` does NOT import `config`. Verify before committing.)
+
+- [ ] **Step 6: Render the fields** in `renderConfigINI` (after `ui_language`):
+
+```go
+ fmt.Fprintf(&b, "reading_lang = %s\n", cfg.ReadingLang)
+ fmt.Fprintf(&b, "reading_version = %s\n", cfg.ReadingVersion)
+```
+
+And add two lines to the config-header doc block explaining them (match the existing comment style: `reading_lang` = language for offline reading text, blank follows ui_language; `reading_version` = force a specific corpus code).
+
+- [ ] **Step 7: Run tests + gofmt** — `go test ./internal/config/ ./internal/bible/`; `gofmt -l internal/`. Confirm no import cycle (`go build ./...`).
+
+- [ ] **Step 8: Commit** — `git commit -m "feat(config): reading_lang/reading_version + ReadingCorpus resolver"`
+
+---
+
+### Task 4: `lectio --corpus-check` validator
+
+**Files:**
+- Create: `internal/bible/validate.go`
+- Modify: `internal/cli/cli.go` (flag wiring) and `internal/cli/corpus.go` (new command handler)
+- Test: `internal/bible/validate_test.go`; fixtures under `internal/bible/testdata/corpora/`
+
+**Interfaces:**
+- Consumes: `Corpora`, `Meta`, `Verses`, `load` (Task 2); the canonical book set.
+- Produces: `type CorpusReport struct { Errors, Warnings []string }`; `func CheckCorpus(code string) CorpusReport`; `func (r CorpusReport) OK() bool`.
+
+- [ ] **Step 1: Expose the canonical book set.** In `internal/bible/booktable.go` (or wherever `books.ini` is parsed), add `func CanonicalBooks() map[string]bool` returning the 73 English keys. Reuse the existing parse; do not re-embed.
+
+- [ ] **Step 2: Write fixtures** under `internal/bible/testdata/corpora/`:
+ - `good.tsv` (a few valid rows across 2 books) + `good.ini` (valid metadata).
+ - `badbook.tsv` (one row with `Genessis` typo).
+ - `gap.tsv` (Genesis 1 verses 1,2,4 — missing 3).
+ - `nosidecar.tsv` (valid rows, no `.ini`).
+ - `badsystem.ini` + `badsystem.tsv` (`psalm_system = klingon`).
+
+- [ ] **Step 3: Write the failing test** `internal/bible/validate_test.go`:
+
+```go
+func TestCheckCorpus(t *testing.T) {
+ SetUserCorporaDir("testdata/corpora")
+ t.Cleanup(func() { SetUserCorporaDir("") })
+
+ if r := CheckCorpus("good"); !r.OK() {
+ t.Fatalf("good corpus flagged: %v", r.Errors)
+ }
+ if r := CheckCorpus("badbook"); r.OK() {
+ t.Fatal("unknown book not caught")
+ }
+ if r := CheckCorpus("gap"); len(r.Warnings) == 0 {
+ t.Fatal("verse gap not warned")
+ }
+ if r := CheckCorpus("nosidecar"); r.OK() {
+ t.Fatal("missing sidecar not caught")
+ }
+ if r := CheckCorpus("badsystem"); r.OK() {
+ t.Fatal("bad psalm_system not caught")
+ }
+}
+```
+
+- [ ] **Step 4: Run it, verify it fails** — `go test ./internal/bible/ -run TestCheckCorpus` → FAIL.
+
+- [ ] **Step 5: Implement `CheckCorpus`** in `internal/bible/validate.go`:
+
+```go
+package bible
+
+import (
+ "fmt"
+ "sort"
+)
+
+type CorpusReport struct {
+ Code string
+ Errors []string
+ Warnings []string
+}
+
+func (r CorpusReport) OK() bool { return len(r.Errors) == 0 }
+
+var validPsalmSystems = map[string]bool{"vulgate": true, "hebrew": true, "drb": true}
+
+// CheckCorpus validates a corpus's text + sidecar and reports coverage vs vul.
+func CheckCorpus(code string) CorpusReport {
+ r := CorpusReport{Code: code}
+ // sidecar
+ m, ok := Meta(code)
+ if !ok || m.Lang == "" || m.Name == "" {
+ r.Errors = append(r.Errors, "missing or incomplete sidecar (need lang, name, psalm_system)")
+ }
+ if m.PsalmSystem != "" && !validPsalmSystems[m.PsalmSystem] {
+ r.Errors = append(r.Errors, fmt.Sprintf("invalid psalm_system %q", m.PsalmSystem))
+ }
+ // text present?
+ c := load(code)
+ if len(c.books) == 0 {
+ r.Errors = append(r.Errors, "no verses parsed (empty or malformed .tsv)")
+ return r
+ }
+ // canonical book names
+ canon := CanonicalBooks()
+ for book := range c.books {
+ if !canon[book] {
+ r.Errors = append(r.Errors, "unknown book name: "+book)
+ }
+ }
+ // verse integrity + coverage vs vul
+ ref := load("vul")
+ for book, chaps := range c.books {
+ for ch, verses := range chaps {
+ seen := map[int]bool{}
+ for _, v := range verses {
+ if seen[v.Verse] {
+ r.Errors = append(r.Errors, fmt.Sprintf("%s %d: duplicate verse %d", book, ch, v.Verse))
+ }
+ seen[v.Verse] = true
+ }
+ }
+ if refChaps, ok := ref.books[book]; ok {
+ for ch := range refChaps {
+ if _, have := chaps[ch]; !have {
+ r.Warnings = append(r.Warnings, fmt.Sprintf("%s: missing chapter %d (present in vul)", book, ch))
+ }
+ }
+ }
+ }
+ sort.Strings(r.Errors)
+ sort.Strings(r.Warnings)
+ return r
+}
+```
+
+(Gap detection: within each chapter, warn when the max verse number exceeds the count by more than a small threshold, reusing the run-shape logic from the session audit — keep it a warning, never an error.)
+
+- [ ] **Step 6: Run tests** — `go test ./internal/bible/ -run TestCheckCorpus` → PASS.
+
+- [ ] **Step 7: Wire the CLI flag.** In `internal/cli/cli.go`, add a `--corpus-check <code>` flag (and `--json`); on set, call a handler in new `internal/cli/corpus.go` that resolves the user dir via `config.CorporaDir()` + `bible.SetUserCorporaDir`, runs `bible.CheckCorpus`, prints errors/warnings (or JSON), and returns exit 1 if `!report.OK()`. Accept a path argument too: if the arg contains a `/` or ends `.tsv`, point `SetUserCorporaDir` at its dir and use its basename as the code.
+
+- [ ] **Step 8: CLI smoke test** — `go run ./cmd/lectio --corpus-check drb` prints a clean report, exit 0. Add a `internal/cli` test asserting exit code 1 for the `badbook` fixture.
+
+- [ ] **Step 9: Full suite + gofmt + commit** — `go test ./...`; `gofmt -l internal/`; `git commit -m "feat(cli): lectio --corpus-check validator"`.
+
+---
+
+### Task 5: Wire resolver into rendering + surface user corpora
+
+**Files:**
+- Modify: `internal/cli/liturgy.go` (`vernacularVersion`)
+- Modify: `internal/cli/cli.go` or main wiring (call `bible.SetUserCorporaDir` at startup)
+- Modify: `internal/cli` `--list` and comparison label lookup
+- Test: `internal/cli/liturgy_test.go`
+
+**Interfaces:**
+- Consumes: `Config.ReadingCorpus` (Task 3), `bible.Corpora`, `bible.Meta` (Task 2).
+
+- [ ] **Step 1: Set the user corpora dir at startup.** Wherever the CLI builds `cfg` (the same place it resolves `config.CalendarsDir()` for the calendar stack), call:
+
+```go
+if dir, err := config.CorporaDir(); err == nil {
+ bible.SetUserCorporaDir(dir)
+}
+```
+
+so both `--ref`, comparison, and `--liturgy` see user corpora.
+
+- [ ] **Step 2: Write the failing test** in `internal/cli/liturgy_test.go`:
+
+```go
+func TestVernacularVersionResolver(t *testing.T) {
+ if got := vernacularVersion(config.Config{ReadingVersion: "drb"}); got != "drb" {
+ t.Fatalf("explicit: %q", got)
+ }
+ if got := vernacularVersion(config.Config{UILanguage: "pl"}); got != "vul" {
+ t.Fatalf("pl should fall back to Latin: %q", got)
+ }
+ if got := vernacularVersion(config.Config{UILanguage: "en"}); got != "drb" {
+ t.Fatalf("en should be drb: %q", got)
+ }
+}
+```
+
+- [ ] **Step 3: Run it, verify it fails** (current `vernacularVersion` ignores `ReadingVersion`).
+
+- [ ] **Step 4: Replace `vernacularVersion`** in `internal/cli/liturgy.go`:
+
+```go
+// vernacularVersion is the corpus that renders reading text: the config's
+// resolved reading corpus, else the complete Latin Vulgate.
+func vernacularVersion(cfg config.Config) string {
+ if code := cfg.ReadingCorpus(); code != "" {
+ return code
+ }
+ return latinFallback // "vul"
+}
+```
+
+- [ ] **Step 5: Run tests** — `go test ./internal/cli/ -run TestVernacularVersion` → PASS. Manually confirm behaviour with a temp config: `LECTIO_CONFIG=… lectio 2025-03-10 -L` shows Latin for pl, Douay for en (unchanged from today).
+
+- [ ] **Step 6: Surface corpora in `--list`/comparison.** Where version labels are resolved (today via `i18n.Get(lang).Version[code]`), fall back to `bible.Meta(code).Name` when the i18n map has no entry, and include user corpora when enumerating available versions. Add a test asserting a user fixture corpus appears in the `--list` output.
+
+- [ ] **Step 7: Full suite + gofmt + commit** — `go test ./...`; `gofmt -l internal/`; `git commit -m "feat(cli): config-driven reading corpus + surface user corpora"`.
+
+---
+
+### Task 6: Scripts + Makefile
+
+**Files:**
+- Create: `scripts/corpus-validate.sh`
+- Modify: `Makefile`
+- Test: manual + `check-corpora` target
+
+**Interfaces:**
+- Consumes: `lectio --corpus-check` (Task 4).
+
+- [ ] **Step 1: Write** `scripts/corpus-validate.sh`:
+
+```sh
+#!/bin/sh
+# Validate a bible corpus by code or by .tsv path (see lectio --corpus-check).
+# Usage: scripts/corpus-validate.sh <code|path-to-tsv>
+set -e
+[ -n "$1" ] || { echo "usage: $0 <code|path.tsv>" >&2; exit 2; }
+exec go run ./cmd/lectio --corpus-check "$1"
+```
+
+`chmod +x scripts/corpus-validate.sh`.
+
+- [ ] **Step 2: Add Makefile targets:**
+
+```make
+# Validate every embedded corpus (CI gate).
+check-corpora:
+ @for f in internal/bible/corpora/*.tsv; do \
+ code=$$(basename $$f .tsv); \
+ echo "checking $$code"; \
+ go run ./cmd/lectio --corpus-check $$code || exit 1; \
+ done
+
+# Validate then embed a corpus pair: make add-corpus CORPUS=fr-crampon SRC=/path/to/dir
+add-corpus:
+ @test -n "$(CORPUS)" || { echo "set CORPUS=<code>" >&2; exit 2; }
+ @test -n "$(SRC)" || { echo "set SRC=<dir with $(CORPUS).tsv/.ini>" >&2; exit 2; }
+ go run ./cmd/lectio --corpus-check "$(SRC)/$(CORPUS).tsv"
+ cp "$(SRC)/$(CORPUS).tsv" "$(SRC)/$(CORPUS).ini" internal/bible/corpora/
+ $(MAKE) build
+```
+
+- [ ] **Step 3: Wire `check-corpora` into the test/check target** — add it as a prerequisite of the existing `test` (or `check`) target so CI runs it.
+
+- [ ] **Step 4: Verify** — `make check-corpora` passes for all four built-ins. Create a throwaway bad pair in `/tmp` and confirm `make add-corpus CORPUS=bad SRC=/tmp` aborts before copying (validation non-zero). Clean up.
+
+- [ ] **Step 5: Commit** — `git commit -m "build: corpus-validate script + add-corpus/check-corpora make targets"`.
+
+---
+
+## Wrap-up
+
+- [ ] Run the full suite once more: `go test ./... && gofmt -l internal/ scripts/ cmd/`.
+- [ ] Bump `config.Version` (0.33.0 → 0.34.0).
+- [ ] Update memory `lectio-selfcontained-calendar-epic.md`: national-bibles corpora mechanism shipped; how a corpus is added; that a corrected Wujek is now a drop-in.
+- [ ] Use **superpowers:finishing-a-development-branch** to merge/install.
+- [ ] Manual acceptance: drop a tiny `fr-x.tsv` + `fr-x.ini` into `~/.config/lectio/corpora/`, set `reading_version = fr-x` (temp config), confirm `lectio … -L` renders the French text and `--corpus-check fr-x` reports coverage.
+
+## Self-Review Notes
+
+- Spec coverage: format (T1), discovery/override (T2), config/selection (T3), validation (T4), rendering/surfacing (T5), scripts/Makefile (T6). `psalm_system` = metadata+validation only (T1, T4), application deferred per spec §5. All spec sections mapped.
+- Import-cycle watch (T3): `bible` must not import `config`; the user dir is injected via `SetUserCorporaDir` from the CLI, not read inside `bible`.
+- Concurrency watch (T2): two locks (`regMu`, `corporaMu`); `SetUserCorporaDir` resets the load cache — race-test in T2 Step 7.
+- Confirm-before-coding: `ini.Parse`'s exact return shape (T1 Step 5) and `CalendarsDir`'s helper (T2 Step 1) must be read from the current code, not assumed.