bookwright-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. bookwright/__init__.py +3 -0
  2. bookwright/__main__.py +6 -0
  3. bookwright/cli.py +19 -0
  4. bookwright/commands/__init__.py +0 -0
  5. bookwright/commands/_envelope.py +36 -0
  6. bookwright/commands/check.py +75 -0
  7. bookwright/commands/graph/__init__.py +23 -0
  8. bookwright/commands/graph/build.py +157 -0
  9. bookwright/commands/graph/envelope.py +26 -0
  10. bookwright/commands/graph/query.py +98 -0
  11. bookwright/commands/init/__init__.py +5 -0
  12. bookwright/commands/init/conflict.py +107 -0
  13. bookwright/commands/init/envelope.py +322 -0
  14. bookwright/commands/init/git.py +96 -0
  15. bookwright/commands/init/main.py +263 -0
  16. bookwright/commands/init/resolve.py +193 -0
  17. bookwright/commands/init/scaffold.py +242 -0
  18. bookwright/commands/init/validate.py +172 -0
  19. bookwright/commands/integration/__init__.py +22 -0
  20. bookwright/commands/integration/use.py +120 -0
  21. bookwright/commands/validate.py +160 -0
  22. bookwright/commands/version.py +35 -0
  23. bookwright/core/__init__.py +35 -0
  24. bookwright/core/_blocks.py +239 -0
  25. bookwright/core/_build.py +154 -0
  26. bookwright/core/_research_block.py +56 -0
  27. bookwright/core/_translate.py +90 -0
  28. bookwright/core/errors.py +127 -0
  29. bookwright/core/iso639_1.py +200 -0
  30. bookwright/core/manifest.py +343 -0
  31. bookwright/errors.py +47 -0
  32. bookwright/golem/__init__.py +71 -0
  33. bookwright/golem/base.py +200 -0
  34. bookwright/golem/errors.py +29 -0
  35. bookwright/golem/modules/__init__.py +1 -0
  36. bookwright/golem/modules/character.py +109 -0
  37. bookwright/golem/modules/event.py +91 -0
  38. bookwright/golem/modules/feature.py +161 -0
  39. bookwright/golem/modules/inference.py +41 -0
  40. bookwright/golem/modules/narrative.py +55 -0
  41. bookwright/golem/modules/provenance.py +197 -0
  42. bookwright/golem/modules/relationship.py +38 -0
  43. bookwright/golem/modules/setting.py +30 -0
  44. bookwright/golem/namespaces.py +332 -0
  45. bookwright/golem/serialize.py +25 -0
  46. bookwright/golem/slug.py +22 -0
  47. bookwright/indexers/__init__.py +47 -0
  48. bookwright/indexers/base.py +55 -0
  49. bookwright/indexers/errors.py +80 -0
  50. bookwright/indexers/rdflib_indexer.py +89 -0
  51. bookwright/integrations/__init__.py +155 -0
  52. bookwright/integrations/base.py +117 -0
  53. bookwright/integrations/claude/__init__.py +29 -0
  54. bookwright/integrations/constants.py +38 -0
  55. bookwright/integrations/descriptions.py +48 -0
  56. bookwright/integrations/errors.py +170 -0
  57. bookwright/integrations/generic/__init__.py +56 -0
  58. bookwright/integrations/lint.py +160 -0
  59. bookwright/integrations/materialize.py +202 -0
  60. bookwright/integrations/options.py +203 -0
  61. bookwright/io/__init__.py +1 -0
  62. bookwright/io/bible.py +500 -0
  63. bookwright/io/errors.py +98 -0
  64. bookwright/io/frontmatter.py +61 -0
  65. bookwright/io/fs.py +226 -0
  66. bookwright/io/manuscript.py +15 -0
  67. bookwright/io/project.py +21 -0
  68. bookwright/io/report.py +107 -0
  69. bookwright/io/research.py +427 -0
  70. bookwright/resources/__init__.py +1 -0
  71. bookwright/resources/commands/bookwright-analyze.md +66 -0
  72. bookwright/resources/commands/bookwright-bible.md +96 -0
  73. bookwright/resources/commands/bookwright-checklist.md +67 -0
  74. bookwright/resources/commands/bookwright-clarify.md +65 -0
  75. bookwright/resources/commands/bookwright-constitution.md +79 -0
  76. bookwright/resources/commands/bookwright-continuity.md +70 -0
  77. bookwright/resources/commands/bookwright-draft.md +74 -0
  78. bookwright/resources/commands/bookwright-outline.md +71 -0
  79. bookwright/resources/commands/bookwright-research.md +107 -0
  80. bookwright/resources/commands/bookwright-scenes.md +66 -0
  81. bookwright/resources/commands/bookwright-synopsis.md +67 -0
  82. bookwright/resources/commands/bookwright-verify.md +136 -0
  83. bookwright/resources/commands/references/golem-character.md +65 -0
  84. bookwright/resources/commands/references/golem-events-timeline.md +56 -0
  85. bookwright/resources/commands/references/golem-relationships.md +53 -0
  86. bookwright/resources/commands/references/greimas-actants.md +57 -0
  87. bookwright/resources/commands/references/pending-protocol.md +72 -0
  88. bookwright/resources/commands/references/propp-functions.md +54 -0
  89. bookwright/resources/commands/references/research-format.md +136 -0
  90. bookwright/resources/project/.bookwright/cache/.gitkeep +0 -0
  91. bookwright/resources/project/.bookwright/schema/.gitkeep +0 -0
  92. bookwright/resources/project/.bookwright/templates/.gitkeep +0 -0
  93. bookwright/resources/project/.gitignore +23 -0
  94. bookwright/resources/project/README.md.j2 +40 -0
  95. bookwright/resources/project/__init__.py +6 -0
  96. bookwright/resources/project/bible/characters/.gitkeep +0 -0
  97. bookwright/resources/project/bible/constitution.md.j2 +74 -0
  98. bookwright/resources/project/bible/glossary.md +36 -0
  99. bookwright/resources/project/bible/locations/.gitkeep +0 -0
  100. bookwright/resources/project/bible/pov-structure.md +43 -0
  101. bookwright/resources/project/bible/relationships.md +36 -0
  102. bookwright/resources/project/bible/research/_index.md +28 -0
  103. bookwright/resources/project/bible/research/sources.md +23 -0
  104. bookwright/resources/project/bible/settings/.gitkeep +0 -0
  105. bookwright/resources/project/bible/subplots.md +35 -0
  106. bookwright/resources/project/bible/themes.md +36 -0
  107. bookwright/resources/project/bible/timeline.md +38 -0
  108. bookwright/resources/project/manuscript/.gitkeep +0 -0
  109. bookwright/resources/project/outline/arcs.md +34 -0
  110. bookwright/resources/project/outline/scenes.md +31 -0
  111. bookwright/resources/project/outline/structure.md +35 -0
  112. bookwright/resources/project/outline/synopsis.md +25 -0
  113. bookwright/resources/schemas/__init__.py +19 -0
  114. bookwright/resources/schemas/golem-1.1/VERSION +1 -0
  115. bookwright/resources/schemas/golem-1.1/golem.ttl +1947 -0
  116. bookwright/resources/schemas/golem-1.1/version.json +8 -0
  117. bookwright/resources/templates/__init__.py +1 -0
  118. bookwright/resources/templates/bible/character.md.tmpl +63 -0
  119. bookwright/resources/templates/bible/location.md.tmpl +37 -0
  120. bookwright/resources/templates/bible/research/_index.md.tmpl +25 -0
  121. bookwright/resources/templates/bible/research/sources.md.tmpl +21 -0
  122. bookwright/resources/templates/bible/research/tema.md.tmpl +37 -0
  123. bookwright/resources/templates/bible/setting.md.tmpl +38 -0
  124. bookwright/resources/templates/manifest.template.toml +79 -0
  125. bookwright/resources/templates/manuscript/chapter.md.tmpl +36 -0
  126. bookwright/resources/templates/scenes/scene.md.tmpl +37 -0
  127. bookwright/resources/vocabularies/__init__.py +6 -0
  128. bookwright/resources/vocabularies/greimas.ttl +4 -0
  129. bookwright/resources/vocabularies/propp.ttl +4 -0
  130. bookwright/resources/vocabularies/sources.ttl +82 -0
  131. bookwright/validation/__init__.py +33 -0
  132. bookwright/validation/anchor_queries.py +223 -0
  133. bookwright/validation/base.py +233 -0
  134. bookwright/validation/queries.py +197 -0
  135. bookwright/validation/registry.py +185 -0
  136. bookwright/validation/report.py +106 -0
  137. bookwright/validation/runner.py +65 -0
  138. bookwright/validation/validators/__init__.py +9 -0
  139. bookwright/validation/validators/character_presence.py +202 -0
  140. bookwright/validation/validators/factual_anchor.py +291 -0
  141. bookwright/validation/validators/focalization.py +152 -0
  142. bookwright/validation/validators/setting_continuity.py +100 -0
  143. bookwright/validation/validators/temporal.py +277 -0
  144. bookwright_cli-0.2.0.dist-info/METADATA +218 -0
  145. bookwright_cli-0.2.0.dist-info/RECORD +149 -0
  146. bookwright_cli-0.2.0.dist-info/WHEEL +4 -0
  147. bookwright_cli-0.2.0.dist-info/entry_points.txt +2 -0
  148. bookwright_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  149. bookwright_cli-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,427 @@
1
+ """Discover ``bible/research/`` files and map their front-matter to provenance entities.
2
+
3
+ The research analogue of :mod:`bookwright.io.bible` (design § 20.5/§ 20.7). It reads
4
+ ``sources.md`` (the Source registry), every ``<topic>.md`` (findings + anchors) and
5
+ ``_index.md`` (global open questions), and turns their YAML front-matter into the
6
+ frozen :class:`~bookwright.golem.modules.provenance.Source` /
7
+ :class:`~bookwright.golem.modules.provenance.Finding` /
8
+ :class:`~bookwright.golem.modules.provenance.Anchor` entities ``graph build`` then
9
+ serializes.
10
+
11
+ Fault model (deliberately **stricter** than the bible mapper — research D7): a
12
+ vocabulary violation, a missing required Source facet, a non-open finding without a
13
+ ``claim``/``sources``, an ``anchors[].promotes`` naming an unknown finding, a
14
+ translation-rule violation, or malformed YAML raises
15
+ :class:`~bookwright.io.errors.ResearchError` and aborts the build with no graph. An
16
+ unresolved ``bears_on``/``constrains`` *narrative* target is the one **soft** miss
17
+ (D12): the link triple is omitted and a :class:`ResearchWarning` is recorded, the
18
+ build still succeeding — existence/kind checking is the iteration-15 validator's job.
19
+ An **absent or empty** ``bible/research/`` yields zero entities and never raises
20
+ (FR-015, SC-005).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Mapping
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ import yaml
31
+ from pydantic import ValidationError
32
+ from rdflib.term import URIRef
33
+
34
+ from bookwright.golem import Anchor, Finding, Source
35
+ from bookwright.golem.base import GolemEntity
36
+ from bookwright.golem.errors import EmptySlugError
37
+ from bookwright.golem.namespaces import RELIABILITY_IRI, SOURCE_TYPE_IRI
38
+ from bookwright.golem.slug import make_slug
39
+
40
+ from .errors import ResearchError
41
+ from .frontmatter import parse_frontmatter
42
+
43
+ SOURCES_FILE = "sources.md"
44
+ INDEX_FILE = "_index.md"
45
+
46
+ # Every facet a Source declares (research-format.md). ``translation`` is governed by
47
+ # the language rule (§ D6), not listed as plainly required here.
48
+ _SOURCE_FACETS = (
49
+ "name",
50
+ "reference",
51
+ "author",
52
+ "original_language",
53
+ "type",
54
+ "reliability",
55
+ "reliability_justification",
56
+ "access_date",
57
+ "original_quote",
58
+ )
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ResearchWarning:
63
+ """A soft, unresolved ``bears_on``/``constrains`` narrative target (research D12).
64
+
65
+ The link triple was omitted; the build still succeeds. ``field`` is
66
+ ``"bears_on"`` or ``"constrains"``; ``name`` is the target that did not resolve
67
+ in the bible ``entity_index``.
68
+ """
69
+
70
+ relpath: str
71
+ field: str
72
+ name: str
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class ResearchResult:
77
+ """The outcome of mapping a project's ``bible/research/`` to provenance entities."""
78
+
79
+ sources: tuple[Source, ...] = ()
80
+ findings: tuple[Finding, ...] = ()
81
+ anchors: tuple[Anchor, ...] = ()
82
+ files_processed: int = 0
83
+ warnings: tuple[ResearchWarning, ...] = ()
84
+
85
+ @property
86
+ def entities(self) -> tuple[GolemEntity, ...]:
87
+ return (*self.sources, *self.findings, *self.anchors)
88
+
89
+
90
+ @dataclass
91
+ class _Accumulator:
92
+ """Mutable state threaded through the mapping passes."""
93
+
94
+ project_root: Path
95
+ uri_base: str
96
+ book_language: str
97
+ bible_index: Mapping[str, URIRef]
98
+ timeline_uri: URIRef
99
+ sources: list[Source] = field(default_factory=list)
100
+ findings: list[Finding] = field(default_factory=list)
101
+ anchors: list[Anchor] = field(default_factory=list)
102
+ warnings: list[ResearchWarning] = field(default_factory=list)
103
+ files_processed: int = 0
104
+ source_index: dict[str, URIRef] = field(default_factory=dict)
105
+
106
+ def result(self) -> ResearchResult:
107
+ return ResearchResult(
108
+ sources=tuple(self.sources),
109
+ findings=tuple(self.findings),
110
+ anchors=tuple(self.anchors),
111
+ files_processed=self.files_processed,
112
+ warnings=tuple(self.warnings),
113
+ )
114
+
115
+
116
+ def map_research( # noqa: PLR0913 — the six parameters are the fixed contract surface (research-io.md)
117
+ project_root: Path,
118
+ research_dir: Path,
119
+ uri_base: str,
120
+ book_language: str,
121
+ bible_index: Mapping[str, URIRef],
122
+ timeline_uri: URIRef,
123
+ ) -> ResearchResult:
124
+ """Map ``research_dir`` to provenance entities (see module docstring).
125
+
126
+ Files are processed in a deterministic order: ``sources.md`` first (so finding
127
+ source references resolve in one pass), then the topic files in sorted order,
128
+ then ``_index.md`` (global open questions).
129
+ """
130
+ acc = _Accumulator(
131
+ project_root=project_root,
132
+ uri_base=uri_base,
133
+ book_language=book_language,
134
+ bible_index=bible_index,
135
+ timeline_uri=timeline_uri,
136
+ )
137
+ if not research_dir.is_dir():
138
+ return acc.result()
139
+
140
+ sources_path = research_dir / SOURCES_FILE
141
+ if sources_path.is_file():
142
+ _map_sources(acc, sources_path)
143
+
144
+ for path in sorted(research_dir.glob("*.md")):
145
+ if path.name in {SOURCES_FILE, INDEX_FILE}:
146
+ continue
147
+ _map_topic(acc, path)
148
+
149
+ index_path = research_dir / INDEX_FILE
150
+ if index_path.is_file():
151
+ _map_index(acc, index_path)
152
+
153
+ return acc.result()
154
+
155
+
156
+ # --- sources.md -------------------------------------------------------------
157
+
158
+
159
+ def _map_sources(acc: _Accumulator, path: Path) -> None:
160
+ relpath = _relpath(acc, path)
161
+ acc.files_processed += 1
162
+ metadata = _load(acc, path, relpath)
163
+ raw_sources = metadata.get("sources", [])
164
+ if not isinstance(raw_sources, list):
165
+ raise ResearchError(relpath, f"`sources` must be a list in {relpath}")
166
+ for raw in raw_sources:
167
+ if not isinstance(raw, dict):
168
+ raise ResearchError(relpath, f"each `sources` item must be a mapping in {relpath}")
169
+ source = _build_source(acc, raw, relpath)
170
+ slug = make_slug(source.name)
171
+ if slug in acc.source_index:
172
+ raise ResearchError(
173
+ relpath,
174
+ f"duplicate source name {source.name!r} (slug {slug!r}) in {relpath}",
175
+ source.name,
176
+ )
177
+ acc.source_index[slug] = source.uri
178
+ acc.sources.append(source)
179
+
180
+
181
+ def _build_source(acc: _Accumulator, raw: dict[str, Any], relpath: str) -> Source:
182
+ """Validate one source mapping and build the frozen :class:`Source` (D4/D6)."""
183
+ for facet in _SOURCE_FACETS:
184
+ if facet not in raw:
185
+ raise ResearchError(
186
+ relpath, f"source is missing required `{facet}` in {relpath}", facet
187
+ )
188
+ _reject_unknown_vocab(raw, relpath)
189
+ try:
190
+ source = Source(uri_base=acc.uri_base, **raw)
191
+ except ValidationError as exc:
192
+ raise ResearchError(relpath, f"invalid source in {relpath}: {_first_error(exc)}") from exc
193
+ except EmptySlugError as exc:
194
+ raise ResearchError(
195
+ relpath, f"source `name` is empty or unsluggable in {relpath}", exc.name
196
+ ) from exc
197
+ return _apply_translation_rule(acc, source, relpath)
198
+
199
+
200
+ def _reject_unknown_vocab(raw: dict[str, Any], relpath: str) -> None:
201
+ """Name the offending value for an out-of-vocabulary ``type``/``reliability``."""
202
+ type_value = raw.get("type")
203
+ if type_value not in SOURCE_TYPE_IRI:
204
+ raise ResearchError(
205
+ relpath, f"unknown source type {type_value!r} in {relpath}", str(type_value)
206
+ )
207
+ reliability = raw.get("reliability")
208
+ if reliability not in RELIABILITY_IRI:
209
+ raise ResearchError(
210
+ relpath, f"unknown reliability {reliability!r} in {relpath}", str(reliability)
211
+ )
212
+
213
+
214
+ def _apply_translation_rule(acc: _Accumulator, source: Source, relpath: str) -> Source:
215
+ """Enforce the language-driven translation rule (research D6, SC-004).
216
+
217
+ When the source language differs from the book's, ``translation`` is required;
218
+ when they match, any supplied translation is dropped (never emitted).
219
+ """
220
+ if source.original_language != acc.book_language:
221
+ if source.translation is None or not source.translation.strip():
222
+ raise ResearchError(
223
+ relpath,
224
+ f"source {source.name!r} needs a `translation` (language "
225
+ f"{source.original_language!r} ≠ book {acc.book_language!r}) in {relpath}",
226
+ source.name,
227
+ )
228
+ return source
229
+ if source.translation is not None:
230
+ return source.model_copy(update={"translation": None})
231
+ return source
232
+
233
+
234
+ # --- <topic>.md & _index.md -------------------------------------------------
235
+
236
+
237
+ def _map_topic(acc: _Accumulator, path: Path) -> None:
238
+ relpath = _relpath(acc, path)
239
+ acc.files_processed += 1
240
+ metadata = _load(acc, path, relpath)
241
+ finding_ids = _map_findings(acc, metadata.get("findings"), relpath, open_only=False)
242
+ _map_anchors(acc, metadata.get("anchors"), relpath, finding_ids)
243
+
244
+
245
+ def _map_index(acc: _Accumulator, path: Path) -> None:
246
+ relpath = _relpath(acc, path)
247
+ acc.files_processed += 1
248
+ metadata = _load(acc, path, relpath)
249
+ _map_findings(acc, metadata.get("open_questions"), relpath, open_only=True)
250
+
251
+
252
+ def _map_findings(
253
+ acc: _Accumulator, raw_findings: Any, relpath: str, *, open_only: bool
254
+ ) -> dict[str, URIRef]:
255
+ """Build the findings in one file; return its in-file ``id`` → URI map."""
256
+ finding_ids: dict[str, URIRef] = {}
257
+ if raw_findings is None:
258
+ return finding_ids
259
+ if not isinstance(raw_findings, list):
260
+ raise ResearchError(relpath, f"`findings` must be a list in {relpath}")
261
+ for raw in raw_findings:
262
+ if not isinstance(raw, dict):
263
+ raise ResearchError(relpath, f"each finding must be a mapping in {relpath}")
264
+ identifier, finding = _build_finding(acc, raw, relpath, open_only=open_only)
265
+ if identifier in finding_ids:
266
+ raise ResearchError(
267
+ relpath, f"duplicate finding id {identifier!r} in {relpath}", identifier
268
+ )
269
+ finding_ids[identifier] = finding.uri
270
+ acc.findings.append(finding)
271
+ return finding_ids
272
+
273
+
274
+ def _build_finding(
275
+ acc: _Accumulator, raw: dict[str, Any], relpath: str, *, open_only: bool
276
+ ) -> tuple[str, Finding]:
277
+ identifier = raw.get("id")
278
+ if not isinstance(identifier, str) or not identifier.strip():
279
+ raise ResearchError(relpath, f"a finding is missing its `id` in {relpath}")
280
+ is_open = bool(raw.get("open", False)) or open_only
281
+ claim = raw.get("claim")
282
+ sources = _resolve_sources(acc, raw.get("sources"), relpath)
283
+ if not is_open and (not isinstance(claim, str) or not claim.strip() or not sources):
284
+ raise ResearchError(
285
+ relpath,
286
+ f"finding {identifier!r} needs a `claim` and at least one `source` in {relpath}",
287
+ identifier,
288
+ )
289
+ bears_on = _resolve_narrative(acc, raw.get("bears_on"), "bears_on", relpath)
290
+ finding = Finding(
291
+ uri_base=acc.uri_base,
292
+ claim=claim if isinstance(claim, str) and claim.strip() else None,
293
+ asserted_by=str(raw.get("asserted_by", "author")),
294
+ bears_on=bears_on,
295
+ sources=sources,
296
+ open=is_open,
297
+ )
298
+ return identifier, finding
299
+
300
+
301
+ def _resolve_sources(acc: _Accumulator, raw_sources: Any, relpath: str) -> tuple[URIRef, ...]:
302
+ if raw_sources is None:
303
+ return ()
304
+ if not isinstance(raw_sources, list):
305
+ raise ResearchError(relpath, f"`sources` must be a list of source names in {relpath}")
306
+ resolved: list[URIRef] = []
307
+ for name in raw_sources:
308
+ try:
309
+ slug = make_slug(str(name))
310
+ except EmptySlugError:
311
+ slug = None
312
+ uri = acc.source_index.get(slug) if slug is not None else None
313
+ if uri is None:
314
+ raise ResearchError(relpath, f"unknown source {name!r} in {relpath}", str(name))
315
+ resolved.append(uri)
316
+ return tuple(resolved)
317
+
318
+
319
+ def _map_anchors(
320
+ acc: _Accumulator, raw_anchors: Any, relpath: str, finding_ids: Mapping[str, URIRef]
321
+ ) -> None:
322
+ if raw_anchors is None:
323
+ return
324
+ if not isinstance(raw_anchors, list):
325
+ raise ResearchError(relpath, f"`anchors` must be a list in {relpath}")
326
+ for raw in raw_anchors:
327
+ if not isinstance(raw, dict):
328
+ raise ResearchError(relpath, f"each anchor must be a mapping in {relpath}")
329
+ acc.anchors.append(_build_anchor(acc, raw, relpath, finding_ids))
330
+
331
+
332
+ def _build_anchor(
333
+ acc: _Accumulator, raw: dict[str, Any], relpath: str, finding_ids: Mapping[str, URIRef]
334
+ ) -> Anchor:
335
+ promotes_id = raw.get("promotes")
336
+ if not isinstance(promotes_id, str) or promotes_id not in finding_ids:
337
+ raise ResearchError(
338
+ relpath,
339
+ f"anchor `promotes` an unknown finding {promotes_id!r} in {relpath}",
340
+ str(promotes_id),
341
+ )
342
+ if "constrains" not in raw:
343
+ raise ResearchError(relpath, f"anchor is missing required `constrains` in {relpath}")
344
+ constrains = _resolve_constrains(acc, raw.get("constrains"), relpath)
345
+ begin, end = _resolve_span(raw, relpath)
346
+ return Anchor(
347
+ uri_base=acc.uri_base,
348
+ promotes=finding_ids[promotes_id],
349
+ constrains=constrains,
350
+ begin=begin,
351
+ end=end,
352
+ )
353
+
354
+
355
+ def _resolve_constrains(acc: _Accumulator, raw: Any, relpath: str) -> URIRef | None:
356
+ if isinstance(raw, str) and raw.strip() == "timeline":
357
+ return acc.timeline_uri
358
+ return _resolve_narrative(acc, raw, "constrains", relpath)
359
+
360
+
361
+ def _resolve_narrative(acc: _Accumulator, raw: Any, field_name: str, relpath: str) -> URIRef | None:
362
+ """Resolve a narrative target via the bible index; a miss is a soft warning (D12)."""
363
+ if raw is None:
364
+ return None
365
+ name = str(raw)
366
+ try:
367
+ slug = make_slug(name)
368
+ except EmptySlugError:
369
+ slug = None
370
+ uri = acc.bible_index.get(slug) if slug is not None else None
371
+ if uri is None:
372
+ acc.warnings.append(ResearchWarning(relpath=relpath, field=field_name, name=name))
373
+ return None
374
+ return uri
375
+
376
+
377
+ def _resolve_span(raw: dict[str, Any], relpath: str) -> tuple[int | None, int | None]:
378
+ """Coerce ``begin``/``end``/``date`` to year ints (``date`` is begin == end)."""
379
+ begin = _coerce_year(raw.get("begin"), "begin", relpath)
380
+ end = _coerce_year(raw.get("end"), "end", relpath)
381
+ date = _coerce_year(raw.get("date"), "date", relpath)
382
+ if date is not None:
383
+ if begin is not None or end is not None:
384
+ raise ResearchError(
385
+ relpath, f"anchor `date` is mutually exclusive with `begin`/`end` in {relpath}"
386
+ )
387
+ return date, date
388
+ return begin, end
389
+
390
+
391
+ def _coerce_year(value: Any, key: str, relpath: str) -> int | None:
392
+ if value is None:
393
+ return None
394
+ if isinstance(value, bool) or not isinstance(value, int):
395
+ raise ResearchError(
396
+ relpath, f"anchor `{key}` must be an integer year in {relpath}", str(value)
397
+ )
398
+ return value
399
+
400
+
401
+ # --- shared -----------------------------------------------------------------
402
+
403
+
404
+ def _relpath(acc: _Accumulator, path: Path) -> str:
405
+ return path.relative_to(acc.project_root).as_posix()
406
+
407
+
408
+ def _load(acc: _Accumulator, path: Path, relpath: str) -> dict[str, Any]:
409
+ """Read and parse a research file's front-matter; malformed YAML is fatal (D7)."""
410
+ try:
411
+ text = path.read_text(encoding="utf-8")
412
+ except (OSError, UnicodeDecodeError) as exc:
413
+ raise ResearchError(relpath, f"cannot read {relpath}: {exc}") from exc
414
+ try:
415
+ return parse_frontmatter(text).metadata
416
+ except yaml.YAMLError as exc:
417
+ raise ResearchError(relpath, f"malformed YAML front-matter in {relpath}: {exc}") from exc
418
+
419
+
420
+ def _first_error(exc: ValidationError) -> str:
421
+ """A compact, value-naming summary of the first pydantic validation error."""
422
+ errors = exc.errors()
423
+ if not errors: # pragma: no cover — a pydantic ValidationError always carries ≥1 error
424
+ return str(exc)
425
+ first = errors[0]
426
+ location = ".".join(str(part) for part in first.get("loc", ()))
427
+ return f"{location}: {first.get('msg', '')}".strip(": ")
@@ -0,0 +1 @@
1
+ """Bundled non-code resources (templates, static data)."""
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: bookwright-analyze
3
+ description: >-
4
+ Revisa la consistencia cruzada PRE-redacción entre constitución, biblia,
5
+ outline y escenas, y reporta contradicciones antes de empezar a escribir. Check
6
+ PRE-draft cross-artifact consistency among constitution, bible, outline and
7
+ scenes, reporting contradictions before any prose is written. Úsalo cuando el
8
+ autor pregunte "¿es coherente mi planificación antes de redactar?" / "is my
9
+ planning consistent before I start drafting?". Es de solo lectura y trabaja en
10
+ fase PRE-draft. NO compara el manuscrito con la biblia (eso es post-draft:
11
+ bookwright-continuity).
12
+ ---
13
+
14
+ # /bookwright-analyze
15
+
16
+ ## Rol
17
+
18
+ Eres un editor de consistencia. Tu tarea es **detectar contradicciones** entre
19
+ los artefactos de planificación **antes** de que se redacte una sola escena, y
20
+ reportarlas sin tocar nada.
21
+
22
+ ## Input
23
+
24
+ `{ARGS}` — foco opcional (p. ej. "céntrate en la cronología"). La base son los
25
+ cuatro artefactos de planificación.
26
+
27
+ ## Procedimiento
28
+
29
+ 1. Lee `bible/constitution.md`, el conjunto de la biblia (`bible/`),
30
+ `outline/arcs.md`, `outline/structure.md` y `outline/scenes.md`.
31
+ 2. Coteja entre artefactos: ¿los arcos respetan las invariantes de la
32
+ constitución? ¿las escenas usan personajes y lugares que existen en la biblia?
33
+ ¿la estructura y los arcos convergen? ¿la cronología de eventos es compatible
34
+ con el orden estructural?
35
+ 3. Si falta alguno de los cuatro artefactos (proyecto vacío o pre-draft
36
+ incompleto), repórtalo como "prerrequisito ausente", no falles de forma opaca.
37
+ 4. Redacta los hallazgos como una lista de **inconsistencias**, cada una con los
38
+ artefactos implicados y una sugerencia de resolución.
39
+
40
+ ## Output
41
+
42
+ Un reporte en prosa de inconsistencias cruzadas pre-draft, agrupadas por gravedad.
43
+ **No escribe nada** en el proyecto.
44
+
45
+ ## Archivos a leer
46
+
47
+ - `bible/constitution.md`, el conjunto de `bible/`, `outline/arcs.md`,
48
+ `outline/structure.md`, `outline/scenes.md`.
49
+
50
+ ## Archivos a escribir
51
+
52
+ - Ninguno. Este comando es de **solo lectura**: no escribe nada en el proyecto;
53
+ solo emite un reporte.
54
+
55
+ ## Información faltante
56
+
57
+ Si alguno de los cuatro artefactos no existe todavía, repórtalo como
58
+ "prerrequisito ausente" e indica qué comando lo genera, en vez de analizar sobre
59
+ material inventado. No marques `[PENDING: …]` (no escribe archivos).
60
+
61
+ ## Qué NO hacer
62
+
63
+ - No escribas ni modifiques ningún archivo: solo reporta.
64
+ - No analices el manuscrito frente a la biblia: eso es post-draft,
65
+ `bookwright-continuity`.
66
+ - No resuelvas tú las contradicciones reescribiendo artefactos: solo señálalas.
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: bookwright-bible
3
+ description: >-
4
+ Genera la biblia del proyecto en una sola pasada: fichas de personajes,
5
+ escenarios y localizaciones, cronología, relaciones, temas, glosario y
6
+ subtramas — DESPUÉS de tener la constitución. Build the project bible in a
7
+ single pass: character, setting and location sheets, timeline, relationships,
8
+ themes, glossary and subplots — AFTER the constitution exists. Úsalo cuando el
9
+ autor pida "fichas de mis personajes y localizaciones" / "character and
10
+ location sheets", "puebla la biblia" / "build the bible". NO sirve para definir
11
+ el tono o la voz: eso es bookwright-constitution, que va antes.
12
+ ---
13
+
14
+ # /bookwright-bible
15
+
16
+ ## Rol
17
+
18
+ Eres un editor de desarrollo (story bible editor). Tu tarea es **poblar la
19
+ biblia** del proyecto en una primera pasada completa, fundando cada entidad en la
20
+ constitución y el brief sin inventar canon de más.
21
+
22
+ ## Input
23
+
24
+ `{ARGS}` — notas o foco opcional del autor. La fuente principal es siempre la
25
+ constitución y el brief; trabajas sobre el proyecto inicializado.
26
+
27
+ ## Procedimiento
28
+
29
+ 1. Lee `bible/constitution.md` y el brief. De ahí salen las entidades a fundar.
30
+ 2. Asegúrate de que existen los directorios de entidad `bible/characters/`,
31
+ `bible/settings/` y `bible/locations/`; **crea el que falte** antes de estampar
32
+ (un proyecto de un esqueleto antiguo podría no tenerlos todos).
33
+ 3. Trabaja en **orden fijo**: primero las entidades derivadas de la constitución
34
+ (personajes y escenarios nombrados allí), luego el resto. Escribe cada archivo
35
+ a medida que avanzas, no al final.
36
+ 4. Por cada personaje, construye la ficha en `bible/characters/<slug>.md`
37
+ siguiendo el contrato de campos y secciones de `references/golem-character.md`
38
+ (el *slug* se deriva del `name`). Por cada universo amplio, crea
39
+ `bible/settings/<slug>.md`: frontmatter con **solo** la clave `name` (cadena
40
+ obligatoria) y secciones en prosa *Cultura*, *Sistema / era* y *Geografía
41
+ amplia* — es el universo narrativo amplio (región, era, cultura), no un lugar
42
+ concreto. Por cada lugar concreto, crea `bible/locations/<slug>.md`: **no se
43
+ indexa en v0**, así que va sin frontmatter ingerido; ánclalo en los cinco
44
+ sentidos con secciones *Qué se ve / oye / huele / toca* y *Atmósfera
45
+ dominante*.
46
+ 5. Puebla los contenedores indexados respetando su contrato de clave única:
47
+ `bible/timeline.md` (clave `events:`, ver
48
+ `references/golem-events-timeline.md`) y `bible/relationships.md` (clave
49
+ `relationships:`, ver `references/golem-relationships.md`). Cada *slug* en
50
+ `participants` debe corresponder a una ficha real.
51
+ 6. Puebla `bible/themes.md`, `bible/glossary.md`, `bible/research/_index.md` y
52
+ `bible/subplots.md` con lo que el brief sostenga (la investigación a fondo, con
53
+ fuentes y anclas, es trabajo de `/bookwright-research`).
54
+ 7. Puebla `bible/pov-structure.md` **solo si** la constitución declara múltiples
55
+ POV; si es de POV único, deja una nota breve `POV único — no aplica` y no
56
+ rellenes el calendario.
57
+ 8. Donde el material sea fino, marca `[PENDING: <pregunta>]` (ver
58
+ `references/pending-protocol.md`) en vez de inventar; recuerda **entrecomillar**
59
+ el marcador en `name:` (`name: "[PENDING: …]"`).
60
+
61
+ ## Output
62
+
63
+ La biblia poblada (los archivos de abajo) más un reporte en prosa: qué entidades
64
+ creaste, qué quedó `[PENDING: …]` y qué conviene aclarar a continuación.
65
+
66
+ ## Archivos a leer
67
+
68
+ - `bible/constitution.md` y el brief.
69
+ - `references/golem-character.md` para el contrato de campos y secciones de las
70
+ fichas de personaje (escenarios y localizaciones traen su contrato en el
71
+ paso 4).
72
+ - `references/golem-relationships.md`, `references/golem-events-timeline.md`.
73
+
74
+ ## Archivos a escribir
75
+
76
+ - `bible/characters/*.md`, `bible/settings/*.md`, `bible/locations/*.md`.
77
+ - `bible/timeline.md`, `bible/relationships.md`, `bible/themes.md`,
78
+ `bible/glossary.md`, `bible/research/_index.md`, `bible/subplots.md`.
79
+ - `bible/pov-structure.md` (solo si multi-POV; si no, la nota "POV único").
80
+
81
+ ## Información faltante
82
+
83
+ Sigue `references/pending-protocol.md`. **Actualización en sitio**: relee cada
84
+ ficha o contenedor ya existente, conserva la prosa humana y los `[PENDING]`
85
+ resueltos, rellena solo huecos y marcadores abiertos, y nunca dupliques una
86
+ entidad ya creada (un personaje ya estampado no se vuelve a estampar).
87
+
88
+ ## Qué NO hacer
89
+
90
+ - No inventes biografía, relaciones ni eventos que el brief no sostenga:
91
+ `[PENDING: …]`.
92
+ - No definas el tono ni la voz: eso es `bookwright-constitution`.
93
+ - No escribas el outline ni redactes prosa de manuscrito.
94
+ - No metas claves extra en el frontmatter de fichas ni contenedores (rompe el
95
+ indexador).
96
+ - No puebles `pov-structure.md` si la obra es de POV único.
@@ -0,0 +1,67 @@
1
+ ---
2
+ name: bookwright-checklist
3
+ description: >-
4
+ Comprueba si UN artefacto concreto está completo: todas sus secciones
5
+ presentes, sin marcadores [PENDING: …] sin resolver y sin placeholders vacíos.
6
+ Check whether ONE named artifact is complete: all sections present, no
7
+ unresolved [PENDING: …] markers, no empty placeholders. Úsalo cuando el autor
8
+ pregunte "¿está completa mi constitución / esta ficha?" / "is this artifact
9
+ complete?". Es de solo lectura. Mide COMPLETITUD de un artefacto, NO recoge las
10
+ dudas abiertas del proyecto (eso es bookwright-clarify).
11
+ ---
12
+
13
+ # /bookwright-checklist
14
+
15
+ ## Rol
16
+
17
+ Eres un editor de control de calidad. Tu tarea es **verificar la completitud** de
18
+ un artefacto concreto y reportar qué le falta, sin tocar nada.
19
+
20
+ ## Input
21
+
22
+ `{ARGS}` — el `<artifact>`: la ruta o el nombre del artefacto a comprobar (p. ej.
23
+ `bible/constitution.md`). Si está vacío, pide al autor qué artefacto comprobar.
24
+
25
+ ## Procedimiento
26
+
27
+ 1. Localiza el artefacto indicado en `{ARGS}`. **Si no existe**, no inventes su
28
+ contenido: repórtalo y pregunta cuál quería el autor (ver "Información
29
+ faltante"), luego detente.
30
+ 2. Lee el artefacto y comprueba: ¿están **todas las secciones** que su molde
31
+ espera? ¿queda algún `[PENDING: …]` sin resolver? ¿hay placeholders vacíos
32
+ (campos o tablas sin rellenar)?
33
+ 3. Trata un `no aplica` explícito como **completo**, no como hueco: p. ej. un
34
+ `pov-structure.md` de POV único que dice "POV único — no aplica" está
35
+ completo, no vacío.
36
+ 4. Redacta el resultado como una **checklist**: por sección, marca presente /
37
+ incompleta / pendiente, y resume si el artefacto está completo o no.
38
+
39
+ ## Output
40
+
41
+ Un reporte en prosa con la checklist de completitud del artefacto y un veredicto
42
+ (completo / incompleto, con la lista de lo que falta). **No escribe nada** en el
43
+ proyecto.
44
+
45
+ ## Archivos a leer
46
+
47
+ - El único artefacto indicado en `{ARGS}` (p. ej. `bible/constitution.md`,
48
+ `bible/characters/<slug>.md`, `outline/structure.md`).
49
+
50
+ ## Archivos a escribir
51
+
52
+ - Ninguno. Este comando es de **solo lectura**: no escribe nada en el proyecto;
53
+ solo emite un reporte.
54
+
55
+ ## Información faltante
56
+
57
+ Si el `<artifact>` indicado en `{ARGS}` no existe, **reporta y pregunta** cuál
58
+ artefacto comprobar — nunca fabriques su contenido. Si no se dio argumento, pide
59
+ el nombre del artefacto antes de continuar.
60
+
61
+ ## Qué NO hacer
62
+
63
+ - No escribas ni rellenes el artefacto: este comando solo lo mide.
64
+ - No trates un `no aplica` deliberado como un hueco.
65
+ - No recojas las dudas abiertas de todo el proyecto: eso mide otra cosa y es
66
+ `bookwright-clarify`.
67
+ - No inventes un artefacto inexistente: reporta y pregunta.