# 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 `.tsv` + `.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 `.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 `` 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 (.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 (.tsv + // .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 ` 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 set -e [ -n "$1" ] || { echo "usage: $0 " >&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=" >&2; exit 2; } @test -n "$(SRC)" || { echo "set SRC=" >&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.