grimoire-kit 3.0.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 (41) hide show
  1. grimoire/__init__.py +7 -0
  2. grimoire/__version__.py +1 -0
  3. grimoire/cli/__init__.py +1 -0
  4. grimoire/cli/app.py +641 -0
  5. grimoire/cli/cmd_merge.py +37 -0
  6. grimoire/cli/cmd_upgrade.py +175 -0
  7. grimoire/core/__init__.py +16 -0
  8. grimoire/core/config.py +237 -0
  9. grimoire/core/exceptions.py +87 -0
  10. grimoire/core/merge.py +230 -0
  11. grimoire/core/project.py +230 -0
  12. grimoire/core/resolver.py +68 -0
  13. grimoire/core/scanner.py +208 -0
  14. grimoire/core/validator.py +249 -0
  15. grimoire/mcp/__init__.py +1 -0
  16. grimoire/mcp/server.py +310 -0
  17. grimoire/memory/__init__.py +7 -0
  18. grimoire/memory/backends/__init__.py +0 -0
  19. grimoire/memory/backends/base.py +84 -0
  20. grimoire/memory/backends/local.py +148 -0
  21. grimoire/memory/backends/ollama.py +254 -0
  22. grimoire/memory/backends/qdrant.py +238 -0
  23. grimoire/memory/manager.py +162 -0
  24. grimoire/py.typed +0 -0
  25. grimoire/registry/__init__.py +1 -0
  26. grimoire/registry/agents.py +189 -0
  27. grimoire/registry/local.py +117 -0
  28. grimoire/tools/__init__.py +19 -0
  29. grimoire/tools/_common.py +109 -0
  30. grimoire/tools/agent_forge.py +228 -0
  31. grimoire/tools/context_guard.py +259 -0
  32. grimoire/tools/context_router.py +320 -0
  33. grimoire/tools/harmony_check.py +358 -0
  34. grimoire/tools/memory_lint.py +468 -0
  35. grimoire/tools/preflight_check.py +208 -0
  36. grimoire/tools/stigmergy.py +356 -0
  37. grimoire_kit-3.0.0.dist-info/METADATA +910 -0
  38. grimoire_kit-3.0.0.dist-info/RECORD +41 -0
  39. grimoire_kit-3.0.0.dist-info/WHEEL +4 -0
  40. grimoire_kit-3.0.0.dist-info/entry_points.txt +3 -0
  41. grimoire_kit-3.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,175 @@
1
+ """``grimoire upgrade`` — migrate a v2 project to v3 structure.
2
+
3
+ Detects a v2 project by the presence of ``project-context.yaml`` without
4
+ v3 markers. Generates the v3 config from the v2 config,
5
+ ensures the v3 directory layout, and preserves all memory files.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from grimoire.tools._common import load_yaml, save_yaml
15
+
16
+ # ── Data Models ───────────────────────────────────────────────────────────────
17
+
18
+ @dataclass(slots=True)
19
+ class UpgradeAction:
20
+ """A single planned migration action."""
21
+
22
+ kind: str # "create-dir", "generate-file", "move-dir", "skip"
23
+ description: str
24
+ target: str
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class UpgradePlan:
29
+ """All planned actions for the v2 → v3 migration."""
30
+
31
+ source_version: str = "v2"
32
+ target_version: str = "v3"
33
+ actions: list[UpgradeAction] = field(default_factory=list)
34
+ warnings: list[str] = field(default_factory=list)
35
+ already_v3: bool = False
36
+
37
+
38
+ # ── Detection ─────────────────────────────────────────────────────────────────
39
+
40
+ def detect_version(project_root: Path) -> str:
41
+ """Return ``"v3"`` if ``project-context.yaml`` contains v3 markers,
42
+ ``"v2"`` if it exists but is v2-style, or ``"unknown"``."""
43
+ pctx = project_root / "project-context.yaml"
44
+ if not pctx.exists():
45
+ return "unknown"
46
+
47
+ try:
48
+ data = load_yaml(pctx)
49
+ except OSError:
50
+ return "unknown"
51
+
52
+ if not isinstance(data, dict):
53
+ return "unknown"
54
+
55
+ # v3 has top-level 'grimoire' key with 'version'
56
+ if "grimoire" in data and isinstance(data["grimoire"], dict):
57
+ return "v3"
58
+
59
+ # v2 has top-level keys like 'project', 'communication_language', etc.
60
+ if "project" in data or "communication_language" in data:
61
+ return "v2"
62
+
63
+ return "unknown"
64
+
65
+
66
+ # ── Planning ──────────────────────────────────────────────────────────────────
67
+
68
+ def _extract_v2_config(project_root: Path) -> dict[str, Any]:
69
+ """Extract usable fields from a v2 project-context.yaml."""
70
+ data = load_yaml(project_root / "project-context.yaml")
71
+ if not isinstance(data, dict):
72
+ return {}
73
+ return data
74
+
75
+
76
+ def plan_upgrade(project_root: Path) -> UpgradePlan:
77
+ """Analyze the project and plan the migration."""
78
+ plan = UpgradePlan()
79
+ version = detect_version(project_root)
80
+
81
+ if version == "v3":
82
+ plan.already_v3 = True
83
+ return plan
84
+
85
+ if version == "unknown":
86
+ plan.warnings.append(
87
+ "No project-context.yaml found or unrecognizable format."
88
+ )
89
+ return plan
90
+
91
+ # v2 → v3 migration plan
92
+ _extract_v2_config(project_root) # validate readable
93
+
94
+ # 1. Generate bmad section in project-context.yaml
95
+ plan.actions.append(UpgradeAction(
96
+ kind="generate-file",
97
+ description="Add 'grimoire' section to project-context.yaml (v3 config)",
98
+ target="project-context.yaml",
99
+ ))
100
+
101
+ # 2. Ensure v3 directories
102
+ for d in ("_grimoire", "_grimoire/_memory", "_grimoire-output",
103
+ "_grimoire/_config", "_grimoire/_config/agents"):
104
+ dp = project_root / d
105
+ if not dp.is_dir():
106
+ plan.actions.append(UpgradeAction(
107
+ kind="create-dir",
108
+ description=f"Create directory: {d}/",
109
+ target=d,
110
+ ))
111
+
112
+ # 3. Warn about orphan files
113
+ old_dirs = ["agents", "tasks", "workflows"]
114
+ for od in old_dirs:
115
+ odp = project_root / od
116
+ if odp.is_dir():
117
+ plan.warnings.append(
118
+ f"Top-level '{od}/' directory exists — may need manual review."
119
+ )
120
+
121
+ return plan
122
+
123
+
124
+ # ── Execution ─────────────────────────────────────────────────────────────────
125
+
126
+ def _generate_v3_section(v2_data: dict[str, Any]) -> dict[str, Any]:
127
+ """Build the 'grimoire' section from v2 config data."""
128
+ project_name = v2_data.get("project", "unnamed")
129
+ if isinstance(project_name, dict):
130
+ project_name = project_name.get("name", "unnamed")
131
+
132
+ return {
133
+ "grimoire": {
134
+ "version": "3.0",
135
+ "migrated_from": "v2",
136
+ },
137
+ "project": {
138
+ "name": project_name,
139
+ },
140
+ "agents": {
141
+ "archetype": "minimal",
142
+ },
143
+ "memory": {
144
+ "backend": "auto",
145
+ },
146
+ }
147
+
148
+
149
+ def execute_upgrade(project_root: Path, plan: UpgradePlan,
150
+ dry_run: bool = False) -> list[str]:
151
+ """Execute the upgrade plan. Returns list of completed action descriptions."""
152
+ completed: list[str] = []
153
+
154
+ for action in plan.actions:
155
+ if action.kind == "create-dir":
156
+ dp = project_root / action.target
157
+ if not dry_run:
158
+ dp.mkdir(parents=True, exist_ok=True)
159
+ completed.append(action.description)
160
+
161
+ elif action.kind == "generate-file" and action.target == "project-context.yaml":
162
+ pctx = project_root / "project-context.yaml"
163
+ v2_data = _extract_v2_config(project_root)
164
+ v3_section = _generate_v3_section(v2_data)
165
+
166
+ if not dry_run:
167
+ # Merge v3 section into existing file
168
+ existing = load_yaml(pctx) if pctx.exists() else {}
169
+ if not isinstance(existing, dict):
170
+ existing = {}
171
+ existing.update(v3_section)
172
+ save_yaml(existing, pctx)
173
+ completed.append(action.description)
174
+
175
+ return completed
@@ -0,0 +1,16 @@
1
+ """Grimoire core — business logic and domain models."""
2
+
3
+ from grimoire.core.config import GrimoireConfig
4
+ from grimoire.core.exceptions import GrimoireError
5
+ from grimoire.core.project import GrimoireProject
6
+ from grimoire.core.resolver import PathResolver
7
+ from grimoire.core.scanner import StackScanner
8
+
9
+ __all__ = [
10
+ "GrimoireConfig",
11
+ "GrimoireError",
12
+ "GrimoireProject",
13
+ "PathResolver",
14
+ "StackScanner",
15
+ ]
16
+
@@ -0,0 +1,237 @@
1
+ """Typed configuration for Grimoire projects.
2
+
3
+ Loads and validates ``project-context.yaml`` into typed dataclasses.
4
+ Unknown sections are preserved in ``extra`` so downstream tools can
5
+ access them without requiring schema changes here.
6
+
7
+ Usage::
8
+
9
+ from grimoire.core.config import GrimoireConfig
10
+
11
+ cfg = GrimoireConfig.from_yaml(Path("project-context.yaml"))
12
+ print(cfg.project.name)
13
+ print(cfg.user.language)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from grimoire.core.exceptions import GrimoireConfigError
23
+
24
+ # ── Sub-sections ──────────────────────────────────────────────────────────────
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class RepoConfig:
28
+ """A single repository entry."""
29
+
30
+ name: str
31
+ path: str = "."
32
+ default_branch: str = "main"
33
+
34
+ @classmethod
35
+ def from_dict(cls, data: dict[str, Any]) -> RepoConfig:
36
+ return cls(
37
+ name=str(data.get("name", "")),
38
+ path=str(data.get("path", ".")),
39
+ default_branch=str(data.get("default_branch", "main")),
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class ProjectConfig:
45
+ """The ``project:`` section."""
46
+
47
+ name: str
48
+ description: str = ""
49
+ type: str = "webapp"
50
+ metaphor: str = ""
51
+ stack: tuple[str, ...] = ()
52
+ repos: tuple[RepoConfig, ...] = ()
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: dict[str, Any]) -> ProjectConfig:
56
+ raw_stack = data.get("stack") or []
57
+ raw_repos = data.get("repos") or []
58
+ return cls(
59
+ name=str(data.get("name", "")),
60
+ description=str(data.get("description", "")),
61
+ type=str(data.get("type", "webapp")),
62
+ metaphor=str(data.get("metaphor", "")),
63
+ stack=tuple(str(s) for s in raw_stack),
64
+ repos=tuple(RepoConfig.from_dict(r) for r in raw_repos),
65
+ )
66
+
67
+
68
+ _VALID_SKILL_LEVELS = frozenset({"beginner", "intermediate", "expert"})
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class UserConfig:
73
+ """The ``user:`` section."""
74
+
75
+ name: str = ""
76
+ language: str = "Français"
77
+ document_language: str = "Français"
78
+ skill_level: str = "intermediate"
79
+
80
+ @classmethod
81
+ def from_dict(cls, data: dict[str, Any]) -> UserConfig:
82
+ skill = str(data.get("skill_level", "intermediate"))
83
+ if skill not in _VALID_SKILL_LEVELS:
84
+ raise GrimoireConfigError(
85
+ f"Invalid skill_level '{skill}', expected one of: {sorted(_VALID_SKILL_LEVELS)}"
86
+ )
87
+ return cls(
88
+ name=str(data.get("name", "")),
89
+ language=str(data.get("language", "Français")),
90
+ document_language=str(data.get("document_language", "Français")),
91
+ skill_level=skill,
92
+ )
93
+
94
+
95
+ _VALID_BACKENDS = frozenset({
96
+ "auto", "local", "qdrant-local", "qdrant-server", "ollama",
97
+ })
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class MemoryConfig:
102
+ """The ``memory:`` section."""
103
+
104
+ backend: str = "auto"
105
+ collection_prefix: str = "grimoire"
106
+ embedding_model: str = ""
107
+ qdrant_url: str = ""
108
+ ollama_url: str = ""
109
+
110
+ @classmethod
111
+ def from_dict(cls, data: dict[str, Any]) -> MemoryConfig:
112
+ backend = str(data.get("backend", "auto"))
113
+ if backend not in _VALID_BACKENDS:
114
+ raise GrimoireConfigError(
115
+ f"Invalid memory backend '{backend}', expected one of: {sorted(_VALID_BACKENDS)}"
116
+ )
117
+ return cls(
118
+ backend=backend,
119
+ collection_prefix=str(data.get("collection_prefix", "grimoire")),
120
+ embedding_model=str(data.get("embedding_model", "")),
121
+ qdrant_url=str(data.get("qdrant_url", "")),
122
+ ollama_url=str(data.get("ollama_url", "")),
123
+ )
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class AgentsConfig:
128
+ """The ``agents:`` section."""
129
+
130
+ archetype: str = "minimal"
131
+ custom_agents: tuple[str, ...] = ()
132
+
133
+ @classmethod
134
+ def from_dict(cls, data: dict[str, Any]) -> AgentsConfig:
135
+ raw = data.get("custom_agents") or []
136
+ return cls(
137
+ archetype=str(data.get("archetype", "minimal")),
138
+ custom_agents=tuple(str(a) for a in raw),
139
+ )
140
+
141
+
142
+ # ── Root Config ───────────────────────────────────────────────────────────────
143
+
144
+ _KNOWN_TOP_KEYS = frozenset({
145
+ "project", "user", "memory", "agents", "installed_archetypes",
146
+ })
147
+
148
+
149
+ @dataclass(frozen=True, slots=True)
150
+ class GrimoireConfig:
151
+ """Root Grimoire project configuration.
152
+
153
+ Immutable after construction. Unrecognised top-level keys are stored
154
+ in ``extra`` so tools can access them without schema changes.
155
+ """
156
+
157
+ project: ProjectConfig
158
+ user: UserConfig = field(default_factory=UserConfig)
159
+ memory: MemoryConfig = field(default_factory=MemoryConfig)
160
+ agents: AgentsConfig = field(default_factory=AgentsConfig)
161
+ installed_archetypes: tuple[str, ...] = ()
162
+ extra: dict[str, Any] = field(default_factory=dict)
163
+
164
+ # ── Factory methods ───────────────────────────────────────────────
165
+
166
+ @classmethod
167
+ def from_dict(cls, data: dict[str, Any]) -> GrimoireConfig:
168
+ """Build a :class:`GrimoireConfig` from a parsed YAML dict.
169
+
170
+ Raises :class:`GrimoireConfigError` on validation failures.
171
+ """
172
+ if not isinstance(data, dict):
173
+ raise GrimoireConfigError("Config root must be a YAML mapping")
174
+
175
+ raw_project = data.get("project")
176
+ if not isinstance(raw_project, dict) or not raw_project.get("name"):
177
+ raise GrimoireConfigError("Config must contain a 'project' section with a 'name' field")
178
+
179
+ raw_archetypes = data.get("installed_archetypes") or []
180
+ extra = {k: v for k, v in data.items() if k not in _KNOWN_TOP_KEYS}
181
+
182
+ return cls(
183
+ project=ProjectConfig.from_dict(raw_project),
184
+ user=UserConfig.from_dict(data.get("user") or {}),
185
+ memory=MemoryConfig.from_dict(data.get("memory") or {}),
186
+ agents=AgentsConfig.from_dict(data.get("agents") or {}),
187
+ installed_archetypes=tuple(str(a) for a in raw_archetypes),
188
+ extra=extra,
189
+ )
190
+
191
+ @classmethod
192
+ def from_yaml(cls, path: Path) -> GrimoireConfig:
193
+ """Load config from a YAML file.
194
+
195
+ Raises :class:`GrimoireConfigError` if the file is missing, unreadable,
196
+ or contains invalid YAML.
197
+ """
198
+ if not path.is_file():
199
+ raise GrimoireConfigError(f"Config file not found: {path}")
200
+
201
+ try:
202
+ from ruamel.yaml import YAML
203
+
204
+ yaml = YAML(typ="safe")
205
+ raw = yaml.load(path)
206
+ except ImportError:
207
+ # Fallback to PyYAML if ruamel not available
208
+ try:
209
+ import yaml as pyyaml # type: ignore[import-untyped]
210
+
211
+ with open(path) as fh:
212
+ raw = pyyaml.safe_load(fh)
213
+ except Exception as exc:
214
+ raise GrimoireConfigError(f"Cannot parse '{path}': {exc}") from exc
215
+ except Exception as exc:
216
+ raise GrimoireConfigError(f"Cannot parse '{path}': {exc}") from exc
217
+
218
+ if raw is None:
219
+ raise GrimoireConfigError(f"Config file is empty: {path}")
220
+
221
+ return cls.from_dict(raw)
222
+
223
+ @classmethod
224
+ def find_and_load(cls, start: Path | None = None) -> GrimoireConfig:
225
+ """Walk up the directory tree to find ``project-context.yaml``.
226
+
227
+ Starts from *start* (default: cwd) and searches upward.
228
+ Raises :class:`GrimoireConfigError` if no config file is found.
229
+ """
230
+ current = (start or Path.cwd()).resolve()
231
+ for parent in [current, *current.parents]:
232
+ candidate = parent / "project-context.yaml"
233
+ if candidate.is_file():
234
+ return cls.from_yaml(candidate)
235
+ raise GrimoireConfigError(
236
+ f"No 'project-context.yaml' found in {current} or any parent directory"
237
+ )
@@ -0,0 +1,87 @@
1
+ """Typed exception hierarchy for Grimoire Kit.
2
+
3
+ Every exception inherits from :class:`GrimoireError` so callers can
4
+ ``except GrimoireError`` to catch all Grimoire-specific failures.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class GrimoireError(Exception):
11
+ """Base exception for all Grimoire Kit errors."""
12
+
13
+
14
+ class GrimoireConfigError(GrimoireError):
15
+ """Invalid or missing ``bmad.yaml`` configuration.
16
+
17
+ Raised when the config file cannot be parsed, contains unknown keys,
18
+ or fails schema validation.
19
+ """
20
+
21
+
22
+ class GrimoireProjectError(GrimoireError):
23
+ """Project not initialised or has an invalid structure.
24
+
25
+ Raised when ``grimoire up`` or other project-scoped commands are run
26
+ outside of a properly initialised Grimoire project.
27
+ """
28
+
29
+
30
+ class GrimoireAgentError(GrimoireError):
31
+ """Agent not found, persona invalid, or activation failure.
32
+
33
+ Raised when an agent referenced in ``bmad.yaml`` cannot be loaded
34
+ from built-in archetypes, the registry, or custom paths.
35
+ """
36
+
37
+
38
+ class GrimoireToolError(GrimoireError):
39
+ """A Grimoire tool failed during execution.
40
+
41
+ Raised when a tool (context-router, harmony-check, etc.) encounters
42
+ a runtime error that prevents it from producing a result.
43
+ """
44
+
45
+
46
+ class GrimoireMergeError(GrimoireError):
47
+ """General merge failure.
48
+
49
+ Raised when ``grimoire merge`` or ``grimoire init`` on an existing project
50
+ encounters an unrecoverable error during file merging.
51
+ """
52
+
53
+
54
+ class GrimoireMergeConflict(GrimoireMergeError): # noqa: N818 — semantic: it's a conflict, not an error
55
+ """Unresolved merge conflict that requires user intervention.
56
+
57
+ Carries the list of conflicting paths so the caller can present
58
+ them to the user.
59
+ """
60
+
61
+ def __init__(self, message: str, conflicts: list[str] | None = None) -> None:
62
+ super().__init__(message)
63
+ self.conflicts: list[str] = conflicts or []
64
+
65
+
66
+ class GrimoireRegistryError(GrimoireError):
67
+ """Registry operation failure (network, auth, package not found).
68
+
69
+ Raised by the registry client when a search, install, or publish
70
+ operation fails.
71
+ """
72
+
73
+
74
+ class GrimoireMemoryError(GrimoireError):
75
+ """Memory backend error (connection, serialisation, query failure).
76
+
77
+ Raised when the memory system cannot read, write, or consolidate
78
+ memories due to a backend issue.
79
+ """
80
+
81
+
82
+ class GrimoireValidationError(GrimoireError):
83
+ """Schema or data validation failure.
84
+
85
+ Raised when DNA files, agent definitions, or other structured data
86
+ fail validation against their expected schema.
87
+ """