Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- Adds bilingual bundled packs for Bible, Dao, Quran, and Heart Sutra.
- Infers English versus Chinese edition from the matched reference alias.
- Preserves per-language display references in compact pack rows.

## v0.1.0 - 2026-05-03

Initial release of Verse-Driven Development.
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,13 @@ internal/schema/ Verse struct + JSON Schema (the contract every pack obey
internal/resolver/ free-form reference parser ("John 3:16", "道德经 11", ...)
internal/packs/ embed.FS-backed pack data + registry
bible-kjv/
bible-cuv-s/
dao-de-jing/
dao-legge/
heart-sutra/
heart-sutra-en/
quran-pickthall/
quran-majian/
internal/mcp/ stdio MCP server (issue #4)
internal/cli/ CLI subcommands (issue #4)
internal/injector/ inject-once envelope helpers (issues #5/#6)
Expand Down
7 changes: 3 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ STATICCHECK_VERSION := v0.6.1

all: lint verify-packs test build

# Rebuild the bundled packs from upstream sources (KJV from Project
# Gutenberg, 道德经 from Project Gutenberg). Run after upstream regenerations
# or whenever the JSONL format changes. Requires Python 3.11+ and
# opencc-python-reimplemented for the dao pack.
# Rebuild bundled packs from upstream sources. Run after upstream
# regenerations or whenever the JSONL format changes. Requires Python 3.11+;
# opencc-python-reimplemented is required for the zh-Hans Dao/Sutra targets.
packs:
python3 scripts/build_packs.py

Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ stdio MCP, CLI commands, and agent hooks.
Current v0.1.0 status:

- Released binaries for macOS arm64, macOS x86_64, and Linux x86_64.
- Bundled packs: KJV Bible, 道德经, and 心经.
- Bundled packs: Bible (KJV + CUV-S), 道德经 (Chinese + Legge English),
心经 (Chinese + English), and Quran (Pickthall English + Ma Jian Chinese).
- Adapter support: Claude Code and Codex.
- Safety gate: one-turn injection lifecycle tests and coding-quality benchmark
passed for v0.1.0.
Expand Down Expand Up @@ -80,8 +81,12 @@ Claude Code slash commands:
```text
/bible John 3:13
/bible 约翰福音 3:16
/dao 11
/dao 第十一章
/sutra 心经
/sutra Heart Sutra
/quran 2:255
/quran 古兰经 2:255
```

Codex inline markers:
Expand All @@ -96,7 +101,11 @@ Direct CLI lookup:

```bash
scripture-mcp lookup "John 3:13" --format=json
scripture-mcp lookup "约翰福音 3:16" --format=json
scripture-mcp lookup "dao 11" --format=text
scripture-mcp lookup "道德经第十一章" --format=text
scripture-mcp lookup "Quran 2:255" --format=text
scripture-mcp lookup "古兰经 2:255" --format=text
scripture-mcp recap --terminal
scripture-mcp recap --learning --first-letter
```
Expand Down Expand Up @@ -229,10 +238,13 @@ Codex transcript.
| Pack | Source | State |
|---|---|---|
| KJV Bible | [Project Gutenberg eBook #10](https://www.gutenberg.org/ebooks/10) | Bundled, 31,102 verses |
| Chinese Union Version, Simplified | [open-bibles](https://github.com/seven1m/open-bibles) | Bundled, 31,100 verses |
| 道德经 | [Project Gutenberg eBook #7337](https://www.gutenberg.org/ebooks/7337) | Bundled, 81 chapters |
| Tao Te Ching, Legge English | [Internet Classics Archive](https://classics.mit.edu/Lao/taote.html) | Bundled, 81 chapters |
| 心经 | [CBETA XML P5 T0251](https://cbetaonline.dila.edu.tw/zh/T0251_001) | Bundled, 1 complete text |
| Quran | planned | Resolver only; no bundled text |
| 中文圣经 | planned | Needs licensing work |
| Heart Sutra, English | [Wikisource](https://en.wikisource.org/wiki/Translation:Shorter_Praj%C3%B1%C4%81p%C4%81ramit%C4%81_H%E1%B9%9Bdaya_S%C5%ABtra) | Bundled, 1 complete text |
| Quran, Pickthall English | [Tanzil](https://tanzil.net/trans/) | Bundled, 6,236 ayat; non-commercial translation terms |
| Quran, Ma Jian Chinese | [Tanzil](https://tanzil.net/trans/) | Bundled, 6,236 ayat; non-commercial translation terms |

Each bundled entry stores a SHA-256 checksum over the text bytes. CI verifies
that pack text and checksum metadata stay in sync.
Expand Down Expand Up @@ -278,8 +290,8 @@ See [`CHANGELOG.md`](./CHANGELOG.md) for release notes.

Useful next work:

- Quran pack with clear source provenance and attribution.
- Chinese Bible pack research.
- Broader translation-source licensing review.
- Optional explicit `--lang` selection for recap/random workflows.
- Homebrew formula.
- Release workflow automation.
- Additional lifecycle probes for other MCP-compatible agents.
Expand Down
40 changes: 40 additions & 0 deletions docs/sources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Bundled Text Sources

This file records the upstream sources, terms, and attribution metadata for
the scripture packs bundled into `scripture-mcp`. It intentionally contains no
scripture passage bodies.

Each pack also stores the same source metadata in
`internal/packs/<pack>/metadata.json`, and every bundled row stores a
SHA-256 checksum over its text bytes.

## Source Matrix

| Pack | Tradition | Work | Language | Source | Terms | Attribution |
|---|---|---|---|---|---|---|
| `bible-kjv` | Bible | KJV | English | [Project Gutenberg eBook #10](https://www.gutenberg.org/cache/epub/10/pg10.txt) | Public domain (United States) | King James Version of the Bible, Project Gutenberg eBook #10 |
| `bible-cuv-s` | Bible | CUV-S | Simplified Chinese | [open-bibles USFX](https://raw.githubusercontent.com/seven1m/open-bibles/master/chi-cuv-simp.usfx.xml) | Public domain | Chinese Union Version (Simplified), open-bibles USFX |
| `dao-de-jing` | Dao | daodejing | Simplified Chinese | [Project Gutenberg eBook #7337](https://www.gutenberg.org/cache/epub/7337/pg7337.txt) | Public domain | `道德經`, Project Gutenberg eBook #7337, produced by Ching-yi Chen |
| `dao-legge` | Dao | legge | English | [Internet Classics Archive](https://classics.mit.edu/Lao/taote.mb.txt) | Public domain source text | Tao Te Ching, translated by James Legge (1891), Internet Classics Archive text |
| `heart-sutra` | Sutra | heart-sutra | Simplified Chinese | [CBETA XML P5 T0251](https://cbetaonline.dila.edu.tw/zh/T0251_001) | Ancient source text; CBETA digital edition terms apply | `般若波罗蜜多心经`, translated by Xuanzang, CBETA XML P5 T0251 |
| `heart-sutra-en` | Sutra | heart-sutra-en | English | [Wikisource raw page](https://en.wikisource.org/w/index.php?title=Translation:Shorter_Praj%C3%B1%C4%81p%C4%81ramit%C4%81_H%E1%B9%9Bdaya_S%C5%ABtra&action=raw) | Creative Commons Attribution-ShareAlike | Shorter Prajnaparamita Hrdaya Sutra, Wikisource translation |
| `quran-pickthall` | Quran | pickthall | English | [Tanzil `en.pickthall`](https://tanzil.net/trans/en.pickthall) | Tanzil translation terms: non-commercial use with attribution | Quran English translation by Mohammed Marmaduke William Pickthall, Tanzil |
| `quran-majian` | Quran | majian | Simplified Chinese | [Tanzil `zh.jian`](https://tanzil.net/trans/zh.jian) | Tanzil translation terms: non-commercial use with attribution | Quran Chinese translation by Ma Jian, Tanzil |

## Transform Notes

| Pack | Transform |
|---|---|
| `dao-de-jing` | Traditional Chinese source normalized to Simplified Chinese with OpenCC `t2s`. |
| `heart-sutra` | CBETA XML P5 body extraction, then OpenCC `t2s`. |
| `heart-sutra-en` | Wikisource raw wiki markup cleaned to the translation body. |
| `bible-cuv-s` | USFX verse markers parsed into compact JSONL rows. |
| `quran-pickthall` / `quran-majian` | Tanzil pipe-delimited translation rows parsed into compact JSONL rows. |

## Cautions

- Quran translation packs are not public-domain packs; retain Tanzil
attribution and non-commercial translation terms in releases.
- CBETA-derived text should retain the CBETA attribution and terms note.
- Do not paste passage bodies into docs, logs, PR descriptions, or chat output;
cite pack IDs, references, checksums, and source metadata instead.
37 changes: 20 additions & 17 deletions internal/cli/lookup.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,28 +227,18 @@ func resolveTrailing(tradition, rest string) (schema.Verse, error) {
return schema.Verse{}, lastErr
}
cur := strings.TrimSpace(rest)
candidate := func() string {
switch tradition {
case "bible":
return cur
default: // dao, quran
if cur == "" {
return tradition
}
return tradition + " " + cur
}
}
var lastErr error
for {
c := candidate()
if c == "" {
if cur == "" {
break
}
v, err := resolveAndLookup(c)
if err == nil {
return v, nil
for _, c := range markerCandidates(tradition, cur) {
v, err := resolveAndLookup(c)
if err == nil {
return v, nil
}
lastErr = err
}
lastErr = err
idx := strings.LastIndexAny(cur, " \t")
if idx < 0 {
break
Expand All @@ -261,6 +251,19 @@ func resolveTrailing(tradition, rest string) (schema.Verse, error) {
return schema.Verse{}, lastErr
}

func markerCandidates(tradition, cur string) []string {
switch tradition {
case "bible":
return []string{cur}
case "dao", "quran":
// First try the raw ref so slash markers can carry a language-specific
// alias such as "/dao 道德经第十一章" or "/quran 古兰经 2:255".
return []string{cur, tradition + " " + cur}
default:
return []string{cur}
}
}

// scanMarker extracts (tradition, ref) from the leftmost marker in prompt.
// Returns (_, _, false) when no marker is present.
func scanMarker(prompt string) (tradition, ref string, ok bool) {
Expand Down
64 changes: 57 additions & 7 deletions internal/cli/lookup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,37 @@ func TestRunLookupSutraBundled(t *testing.T) {
}
}

func TestRunLookupBilingualPacks(t *testing.T) {
cases := []struct {
name string
ref string
id string
lang string
}{
{"bible_zh", "约翰福音 3:16", "bible.cuv-s.john.3.16", "zh-Hans"},
{"dao_en", "dao 11", "dao.legge.11.1", "en"},
{"dao_zh", "道德经 11", "dao.daodejing.11.1", "zh-Hans"},
{"sutra_en", "Heart Sutra", "sutra.heart-sutra-en.1", "en"},
{"quran_en", "Quran 2:255", "quran.pickthall.2.255", "en"},
{"quran_zh", "古兰经 2:255", "quran.majian.2.255", "zh-Hans"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var out, errBuf bytes.Buffer
if code := runLookup([]string{tc.ref}, Streams{Out: &out, Err: &errBuf}); code != 0 {
t.Fatalf("exit %d, stderr=%q", code, errBuf.String())
}
var v schema.Verse
if err := json.Unmarshal(out.Bytes(), &v); err != nil {
t.Fatalf("output is not JSON Verse: %v", err)
}
if v.ID != tc.id || v.Lang != tc.lang {
t.Errorf("lookup %q got id=%q lang=%q; want id=%q lang=%q", tc.ref, v.ID, v.Lang, tc.id, tc.lang)
}
})
}
}

func TestLookupFromPromptSlashMarker(t *testing.T) {
in := strings.NewReader("/bible John 3:16 Refactor the cron-string scheduler.")
var out, errBuf bytes.Buffer
Expand Down Expand Up @@ -141,8 +172,27 @@ func TestLookupFromPromptInlineMarker(t *testing.T) {
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("output not JSON: %v\n%s", err, out.String())
}
if !strings.Contains(resp.HookSpecificOutput.AdditionalContext, "Tao Te Ching") {
t.Errorf("dao envelope missing English display ref")
}
}

func TestLookupFromPromptInlineMarkerChineseDao(t *testing.T) {
in := strings.NewReader("Please [[dao:道德经第十一章]] keep going on the helper.")
var out bytes.Buffer
if code := runLookupFromPrompt(nil, Streams{In: in, Out: &out, Err: &bytes.Buffer{}}); code != 0 {
t.Fatalf("exit %d", code)
}
var resp struct {
HookSpecificOutput struct {
AdditionalContext string `json:"additionalContext"`
} `json:"hookSpecificOutput"`
}
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("output not JSON: %v\n%s", err, out.String())
}
if !strings.Contains(resp.HookSpecificOutput.AdditionalContext, "道德经") {
t.Errorf("dao envelope missing display ref:\n%s", resp.HookSpecificOutput.AdditionalContext)
t.Errorf("dao envelope missing Chinese display ref")
}
}

Expand All @@ -165,8 +215,8 @@ func TestLookupFromPromptDollarDaoAlias(t *testing.T) {
t.Errorf("hookSpecificOutput hookEventName = %q, want UserPromptSubmit",
resp.HookSpecificOutput.HookEventName)
}
if !strings.Contains(resp.HookSpecificOutput.AdditionalContext, "道德经") {
t.Errorf("dao envelope missing display ref:\n%s", resp.HookSpecificOutput.AdditionalContext)
if !strings.Contains(resp.HookSpecificOutput.AdditionalContext, "Tao Te Ching") {
t.Errorf("dao envelope missing English display ref")
}
}

Expand All @@ -184,8 +234,8 @@ func TestLookupFromPromptDollarDaoDotAlias(t *testing.T) {
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("output not JSON: %v\n%s", err, out.String())
}
if !strings.Contains(resp.HookSpecificOutput.AdditionalContext, "道德经") {
t.Errorf("dao envelope missing display ref:\n%s", resp.HookSpecificOutput.AdditionalContext)
if !strings.Contains(resp.HookSpecificOutput.AdditionalContext, "Tao Te Ching") {
t.Errorf("dao envelope missing English display ref")
}
}

Expand Down Expand Up @@ -338,8 +388,8 @@ func TestLookupFromPromptHookEventFlag(t *testing.T) {
t.Errorf("hookSpecificOutput hookEventName = %q, want UserPromptSubmit",
resp.HookSpecificOutput.HookEventName)
}
if !strings.Contains(resp.HookSpecificOutput.AdditionalContext, "道德经") {
t.Errorf("dao envelope missing display ref:\n%s", resp.HookSpecificOutput.AdditionalContext)
if !strings.Contains(resp.HookSpecificOutput.AdditionalContext, "Tao Te Ching") {
t.Errorf("dao envelope missing English display ref")
}
var raw map[string]any
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/recap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ func TestRecapBibleHasAttribution(t *testing.T) {
if !strings.Contains(s, "📖") {
t.Errorf("recap output missing scripture marker: %s", s)
}
if !strings.Contains(s, "King James Version") {
t.Errorf("bible recap missing KJV attribution: %s", s)
if !strings.Contains(s, "King James Version") && !strings.Contains(s, "Chinese Union Version") {
t.Errorf("bible recap missing known Bible attribution")
}
}

Expand Down
12 changes: 10 additions & 2 deletions internal/injector/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,21 @@ func Envelope(v schema.Verse) string {
}

// DisplayRef formats a verse's canonical reference for human display.
// Prefers DisplayRef["en"] when set; falls back to a tradition-specific
// rendering otherwise.
// Prefers the verse language's DisplayRef when set, then English, then
// Simplified Chinese, and finally a tradition-specific rendering.
func DisplayRef(v schema.Verse) string {
if v.DisplayRef != nil {
if v.Lang != "" {
if s, ok := v.DisplayRef[v.Lang]; ok && s != "" {
return s
}
}
if s, ok := v.DisplayRef["en"]; ok && s != "" {
return s
}
if s, ok := v.DisplayRef["zh-Hans"]; ok && s != "" {
return s
}
}
switch v.Tradition {
case "bible":
Expand Down
11 changes: 7 additions & 4 deletions internal/lifecycle/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,13 @@ func TestSlashMarkerTraditionsLifecycle(t *testing.T) {
ref string
}{
{"/bible John 3:16", "John 3:16"},
{"/dao 11", "道德经 11"},
// sutra and quran ship api_only in v0.1, so the hook soft-fails
// to no-envelope; the lifecycle invariant is still preserved
// (no envelope = no leak possible) but there's nothing to leak.
{"/bible 约翰福音 3:16", "约翰福音 3:16"},
{"/dao 11", "dao 11"},
{"/dao 道德经第十一章", "道德经 11"},
{"/sutra Heart Sutra", "Heart Sutra"},
{"/sutra 心经", "心经"},
{"/quran 2:255", "Quran 2:255"},
{"/quran 古兰经 2:255", "古兰经 2:255"},
}
for _, tc := range cases {
t.Run(tc.marker, func(t *testing.T) {
Expand Down
4 changes: 0 additions & 4 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,6 @@ func (s *Server) lookupByRef(ref string) (schema.Verse, error) {
}
v, ok := s.registry.Lookup(id)
if !ok {
if r.Tradition == resolver.TraditionSutra || r.Tradition == resolver.TraditionQuran {
return schema.Verse{}, fmt.Errorf("%w: %s", packs.ErrNotBundled, r.Tradition)
}
return schema.Verse{}, fmt.Errorf("verse not found: %s", id)
}
return v, nil
Expand Down Expand Up @@ -444,4 +441,3 @@ func textContent(s string) map[string]any {
"content": []map[string]any{{"type": "text", "text": s}},
}
}

Loading
Loading