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,89 @@
1
+ """``RdflibIndexer`` — the v0 default engine (wraps :class:`rdflib.Graph`).
2
+
3
+ Owns Turtle serialization and prefix binding so FR-015 (short prefixes) holds
4
+ regardless of which engine the manifest selects (R5). The build command feeds
5
+ ``entity.to_triples()`` through :meth:`add_triple`; ``graph query`` runs SPARQL
6
+ through :meth:`query`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from rdflib import Graph
16
+ from rdflib.query import ResultRow
17
+ from rdflib.term import Literal, URIRef
18
+
19
+ from bookwright.golem.namespaces import bind_prefixes
20
+
21
+ from .errors import GraphLoadError, InvalidQueryError
22
+
23
+
24
+ class RdflibIndexer:
25
+ """An :class:`~bookwright.indexers.base.Indexer` backed by ``rdflib``."""
26
+
27
+ def __init__(self, graph: Graph | None = None) -> None:
28
+ self._graph = graph if graph is not None else Graph()
29
+ bind_prefixes(self._graph)
30
+
31
+ # --- ingestion ----------------------------------------------------------
32
+
33
+ def add_triple(
34
+ self,
35
+ s: URIRef | str,
36
+ p: URIRef | str,
37
+ o: URIRef | Literal | str | int | float,
38
+ ) -> None:
39
+ """Add one triple. IRI-like ``str`` subjects/predicates coerce to ``URIRef``;
40
+ objects that are already rdflib terms pass through, scalars become literals."""
41
+ subject = s if isinstance(s, URIRef) else URIRef(s)
42
+ predicate = p if isinstance(p, URIRef) else URIRef(p)
43
+ if isinstance(o, (URIRef, Literal)):
44
+ obj: URIRef | Literal = o
45
+ else:
46
+ obj = Literal(o)
47
+ self._graph.add((subject, predicate, obj))
48
+
49
+ # --- persistence --------------------------------------------------------
50
+
51
+ def load(self, ttl_path: Path) -> None:
52
+ """Parse the Turtle at ``ttl_path`` into the engine's graph.
53
+
54
+ A malformed file raises :class:`GraphLoadError` (a clean envelope) rather
55
+ than letting an rdflib parse error escape as a raw traceback.
56
+ """
57
+ try:
58
+ self._graph.parse(str(ttl_path), format="turtle")
59
+ except Exception as exc: # rdflib raises a variety of parse errors
60
+ raise GraphLoadError(str(ttl_path), str(exc)) from exc
61
+
62
+ def save(self, ttl_path: Path) -> None:
63
+ """Serialize the graph to Turtle (short prefixes), creating parent dirs."""
64
+ ttl_path.parent.mkdir(parents=True, exist_ok=True)
65
+ self._graph.serialize(destination=str(ttl_path), format="turtle")
66
+
67
+ # --- querying -----------------------------------------------------------
68
+
69
+ def query(self, sparql: str) -> Iterable[dict[str, Any]]:
70
+ """Run a SELECT/ASK; return one dict per row (projected var → ``str``).
71
+
72
+ The result is fully materialized inside the ``try`` so a malformed query
73
+ raises :class:`InvalidQueryError` with **no partial rows** yielded first
74
+ (FR-016).
75
+ """
76
+ try:
77
+ result = self._graph.query(sparql)
78
+ rows: list[dict[str, Any]] = [
79
+ {str(var): str(value) for var, value in row.asdict().items()}
80
+ for row in result
81
+ if isinstance(row, ResultRow)
82
+ ]
83
+ except Exception as exc: # rdflib raises a variety of parse errors
84
+ raise InvalidQueryError(str(exc)) from exc
85
+ return rows
86
+
87
+ def count(self) -> int:
88
+ """Return the number of triples currently held."""
89
+ return len(self._graph)
@@ -0,0 +1,155 @@
1
+ """Public API for the ``bookwright.integrations`` layer (iteration 3).
2
+
3
+ Importing this package eagerly populates ``INTEGRATION_REGISTRY`` via
4
+ ``_register_builtins()`` (FR-002). Downstream consumers (iteration 4's
5
+ ``bookwright init``, iteration 9's skills materializer) need only::
6
+
7
+ from bookwright.integrations import get, list_keys, parse_options
8
+
9
+ The public surface (re-exported in ``__all__``) is contractual; renaming
10
+ any symbol below is a breaking change for iterations 4+.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from bookwright.integrations.base import SkillsIntegration
16
+ from bookwright.integrations.claude import ClaudeIntegration
17
+ from bookwright.integrations.constants import (
18
+ SKILL_DESCRIPTION_MAX_LENGTH,
19
+ SKILL_NAME_MAX_LENGTH,
20
+ SKILL_PLACEHOLDER_MARKER_NAME,
21
+ )
22
+ from bookwright.integrations.errors import (
23
+ DuplicateRegistrationError,
24
+ InvalidIntegrationError,
25
+ InvalidOptionDeclarationError,
26
+ MalformedOptionError,
27
+ SkillLintError,
28
+ SkillMaterializationError,
29
+ UnknownIntegrationError,
30
+ UnknownOptionError,
31
+ )
32
+ from bookwright.integrations.generic import GenericIntegration
33
+ from bookwright.integrations.options import IntegrationOption, parse_options
34
+
35
+ INTEGRATION_REGISTRY: dict[str, type[SkillsIntegration]] = {}
36
+
37
+
38
+ def _fqcn(cls: type) -> str:
39
+ """Fully-qualified class name, used in DuplicateRegistrationError payloads."""
40
+
41
+ return f"{cls.__module__}.{cls.__qualname__}"
42
+
43
+
44
+ def _register(cls: type[SkillsIntegration]) -> None:
45
+ """Register one integration class under its declared ``cls.key``.
46
+
47
+ Re-registering the same class is a no-op (FR-002 idempotency under
48
+ ``importlib.reload``). Registering a *different* class under an
49
+ existing key raises ``DuplicateRegistrationError`` naming both
50
+ classes (FR-005, research R5). Registering a class whose ``key``
51
+ is empty (the base-class sentinel) raises ``InvalidIntegrationError``
52
+ (R13) — subclasses MUST override ``key`` with a non-empty string.
53
+ Registering a class whose ``default_skills_dir`` is empty raises
54
+ ``InvalidIntegrationError`` (R25) for the same reason: an empty
55
+ default would surface as a misleading ``resolves_to_project_root``
56
+ error in ``setup()`` because ``Path("")`` collapses to ``Path(".")``.
57
+ """
58
+
59
+ new_fqcn = _fqcn(cls)
60
+ if not cls.key:
61
+ raise InvalidIntegrationError(rule="empty_key", value=new_fqcn)
62
+ if not cls.default_skills_dir:
63
+ # R25 — symmetric guard to R13. Without this, a forgetful subclass
64
+ # that overrides `key` but leaves `default_skills_dir` at the
65
+ # base sentinel ("") would register successfully and then fail
66
+ # at setup() time with `resolves_to_project_root` (because
67
+ # `Path("") == Path(".")` collapses to project_root), pointing
68
+ # the author at the wrong layer.
69
+ raise InvalidIntegrationError(rule="empty_default_skills_dir", value=new_fqcn)
70
+
71
+ existing = INTEGRATION_REGISTRY.get(cls.key)
72
+ if existing is None:
73
+ INTEGRATION_REGISTRY[cls.key] = cls
74
+ return
75
+
76
+ # R12 — compare by fully-qualified class name, not by identity. After
77
+ # `importlib.reload(bookwright.integrations.claude)` the reloaded
78
+ # `ClaudeIntegration` is a NEW class object with different identity
79
+ # from the one already in the registry; relying on `is` would raise
80
+ # DuplicateRegistrationError spuriously, breaking the FR-002 reload
81
+ # idempotency promise. FQCN equality preserves the duplicate guard
82
+ # for genuinely different classes (different module/qualname) that
83
+ # collide on `key`.
84
+ # R27 — compute existing FQCN once and reuse for both the equality
85
+ # check and the error payload, instead of recomputing on each call
86
+ # site.
87
+ existing_fqcn = _fqcn(existing)
88
+ if existing is cls or existing_fqcn == new_fqcn:
89
+ INTEGRATION_REGISTRY[cls.key] = cls # rebind to the reloaded class
90
+ return
91
+ raise DuplicateRegistrationError(
92
+ value=cls.key,
93
+ existing=existing_fqcn,
94
+ new=new_fqcn,
95
+ )
96
+
97
+
98
+ def _register_builtins() -> None:
99
+ """Populate ``INTEGRATION_REGISTRY`` with the two v0 built-ins.
100
+
101
+ Future contributors add a single ``_register(NewIntegration)`` line
102
+ here (per quickstart "Adding a new integration"). No edit to
103
+ ``base.py``, ``claude/``, or ``generic/`` is permitted — this is
104
+ enforced mechanically by ``tests/integrations/test_plugin_contract.py``
105
+ (FR-031).
106
+ """
107
+
108
+ _register(ClaudeIntegration)
109
+ _register(GenericIntegration)
110
+
111
+
112
+ def get(key: str) -> type[SkillsIntegration]:
113
+ """Look up an integration class by its short key (FR-003)."""
114
+
115
+ cls = INTEGRATION_REGISTRY.get(key)
116
+ if cls is None:
117
+ raise UnknownIntegrationError(value=key, valid=list_keys())
118
+ return cls
119
+
120
+
121
+ def list_keys() -> list[str]:
122
+ """Return registered integration keys, alphabetically sorted (FR-004)."""
123
+
124
+ return sorted(INTEGRATION_REGISTRY.keys())
125
+
126
+
127
+ _register_builtins()
128
+
129
+
130
+ __all__ = [
131
+ # NOTE: `INTEGRATION_REGISTRY` is intentionally NOT exposed in __all__
132
+ # (R17). The dict remains importable from the module namespace for
133
+ # the in-tree test snapshot fixture and the registry-mutation guard
134
+ # tests, but external consumers MUST go through `get`, `list_keys`,
135
+ # and `_register` — direct dict assignment bypasses FR-005's
136
+ # duplicate-detection guard and the R13 empty-key guard.
137
+ "SKILL_DESCRIPTION_MAX_LENGTH",
138
+ "SKILL_NAME_MAX_LENGTH",
139
+ "SKILL_PLACEHOLDER_MARKER_NAME",
140
+ "ClaudeIntegration",
141
+ "DuplicateRegistrationError",
142
+ "GenericIntegration",
143
+ "IntegrationOption",
144
+ "InvalidIntegrationError",
145
+ "InvalidOptionDeclarationError",
146
+ "MalformedOptionError",
147
+ "SkillLintError",
148
+ "SkillMaterializationError",
149
+ "SkillsIntegration",
150
+ "UnknownIntegrationError",
151
+ "UnknownOptionError",
152
+ "get",
153
+ "list_keys",
154
+ "parse_options",
155
+ ]
@@ -0,0 +1,117 @@
1
+ """``SkillsIntegration`` — the single operative v0 base class for integrations.
2
+
3
+ Subclasses live under ``bookwright.integrations.<key>/`` and override:
4
+ - ``key``, ``config``, ``default_skills_dir`` (always),
5
+ - ``supports_*`` flags (when the agent supports the capability),
6
+ - ``options()`` (when the integration declares ``--integration-options``
7
+ flags), and
8
+ - ``resolve_skills_dir()`` (when the resolved dir depends on
9
+ ``parsed_options``).
10
+
11
+ ``setup()`` is implemented once here; no v0 subclass overrides it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Mapping
17
+ from pathlib import Path
18
+ from types import MappingProxyType
19
+ from typing import TYPE_CHECKING, ClassVar
20
+
21
+ from bookwright.integrations.errors import MalformedOptionError
22
+ from bookwright.integrations.materialize import generate_skill_md, iter_command_sources
23
+ from bookwright.integrations.options import IntegrationOption
24
+ from bookwright.io.fs import NullLedger, mkdir_tracked
25
+
26
+ if TYPE_CHECKING:
27
+ from bookwright.core.manifest import Manifest
28
+ from bookwright.io.fs import FileLedger
29
+
30
+
31
+ class SkillsIntegration:
32
+ """Base contract every Bookwright v0 integration implements."""
33
+
34
+ # Sentinel defaults — every concrete subclass MUST override `key`,
35
+ # `config`, and `default_skills_dir`. The empty-string sentinel on
36
+ # `key` is caught by `_register` (R13). `config`'s default is a
37
+ # frozen `MappingProxyType` (R19) so a forgetful subclass that
38
+ # accidentally writes `cls.config['x'] = 'y'` raises TypeError
39
+ # instead of silently mutating the shared base dict and polluting
40
+ # every other forgetful subclass.
41
+ key: ClassVar[str] = ""
42
+ config: ClassVar[Mapping[str, str | bool]] = MappingProxyType({})
43
+ default_skills_dir: ClassVar[str] = ""
44
+
45
+ supports_dynamic_context: ClassVar[bool] = False
46
+ supports_subagents: ClassVar[bool] = False
47
+ supports_tool_restrictions: ClassVar[bool] = False
48
+
49
+ @classmethod
50
+ def options(cls) -> list[IntegrationOption]:
51
+ """Return the integration's declared ``--integration-options`` flags.
52
+
53
+ Default: empty list (FR-013). Override to declare flags.
54
+ """
55
+
56
+ return []
57
+
58
+ def resolve_skills_dir(
59
+ self,
60
+ parsed_options: Mapping[str, object] | None = None,
61
+ ) -> Path:
62
+ """Return the project-relative skills directory for this integration.
63
+
64
+ Default implementation returns ``Path(self.default_skills_dir)`` and
65
+ ignores ``parsed_options`` (FR-023). Subclasses that declare options
66
+ affecting the resolved directory (e.g., ``GenericIntegration``'s
67
+ ``--skills-dir``) MUST override.
68
+
69
+ ``Mapping`` (not ``dict``) is used in the signature so callers may
70
+ pass the narrower ``dict[str, str | bool]`` returned by
71
+ ``parse_options`` without an explicit cast (``dict`` is invariant).
72
+ """
73
+
74
+ del parsed_options
75
+ return Path(self.default_skills_dir)
76
+
77
+ def setup(
78
+ self,
79
+ project_root: Path,
80
+ manifest: Manifest,
81
+ parsed_options: Mapping[str, object] | None = None,
82
+ *,
83
+ ledger: FileLedger | None = None,
84
+ ) -> None:
85
+ """Materialize one ``SKILL.md`` per source command under the resolved dir.
86
+
87
+ Shared by every v0 integration (no subclass overrides it — the only
88
+ per-integration variation is already behind ``resolve_skills_dir`` and
89
+ the capability flags). For each packaged source command, delegate to
90
+ ``generate_skill_md``; idempotent per-``SKILL.md`` (FR-014); never writes
91
+ outside the resolved dir (FR-017).
92
+
93
+ ``ledger`` is the rollback-recording ``FileLedger`` (``init`` passes its
94
+ live ``BackupLedger``); when omitted it defaults to a ``NullLedger`` so
95
+ ``setup()`` is standalone-callable. Every materialized path is recorded
96
+ through it (FR-019). A ``SkillLintError``/``SkillMaterializationError``
97
+ from any command propagates, aborting this integration (FR-016).
98
+ """
99
+
100
+ # `manifest` is part of the iteration-9 contract; unused in v0 body.
101
+ del manifest
102
+
103
+ ledger = ledger or NullLedger()
104
+ resolved = self.resolve_skills_dir(parsed_options)
105
+ target = (project_root / resolved).resolve()
106
+ root = project_root.resolve()
107
+ if target == root:
108
+ # `--skills-dir=`, `--skills-dir .`, `--skills-dir ./`, etc. all
109
+ # collapse the target into project_root itself. Rejected as a
110
+ # separate rule from `escapes_project_root` so the JSON envelope
111
+ # can distinguish "lands AT root" from "lands OUTSIDE root" (R6).
112
+ raise MalformedOptionError(rule="resolves_to_project_root", value=str(resolved))
113
+ if not target.is_relative_to(root):
114
+ raise MalformedOptionError(rule="escapes_project_root", value=str(resolved))
115
+ mkdir_tracked(target, ledger)
116
+ for command_path in iter_command_sources():
117
+ generate_skill_md(command_path, target, self, ledger=ledger)
@@ -0,0 +1,29 @@
1
+ """``ClaudeIntegration`` — Claude Code integration (FR-007, FR-010, FR-013)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from bookwright.integrations.base import SkillsIntegration
8
+
9
+
10
+ class ClaudeIntegration(SkillsIntegration):
11
+ """Integration for the Claude Code agent (.claude/skills layout).
12
+
13
+ Inherits the base ``setup()`` body and the base ``resolve_skills_dir``
14
+ (which already returns ``Path(default_skills_dir)``); no overrides are
15
+ necessary in v0.
16
+ """
17
+
18
+ key: ClassVar[str] = "claude"
19
+ default_skills_dir: ClassVar[str] = ".claude/skills"
20
+ config: ClassVar[dict[str, str | bool]] = {
21
+ "name": "Claude Code",
22
+ "install_url": "https://docs.claude.com/claude-code",
23
+ "requires_cli": True,
24
+ "context_file": "CLAUDE.md",
25
+ }
26
+
27
+ supports_dynamic_context: ClassVar[bool] = True
28
+ supports_subagents: ClassVar[bool] = True
29
+ supports_tool_restrictions: ClassVar[bool] = True
@@ -0,0 +1,38 @@
1
+ """Agent Skills (agentskills.io) compliance constants for the integrations layer.
2
+
3
+ Single source of truth for the numeric caps, the inherited default license,
4
+ and the dynamic-context injection allowlist consumed by iteration 9's
5
+ ``SKILL.md`` materializer and linter (FR-003, FR-005, FR-013, FR-015).
6
+
7
+ Structural invariant from FR-003 — *the directory name MUST equal the
8
+ ``name`` field inside its ``SKILL.md``* — is not numeric and therefore is not
9
+ encoded as a constant; it is enforced by iteration 9's materializer/linter and
10
+ documented here so future readers do not duplicate the rule.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Final
16
+
17
+ SKILL_NAME_MAX_LENGTH: Final[int] = 64
18
+ SKILL_DESCRIPTION_MAX_LENGTH: Final[int] = 1024
19
+
20
+ #: Tier-2 ``SKILL.md`` body budget (R6/FR-015). Bodies are copied unchanged from
21
+ #: already-budget-passing iteration-8 sources, so this is a regression guard.
22
+ SKILL_BODY_MAX_TOKENS: Final[int] = 5000
23
+
24
+ #: License inherited by a materialized skill when its source declares no
25
+ #: ``license`` (FR-005/A-002). The single source of truth for the design default.
26
+ DEFAULT_SKILL_LICENSE: Final[str] = "Apache-2.0"
27
+
28
+ #: Deny-by-default allowlist of project-file *read* commands permitted inside a
29
+ #: `` !`…` `` dynamic-context injection (the FR-013 invariant). File-read only —
30
+ #: ``ls``/``find`` (which *list*, not *read a file*) are deliberately excluded to
31
+ #: stay faithful to FR-013 ("reads a project file").
32
+ INJECTION_READ_COMMANDS: Final[frozenset[str]] = frozenset({"cat", "head", "tail"})
33
+
34
+ #: DEPRECATED (iteration 9): the iteration-3 placeholder-marker filename. The
35
+ #: marker is **no longer written** — ``setup()`` now materializes real
36
+ #: ``SKILL.md`` files. Retained only so the deprecated symbol stays importable
37
+ #: for the legacy-cleanup tests; do not write this file in new code.
38
+ SKILL_PLACEHOLDER_MARKER_NAME: Final[str] = ".bookwright-skills-placeholder"
@@ -0,0 +1,48 @@
1
+ """Authoritative `SKILL.md` `description` table (R1/R3, FR-004).
2
+
3
+ A pure, dependency-free data module: `SKILL_DESCRIPTIONS` maps each command name
4
+ to its bilingual ES/EN trigger-bearing description, and `get_description` is the
5
+ single authoritative lookup (with source-frontmatter fallback) where the
6
+ 1024-char cap is asserted in **one** place.
7
+
8
+ In v0 the table mirrors the iteration-8 source frontmatter `description` verbatim
9
+ under a CI equality gate (SC-009): the dict is the authoritative read seam (where
10
+ the cap lives and where future per-skill tuning would land), while the gate makes
11
+ any divergence from the source an explicit, reviewed change — never silent drift.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from bookwright.integrations.constants import SKILL_DESCRIPTION_MAX_LENGTH
17
+
18
+ SKILL_DESCRIPTIONS: dict[str, str] = {
19
+ "bookwright-constitution": 'Define la constitución narrativa del libro: voz, tono, pacto con el lector, líneas rojas e invariantes de coherencia — el paso de configuración que va ANTES de la biblia. Build the book\'s narrative constitution: voice, tone, reader pact, red lines and coherence invariants — the setup step BEFORE the bible. Úsalo cuando el autor quiera fijar el tono, la voz o las reglas de su obra ("define el tono", "set the tone/voice", "establece las bases"). NO genera fichas de personajes ni localizaciones: eso es bookwright-bible, que va después.',
20
+ "bookwright-bible": 'Genera la biblia del proyecto en una sola pasada: fichas de personajes, escenarios y localizaciones, cronología, relaciones, temas, glosario y subtramas — DESPUÉS de tener la constitución. Build the project bible in a single pass: character, setting and location sheets, timeline, relationships, themes, glossary and subplots — AFTER the constitution exists. Úsalo cuando el autor pida "fichas de mis personajes y localizaciones" / "character and location sheets", "puebla la biblia" / "build the bible". NO sirve para definir el tono o la voz: eso es bookwright-constitution, que va antes.',
21
+ "bookwright-outline": 'Construye el esqueleto narrativo de la obra: arcos de personaje, estructura por actos y capítulos, y una sinopsis inicial, a partir de la constitución y la biblia. Build the book\'s narrative skeleton: character arcs, act/chapter structure and an initial synopsis, from the constitution and bible. Úsalo cuando el autor quiera "estructurar la trama", "diseñar los arcos" / "outline the plot", "design the arcs and structure". Trabaja al nivel de capítulos y arcos, no de escenas concretas (eso es bookwright-scenes).',
22
+ "bookwright-scenes": 'Desglosa la estructura en una lista de escenas concretas, cada una con su función narrativa, personajes presentes, lugar y beats. Break the structure into a concrete scene list, each carrying its narrative function, characters present, location and beats. Úsalo cuando el autor quiera "desglosar los capítulos en escenas", "preparar la lista de escenas" / "break chapters into scenes", "plan the scene list" antes de redactar. Planifica escenas; NO redacta su prosa (eso es bookwright-draft).',
23
+ "bookwright-draft": 'Redacta la prosa de una escena concreta (indicada por su scene_id) en el capítulo correcto del manuscrito, respetando la voz, la focalización y las restricciones de la constitución y la biblia. Draft the prose of a specific scene (given by its scene_id) into the correct manuscript chapter, honoring the voice, focalization and constraints from the constitution and bible. Úsalo cuando el autor diga "escribe/redacta la escena X" / "draft/write scene X". Es el único comando que produce prosa de manuscrito.',
24
+ "bookwright-synopsis": 'Actualiza la sinopsis del proyecto: una versión corta (250–350 palabras) y una larga (1000–2000 palabras) que reflejan el estado actual de la trama. Update the project synopsis: a short version (250–350 words) and a long one (1000–2000 words) reflecting the current state of the plot. Úsalo cuando el autor pida "actualiza/genera la sinopsis", "resume la novela" / "update/write the synopsis", "summarize the plot". Regenera ambos resúmenes en cualquier momento del proyecto.',
25
+ "bookwright-clarify": 'Revisa los artefactos del proyecto y devuelve una lista de preguntas abiertas que el autor debería resolver antes de seguir. Review the project artifacts and return a list of open questions the author should resolve before continuing. Úsalo cuando el autor pregunte "¿qué me falta por aclarar antes de seguir?", "¿qué dudas quedan?" / "what\'s still unclear?", "what do I need to decide next?". Es de solo lectura. Pregunta por DUDAS abiertas, NO comprueba la completitud de un artefacto concreto (eso es bookwright-checklist).',
26
+ "bookwright-analyze": 'Revisa la consistencia cruzada PRE-redacción entre constitución, biblia, outline y escenas, y reporta contradicciones antes de empezar a escribir. Check PRE-draft cross-artifact consistency among constitution, bible, outline and scenes, reporting contradictions before any prose is written. Úsalo cuando el autor pregunte "¿es coherente mi planificación antes de redactar?" / "is my planning consistent before I start drafting?". Es de solo lectura y trabaja en fase PRE-draft. NO compara el manuscrito con la biblia (eso es post-draft: bookwright-continuity).',
27
+ "bookwright-continuity": 'Revisa la consistencia POST-redacción del manuscrito frente a la biblia: cumplimiento de la biblia, coherencia de los arcos de personaje y de la línea de tiempo. Check POST-draft continuity of the manuscript against the bible: bible compliance, character-arc consistency and timeline coherence. Úsalo cuando el autor pida "revisa si mi manuscrito es coherente con la biblia" / "check my manuscript against the bible". Es de solo lectura y trabaja en fase POST-draft. NO revisa la planificación antes de redactar (eso es pre-draft: bookwright-analyze).',
28
+ "bookwright-checklist": 'Comprueba si UN artefacto concreto está completo: todas sus secciones presentes, sin marcadores [PENDING: …] sin resolver y sin placeholders vacíos. Check whether ONE named artifact is complete: all sections present, no unresolved [PENDING: …] markers, no empty placeholders. Úsalo cuando el autor pregunte "¿está completa mi constitución / esta ficha?" / "is this artifact complete?". Es de solo lectura. Mide COMPLETITUD de un artefacto, NO recoge las dudas abiertas del proyecto (eso es bookwright-clarify).',
29
+ "bookwright-research": 'Investiga un tema del mundo real y lo documenta como hallazgos con procedencia completa (fuentes, citas en lengua original, fiabilidad) en bible/research/, marcando qué hallazgos son anclas que restringen la ficción. Research a real-world topic and document it as findings with full provenance (sources, original-language quotes, reliability) under bible/research/, marking which findings are binding anchors on the fiction. Úsalo cuando el autor pida "investiga <tema>", "documenta <tema> con fuentes", "preséntame fuentes sobre <tema>" / "research <topic>", "find sources on <topic>". NO verifica prosa ya escrita contra sus fuentes (eso es bookwright-verify, posterior) ni puebla fichas de personajes o localizaciones (eso es bookwright-bible).',
30
+ "bookwright-verify": 'Verifica el manuscrito ya redactado contra las anclas de investigación: detecta pasajes que contradicen lo investigado — anacronismos, errores de procedimiento (algo ilegal o imposible en la ambientación) e inexactitudes culturales o lingüísticas. Verify the drafted manuscript against the research anchors: flag passages that contradict the research — anachronisms, procedural errors (something illegal or impossible in the setting) and cultural or linguistic inaccuracies. Úsalo cuando el autor pida "verifica si mi manuscrito contradice lo investigado" / "check my manuscript against my research". Es de solo lectura y trabaja en fase POST-draft. NO compara el manuscrito con la biblia (eso es bookwright-continuity) ni audita la integridad estructural de las anclas (eso es el validator factual_anchor).',
31
+ }
32
+
33
+
34
+ def get_description(name: str, fallback: str) -> str:
35
+ """Authoritative lookup with source-frontmatter fallback (R3, FR-004).
36
+
37
+ Returns `SKILL_DESCRIPTIONS[name]` when present, else `fallback` (the source
38
+ frontmatter description). The 1024-char cap is *enforced* at runtime by
39
+ ``lint_skill_md`` Rule 3 (which survives ``python -O``); the ``assert`` below
40
+ is only a developer-time tripwire on the static table — over-cap is a coding
41
+ error caught by tests (the SC-009 equality gate), never a user-data case.
42
+ """
43
+
44
+ result = SKILL_DESCRIPTIONS.get(name, fallback)
45
+ assert len(result) < SKILL_DESCRIPTION_MAX_LENGTH, (
46
+ f"description for {name!r} is {len(result)} chars, >= cap {SKILL_DESCRIPTION_MAX_LENGTH}"
47
+ )
48
+ return result
@@ -0,0 +1,170 @@
1
+ """Structured exception family for the integrations layer (FR-035, FR-036).
2
+
3
+ Every public error inherits from the private `_IntegrationError` base — which now
4
+ inherits the shared `BookwrightError` — and exposes:
5
+ - a class-level ``code: str`` (immutable identifier),
6
+ - a ``message: str`` attribute (human-readable, also passed to
7
+ ``Exception.__init__``),
8
+ - the structured fields under the canonical envelope's ``details`` (the
9
+ single ``to_json()`` is owned by `BookwrightError`; see
10
+ ``specs/018-unified-error-envelope/contracts/error-envelope.md``).
11
+
12
+ The ``init --json`` / ``integration use --json`` consumers read the canonical
13
+ ``to_json()`` body; renaming any ``details`` field below is a breaking change.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from bookwright.errors import BookwrightError
19
+
20
+
21
+ class _IntegrationError(BookwrightError):
22
+ """Private base for all structured errors raised by the integrations layer.
23
+
24
+ Subclasses MUST set a non-empty class-level ``code`` and assign their
25
+ structured fields on ``self`` in ``__init__`` (e.g., ``self.rule``,
26
+ ``self.value``), then end ``__init__`` with
27
+ ``super().__init__(message, {<public attrs>})`` so the inherited
28
+ ``BookwrightError.to_json()`` emits the canonical envelope. The base is
29
+ abstract: it declares no ``code`` and is never serialized directly.
30
+ """
31
+
32
+
33
+ class UnknownIntegrationError(_IntegrationError):
34
+ """Raised by ``get(key)`` when ``key`` is not in ``INTEGRATION_REGISTRY``."""
35
+
36
+ code = "unknown_integration"
37
+
38
+ def __init__(self, *, value: str | None, valid: list[str]) -> None:
39
+ self.value = value
40
+ self.valid = list(valid)
41
+ message = f"unknown integration: {value!r}; valid: [{', '.join(self.valid)}]"
42
+ super().__init__(message, {"value": value, "valid": self.valid})
43
+
44
+
45
+ class UnknownOptionError(_IntegrationError):
46
+ """Raised by ``parse_options`` for a flag the integration does not declare."""
47
+
48
+ code = "unknown_option"
49
+
50
+ def __init__(self, *, integration: str, value: str, valid: list[str]) -> None:
51
+ self.integration = integration
52
+ self.value = value
53
+ self.valid = list(valid)
54
+ message = (
55
+ f"unknown option {value} for integration {integration!r}; "
56
+ f"valid: [{', '.join(self.valid)}]"
57
+ )
58
+ super().__init__(
59
+ message,
60
+ {"integration": integration, "value": value, "valid": self.valid},
61
+ )
62
+
63
+
64
+ class MalformedOptionError(_IntegrationError):
65
+ """Raised by ``parse_options`` on a structural rule violation in user input."""
66
+
67
+ code = "malformed_option"
68
+
69
+ def __init__(self, *, rule: str, value: str) -> None:
70
+ self.rule = rule
71
+ self.value = value
72
+ message = f"malformed option {value!r}: {rule}"
73
+ super().__init__(message, {"rule": rule, "value": value})
74
+
75
+
76
+ class DuplicateRegistrationError(_IntegrationError):
77
+ """Raised by ``_register`` when a *different* class is bound to an existing key."""
78
+
79
+ code = "duplicate_registration"
80
+
81
+ def __init__(self, *, value: str, existing: str, new: str) -> None:
82
+ self.value = value
83
+ self.existing = existing
84
+ self.new = new
85
+ message = (
86
+ f"duplicate integration registration for key {value!r}: "
87
+ f"already registered as {existing}, refusing to replace with {new}"
88
+ )
89
+ super().__init__(message, {"value": value, "existing": existing, "new": new})
90
+
91
+
92
+ class InvalidOptionDeclarationError(_IntegrationError):
93
+ """Raised when an ``IntegrationOption`` descriptor itself is malformed.
94
+
95
+ Programming-error guard for FR-015: surfaced the first time
96
+ ``parse_options`` introspects an integration's ``options()``. Never
97
+ user-facing — the offending integration class needs to be fixed.
98
+ """
99
+
100
+ code = "invalid_option_declaration"
101
+
102
+ def __init__(self, *, rule: str, value: str) -> None:
103
+ self.rule = rule
104
+ self.value = value
105
+ message = (
106
+ f"invalid option declaration ({rule}): {value!r}; "
107
+ "this is a programming error in the integration's options()"
108
+ )
109
+ super().__init__(message, {"rule": rule, "value": value})
110
+
111
+
112
+ class InvalidIntegrationError(_IntegrationError):
113
+ """Raised by ``_register`` when an integration class is malformed.
114
+
115
+ Programming-error guard (R13): catches integrations that forgot to
116
+ override the base ``SkillsIntegration`` sentinel defaults
117
+ (e.g., ``cls.key = ""``). Never user-facing — the offending
118
+ integration class needs to be fixed.
119
+ """
120
+
121
+ code = "invalid_integration"
122
+
123
+ def __init__(self, *, rule: str, value: str) -> None:
124
+ self.rule = rule
125
+ self.value = value
126
+ message = (
127
+ f"invalid integration ({rule}): {value!r}; "
128
+ "this is a programming error in the integration class"
129
+ )
130
+ super().__init__(message, {"rule": rule, "value": value})
131
+
132
+
133
+ class SkillLintError(_IntegrationError):
134
+ """Raised by ``lint_skill_md`` on the first agentskills.io violation (FR-015).
135
+
136
+ Post-write: a freshly generated skill is linted right after it is written, and
137
+ ``generate_skill_md`` deletes the offending skill dir before this error escapes
138
+ (FR-016 — "no invalid SKILL.md on disk"). Note that ``generate_skill_md`` does
139
+ NOT re-lint a *pre-existing* ``SKILL.md``: idempotency (FR-014) skips it
140
+ untouched, so re-validation of hand-edited skills is the job of the standalone
141
+ ``lint_skill_md`` call (and the iteration-11 validation system that reuses it),
142
+ not of ``setup()``.
143
+ """
144
+
145
+ code = "skill_lint_failed"
146
+
147
+ def __init__(self, *, skill: str, rule: str, detail: str) -> None:
148
+ self.skill = skill
149
+ self.rule = rule
150
+ self.detail = detail
151
+ message = f"skill {skill!r} failed lint rule {rule!r}: {detail}"
152
+ super().__init__(message, {"skill": skill, "rule": rule, "detail": detail})
153
+
154
+
155
+ class SkillMaterializationError(_IntegrationError):
156
+ """Raised by ``generate_skill_md`` on a pre-write authoring error (FR-010/FR-020).
157
+
158
+ ``rule`` ∈ {``dangling_reference``, ``name_frontmatter_mismatch``,
159
+ ``residual_token``}. All are detected *before* the first filesystem write, so a
160
+ rejected source leaves **zero** on-disk state (nothing to clean up).
161
+ """
162
+
163
+ code = "skill_materialization_failed"
164
+
165
+ def __init__(self, *, skill: str, rule: str, detail: str) -> None:
166
+ self.skill = skill
167
+ self.rule = rule
168
+ self.detail = detail
169
+ message = f"skill {skill!r} materialization failed ({rule}): {detail}"
170
+ super().__init__(message, {"skill": skill, "rule": rule, "detail": detail})