grain-kit 0.1.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 (71) hide show
  1. grain/__init__.py +0 -0
  2. grain/adapters/__init__.py +0 -0
  3. grain/adapters/adapter_config.py +207 -0
  4. grain/adapters/capabilities.py +199 -0
  5. grain/adapters/export.py +104 -0
  6. grain/adapters/filesystem.py +32 -0
  7. grain/adapters/manifest.py +36 -0
  8. grain/adapters/model_config.py +205 -0
  9. grain/cli/__init__.py +81 -0
  10. grain/cli/adapter.py +115 -0
  11. grain/cli/context.py +282 -0
  12. grain/cli/docs.py +89 -0
  13. grain/cli/error_handler.py +39 -0
  14. grain/cli/init.py +45 -0
  15. grain/cli/model.py +202 -0
  16. grain/cli/onboard.py +61 -0
  17. grain/cli/orchestrate.py +204 -0
  18. grain/cli/output.py +54 -0
  19. grain/cli/phase.py +67 -0
  20. grain/cli/prompt.py +63 -0
  21. grain/cli/review.py +183 -0
  22. grain/cli/task.py +303 -0
  23. grain/cli/workflow.py +188 -0
  24. grain/domain/__init__.py +33 -0
  25. grain/domain/adapters.py +178 -0
  26. grain/domain/context.py +147 -0
  27. grain/domain/documents.py +81 -0
  28. grain/domain/errors.py +42 -0
  29. grain/domain/onboard.py +12 -0
  30. grain/domain/orchestrator.py +69 -0
  31. grain/domain/packets.py +111 -0
  32. grain/domain/routing.py +221 -0
  33. grain/domain/scan_result.py +19 -0
  34. grain/domain/workflow.py +27 -0
  35. grain/domain/workflow_loop.py +73 -0
  36. grain/services/__init__.py +0 -0
  37. grain/services/codebase_scanner.py +191 -0
  38. grain/services/context_service.py +557 -0
  39. grain/services/docs_extractor.py +82 -0
  40. grain/services/docs_service.py +167 -0
  41. grain/services/graph_service.py +524 -0
  42. grain/services/handoff_service.py +414 -0
  43. grain/services/init_service.py +223 -0
  44. grain/services/model_service.py +82 -0
  45. grain/services/onboard_doc_generator.py +134 -0
  46. grain/services/onboard_service.py +59 -0
  47. grain/services/orchestration_service.py +460 -0
  48. grain/services/pdf_extractor.py +41 -0
  49. grain/services/prompt_service.py +73 -0
  50. grain/services/review_service.py +238 -0
  51. grain/services/spreadsheet_extractor.py +90 -0
  52. grain/services/structural_intelligence_service.py +494 -0
  53. grain/services/task_prepare_service.py +59 -0
  54. grain/services/task_service.py +302 -0
  55. grain/services/workflow_loop_config_service.py +168 -0
  56. grain/services/workflow_loop_service.py +525 -0
  57. grain/services/workflow_run_service.py +258 -0
  58. grain/services/workflow_service.py +367 -0
  59. grain/templates/__init__.py +0 -0
  60. grain/templates/loader.py +18 -0
  61. grain/validators/__init__.py +3 -0
  62. grain/validators/authority_validator.py +58 -0
  63. grain/validators/doc_existence_validator.py +30 -0
  64. grain/validators/manifest_validator.py +102 -0
  65. grain/validators/orchestrator_validator.py +58 -0
  66. grain/validators/packet_validator.py +108 -0
  67. grain_kit-0.1.0.dist-info/METADATA +580 -0
  68. grain_kit-0.1.0.dist-info/RECORD +71 -0
  69. grain_kit-0.1.0.dist-info/WHEEL +5 -0
  70. grain_kit-0.1.0.dist-info/entry_points.txt +2 -0
  71. grain_kit-0.1.0.dist-info/top_level.txt +1 -0
grain/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,207 @@
1
+ """Load and parse adapter profile configuration from runtime markdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from grain.adapters.capabilities import GraphAwareAdapterCapability
9
+ from grain.domain.adapters import AdapterProfile
10
+ from grain.domain.errors import ConfigError, MissingPathError
11
+
12
+ ADAPTER_PROFILES_PATH = "docs/runtime/adapter_profiles.md"
13
+ _PROFILE_SECTION_HEADER = "## 5. Adapter Profiles"
14
+ _PROFILE_HEADER = re.compile(r"^###\s+([a-zA-Z0-9_]+)\s*$")
15
+ _INLINE_FIELD = re.compile(r"^- `([^`]+)`: `([^`]+)`\s*$")
16
+ _LIST_FIELD_HEADER = re.compile(r"^- `([^`]+)`:\s*$")
17
+
18
+ _REQUIRED_FIELDS: tuple[str, ...] = ("adapter_id", "domain_type", "applies_to")
19
+ _REQUIRED_HINT_FIELDS: tuple[str, ...] = (
20
+ "context_priority_rules",
21
+ "test_or_validation_hints",
22
+ )
23
+
24
+
25
+ def load_adapter_profiles(root: Path) -> list[AdapterProfile]:
26
+ """Load adapter profiles from docs/runtime/adapter_profiles.md."""
27
+ config_path = root / ADAPTER_PROFILES_PATH
28
+ if not config_path.exists():
29
+ raise MissingPathError(
30
+ f"Adapter profile config not found: {ADAPTER_PROFILES_PATH}",
31
+ detail=str(config_path),
32
+ )
33
+
34
+ text = config_path.read_text(encoding="utf-8")
35
+ profiles = parse_adapter_profiles_markdown(text)
36
+ for profile in profiles:
37
+ profile.capabilities = GraphAwareAdapterCapability(root, profile)
38
+ return profiles
39
+
40
+
41
+ def parse_adapter_profiles_markdown(text: str) -> list[AdapterProfile]:
42
+ """Parse adapter profiles from adapter_profiles runtime markdown."""
43
+ lines = text.splitlines()
44
+ profile_start = _find_profile_section(lines)
45
+ if profile_start is None:
46
+ raise ConfigError(
47
+ "Adapter profile config is incomplete",
48
+ detail="Missing '## 5. Adapter Profiles' section",
49
+ )
50
+
51
+ profiles: list[AdapterProfile] = []
52
+ index = profile_start + 1
53
+ while index < len(lines):
54
+ stripped = lines[index].strip()
55
+ if stripped.startswith("## "):
56
+ break
57
+
58
+ profile_match = _PROFILE_HEADER.match(stripped)
59
+ if not profile_match:
60
+ index += 1
61
+ continue
62
+
63
+ section_name = profile_match.group(1)
64
+ fields, index = _parse_profile_fields(lines, index + 1)
65
+ profiles.append(_build_profile(section_name, fields))
66
+
67
+ if not profiles:
68
+ raise ConfigError(
69
+ "Adapter profile config is incomplete",
70
+ detail="No adapter profiles found in '## 5. Adapter Profiles' section",
71
+ )
72
+
73
+ return profiles
74
+
75
+
76
+ def _find_profile_section(lines: list[str]) -> int | None:
77
+ """Return line index for adapter profile section header."""
78
+ for index, line in enumerate(lines):
79
+ if line.strip() == _PROFILE_SECTION_HEADER:
80
+ return index
81
+ return None
82
+
83
+
84
+ def _parse_profile_fields(
85
+ lines: list[str],
86
+ start_index: int,
87
+ ) -> tuple[dict[str, object], int]:
88
+ """Parse one adapter profile field block under a ### profile header."""
89
+ fields: dict[str, object] = {}
90
+ index = start_index
91
+ while index < len(lines):
92
+ stripped = lines[index].strip()
93
+ if _PROFILE_HEADER.match(stripped) or stripped.startswith("## "):
94
+ break
95
+
96
+ inline_match = _INLINE_FIELD.match(stripped)
97
+ if inline_match:
98
+ fields[inline_match.group(1)] = inline_match.group(2).strip()
99
+ index += 1
100
+ continue
101
+
102
+ list_header_match = _LIST_FIELD_HEADER.match(stripped)
103
+ if list_header_match:
104
+ items, index = _consume_bullets(lines, index + 1)
105
+ fields[list_header_match.group(1)] = items
106
+ continue
107
+
108
+ index += 1
109
+
110
+ return fields, index
111
+
112
+
113
+ def _consume_bullets(lines: list[str], start_index: int) -> tuple[list[str], int]:
114
+ """Collect contiguous markdown bullet items starting at start_index."""
115
+ items: list[str] = []
116
+ index = start_index
117
+ while index < len(lines):
118
+ stripped = lines[index].strip()
119
+ if not stripped:
120
+ index += 1
121
+ continue
122
+ if _INLINE_FIELD.match(stripped) or _LIST_FIELD_HEADER.match(stripped):
123
+ break
124
+ if stripped.startswith("- "):
125
+ items.append(_strip_backticks(stripped[2:].strip()))
126
+ index += 1
127
+ continue
128
+ break
129
+ return items, index
130
+
131
+
132
+ def _build_profile(section_name: str, fields: dict[str, object]) -> AdapterProfile:
133
+ """Validate parsed fields and convert them into an AdapterProfile."""
134
+ missing_fields = [name for name in _REQUIRED_FIELDS if not fields.get(name)]
135
+ if missing_fields:
136
+ missing = ", ".join(missing_fields)
137
+ raise ConfigError(
138
+ "Adapter profile config is incomplete",
139
+ detail=f"Missing required field(s) in '{section_name}': {missing}",
140
+ )
141
+
142
+ adapter_id = _as_text(fields.get("adapter_id"))
143
+ if adapter_id != section_name:
144
+ raise ConfigError(
145
+ "Adapter profile config is invalid",
146
+ detail=(
147
+ f"Section '{section_name}' has adapter_id '{adapter_id}'. "
148
+ "Header and adapter_id must match."
149
+ ),
150
+ )
151
+
152
+ domain_type = _as_text(fields.get("domain_type"))
153
+ applies_to = _as_list(fields.get("applies_to"))
154
+ if not applies_to:
155
+ raise ConfigError(
156
+ "Adapter profile config is incomplete",
157
+ detail=f"Field 'applies_to' must include at least one item in '{section_name}'",
158
+ )
159
+
160
+ hint_presence = any(_as_list(fields.get(name)) for name in _REQUIRED_HINT_FIELDS)
161
+ if not hint_presence:
162
+ required = ", ".join(_REQUIRED_HINT_FIELDS)
163
+ raise ConfigError(
164
+ "Adapter profile config is incomplete",
165
+ detail=(
166
+ f"Adapter '{section_name}' must include at least one hint section: {required}"
167
+ ),
168
+ )
169
+
170
+ return AdapterProfile(
171
+ adapter_id=adapter_id,
172
+ domain_type=domain_type,
173
+ applies_to=applies_to,
174
+ relevant_file_patterns=_as_list(fields.get("relevant_file_patterns")),
175
+ ignore_file_patterns=_as_list(fields.get("ignore_file_patterns")),
176
+ build_or_run_hints=_as_list(fields.get("build_or_run_hints")),
177
+ test_or_validation_hints=_as_list(fields.get("test_or_validation_hints")),
178
+ review_focus_hints=_as_list(fields.get("review_focus_hints")),
179
+ context_priority_rules=_as_list(fields.get("context_priority_rules")),
180
+ default_model_bias=_as_list(fields.get("default_model_bias")),
181
+ )
182
+
183
+
184
+ def _as_text(value: object) -> str:
185
+ """Normalize a parsed scalar field into text."""
186
+ if value is None:
187
+ return ""
188
+ if isinstance(value, list):
189
+ return value[0] if value else ""
190
+ return _strip_backticks(str(value).strip())
191
+
192
+
193
+ def _as_list(value: object) -> list[str]:
194
+ """Normalize a parsed field into a list of strings."""
195
+ if value is None:
196
+ return []
197
+ if isinstance(value, list):
198
+ return [_strip_backticks(str(item).strip()) for item in value if str(item).strip()]
199
+ text = _strip_backticks(str(value).strip())
200
+ return [text] if text else []
201
+
202
+
203
+ def _strip_backticks(value: str) -> str:
204
+ """Remove one layer of surrounding markdown backticks."""
205
+ if len(value) >= 2 and value.startswith("`") and value.endswith("`"):
206
+ return value[1:-1]
207
+ return value
@@ -0,0 +1,199 @@
1
+ """Adapter capability implementations backed by structural graph outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from pathlib import Path
7
+
8
+ from grain.domain.adapters import (
9
+ AdapterProfile,
10
+ ArtifactPattern,
11
+ ContextHint,
12
+ FollowupSuggestion,
13
+ ImpactSignal,
14
+ ScopeSignal,
15
+ ValidationRequirement,
16
+ )
17
+ from grain.services.graph_service import build_knowledge_graph
18
+
19
+ _GRAPH_HOPS_FOR_IMPACT = 2
20
+ _CANDIDATE_LIMIT = 80
21
+
22
+
23
+ class GraphAwareAdapterCapability:
24
+ """Adapter capability implementation using graph outputs when available.
25
+
26
+ Falls back to deterministic static profile signals when graph construction
27
+ does not provide useful data.
28
+ """
29
+
30
+ def __init__(self, root: Path, profile: AdapterProfile):
31
+ self.root = root
32
+ self.profile = profile
33
+
34
+ def detect_scope(self, scope_description: str) -> ScopeSignal:
35
+ candidates = _candidate_files(self.root, self.profile)
36
+ if not candidates:
37
+ return _static_scope_signal(self.profile)
38
+
39
+ result, artifact = build_knowledge_graph(
40
+ self.root,
41
+ candidates,
42
+ produced_by=f"adapter_capability.detect_scope.{self.profile.adapter_id}",
43
+ )
44
+ if not result.ok or artifact is None:
45
+ return _static_scope_signal(self.profile)
46
+
47
+ file_paths = _graph_file_paths(artifact)
48
+ if not file_paths:
49
+ return _static_scope_signal(self.profile)
50
+
51
+ relevant_areas = sorted(
52
+ {self.profile.domain_type, *self.profile.applies_to, *[node.kind for node in artifact.nodes]}
53
+ )
54
+ return ScopeSignal(
55
+ file_patterns=file_paths,
56
+ relevant_areas=relevant_areas,
57
+ )
58
+
59
+ def collect_context(self, task_description: str) -> ContextHint:
60
+ return ContextHint(
61
+ file_patterns=self.profile.relevant_file_patterns,
62
+ priority_rules=self.profile.context_priority_rules,
63
+ )
64
+
65
+ def analyze_impact(self, touched_files: list[str]) -> ImpactSignal:
66
+ candidates = _candidate_files(self.root, self.profile)
67
+ graph_sources = _dedupe_preserve_order([*touched_files, *candidates])
68
+ if not graph_sources:
69
+ return ImpactSignal(affected_files=[], downstream_areas=[self.profile.domain_type])
70
+
71
+ result, artifact = build_knowledge_graph(
72
+ self.root,
73
+ graph_sources,
74
+ produced_by=f"adapter_capability.analyze_impact.{self.profile.adapter_id}",
75
+ )
76
+ if not result.ok or artifact is None:
77
+ return ImpactSignal(
78
+ affected_files=_dedupe_preserve_order(touched_files),
79
+ downstream_areas=[self.profile.domain_type],
80
+ )
81
+
82
+ adjacency = _graph_adjacency(artifact)
83
+ file_node_to_path = {
84
+ node.id: str(node.metadata.get("path", ""))
85
+ for node in artifact.nodes
86
+ if node.kind in {"file", "canonical_doc", "runtime_doc"}
87
+ and isinstance(node.metadata.get("path", ""), str)
88
+ and str(node.metadata.get("path", ""))
89
+ }
90
+ start_nodes = [
91
+ f"file::{path}"
92
+ for path in touched_files
93
+ if f"file::{path}" in file_node_to_path
94
+ ]
95
+ if not start_nodes:
96
+ return ImpactSignal(
97
+ affected_files=_dedupe_preserve_order(touched_files),
98
+ downstream_areas=[self.profile.domain_type],
99
+ )
100
+
101
+ impacted_nodes = _reachable_within_hops(adjacency, start_nodes, _GRAPH_HOPS_FOR_IMPACT)
102
+ impacted_paths = _dedupe_preserve_order(
103
+ [file_node_to_path[node_id] for node_id in impacted_nodes if node_id in file_node_to_path]
104
+ )
105
+ downstream_areas = sorted(
106
+ {self.profile.domain_type, *[node.kind for node in artifact.nodes if node.id in impacted_nodes]}
107
+ )
108
+ return ImpactSignal(
109
+ affected_files=impacted_paths,
110
+ downstream_areas=downstream_areas,
111
+ )
112
+
113
+ def validate_changes(self, task_description: str) -> ValidationRequirement:
114
+ return ValidationRequirement(requirements=self.profile.test_or_validation_hints)
115
+
116
+ def export_artifacts(self, task_description: str) -> ArtifactPattern:
117
+ patterns = [f"adapter:{self.profile.adapter_id}", *self.profile.relevant_file_patterns]
118
+ return ArtifactPattern(patterns=_dedupe_preserve_order(patterns))
119
+
120
+ def suggest_followups(self, execution_outcome: str) -> FollowupSuggestion:
121
+ followups = []
122
+ if self.profile.review_focus_hints:
123
+ followups.append(f"review-focus:{self.profile.review_focus_hints[0]}")
124
+ if self.profile.test_or_validation_hints:
125
+ followups.append(f"validate:{self.profile.test_or_validation_hints[0]}")
126
+ return FollowupSuggestion(followups=followups)
127
+
128
+
129
+ def _candidate_files(root: Path, profile: AdapterProfile) -> list[str]:
130
+ if not profile.relevant_file_patterns:
131
+ return []
132
+ candidates: list[str] = []
133
+ for pattern in profile.relevant_file_patterns:
134
+ for matched in root.glob(pattern):
135
+ if not matched.is_file():
136
+ continue
137
+ rel = matched.relative_to(root).as_posix()
138
+ if any(fnmatch.fnmatch(rel, pat) for pat in profile.ignore_file_patterns):
139
+ continue
140
+ candidates.append(rel)
141
+ return _dedupe_preserve_order(candidates)[:_CANDIDATE_LIMIT]
142
+
143
+
144
+ def _static_scope_signal(profile: AdapterProfile) -> ScopeSignal:
145
+ return ScopeSignal(
146
+ file_patterns=profile.relevant_file_patterns,
147
+ relevant_areas=sorted({profile.domain_type, *profile.applies_to}),
148
+ )
149
+
150
+
151
+ def _graph_file_paths(artifact) -> list[str]:
152
+ paths: list[str] = []
153
+ for node in artifact.nodes:
154
+ if node.kind not in {"file", "canonical_doc", "runtime_doc"}:
155
+ continue
156
+ path = node.metadata.get("path", "")
157
+ if isinstance(path, str) and path:
158
+ paths.append(path)
159
+ return _dedupe_preserve_order(paths)
160
+
161
+
162
+ def _graph_adjacency(artifact) -> dict[str, set[str]]:
163
+ adjacency: dict[str, set[str]] = {}
164
+ for edge in artifact.edges:
165
+ adjacency.setdefault(edge.source, set()).add(edge.target)
166
+ adjacency.setdefault(edge.target, set()).add(edge.source)
167
+ return adjacency
168
+
169
+
170
+ def _reachable_within_hops(
171
+ adjacency: dict[str, set[str]],
172
+ start_nodes: list[str],
173
+ max_hops: int,
174
+ ) -> set[str]:
175
+ seen: set[str] = set(start_nodes)
176
+ frontier: set[str] = set(start_nodes)
177
+ for _ in range(max_hops):
178
+ next_frontier: set[str] = set()
179
+ for node in frontier:
180
+ for neighbor in adjacency.get(node, set()):
181
+ if neighbor in seen:
182
+ continue
183
+ seen.add(neighbor)
184
+ next_frontier.add(neighbor)
185
+ if not next_frontier:
186
+ break
187
+ frontier = next_frontier
188
+ return seen
189
+
190
+
191
+ def _dedupe_preserve_order(items: list[str]) -> list[str]:
192
+ seen: set[str] = set()
193
+ out: list[str] = []
194
+ for item in items:
195
+ if item in seen:
196
+ continue
197
+ seen.add(item)
198
+ out.append(item)
199
+ return out
@@ -0,0 +1,104 @@
1
+ """Export adapter for context bundle outputs."""
2
+
3
+ from pathlib import Path
4
+
5
+ from grain.domain.context import ContextBundle
6
+ from grain.services.docs_extractor import DocsExtractor
7
+ from grain.services.pdf_extractor import PdfExtractor
8
+ from grain.services.spreadsheet_extractor import SpreadsheetExtractor
9
+
10
+
11
+ def render_context_markdown_export(root: Path, bundle: ContextBundle) -> str:
12
+ """Render a single assembled markdown export for a ContextBundle."""
13
+ generated_at = bundle.export_metadata.get("generated_at", "")
14
+ sources = bundle.export_metadata.get("sources", [])
15
+ adapter_context = bundle.export_metadata.get("adapter_context", {})
16
+ primary_adapter = str(adapter_context.get("primary_adapter", "none"))
17
+ review_hints = adapter_context.get("review_focus_hints", [])
18
+ validation_hints = adapter_context.get("test_or_validation_hints", [])
19
+
20
+ lines: list[str] = [
21
+ "# Context Export",
22
+ "",
23
+ f"Task ID: {bundle.task_id}",
24
+ f"Generated At: {generated_at}",
25
+ f"Primary Adapter: {primary_adapter}",
26
+ ]
27
+
28
+ if primary_adapter != "none":
29
+ lines.extend(
30
+ [
31
+ "",
32
+ "## Adapter Hints",
33
+ "",
34
+ "### Review Focus Hints",
35
+ ]
36
+ )
37
+ if review_hints:
38
+ for hint in review_hints:
39
+ lines.append(f"- {hint}")
40
+ else:
41
+ lines.append("- (none)")
42
+ lines.extend(["", "### Test/Validation Hints"])
43
+ if validation_hints:
44
+ for hint in validation_hints:
45
+ lines.append(f"- {hint}")
46
+ else:
47
+ lines.append("- (none)")
48
+
49
+ lines.extend(
50
+ [
51
+ "",
52
+ "## Sources",
53
+ ]
54
+ )
55
+ for source in sources:
56
+ lines.append(f"- `{source}`")
57
+
58
+ for source in sources:
59
+ source_path = root / source
60
+ lines.append("")
61
+ lines.append(f"## Source: `{source}`")
62
+ if not source_path.exists():
63
+ lines.append("")
64
+ lines.append("_Missing source file on disk._")
65
+ continue
66
+ lines.append("")
67
+ lines.append("```md")
68
+ lines.append(_render_source_content(source_path))
69
+ lines.append("```")
70
+
71
+ return "\n".join(lines).strip() + "\n"
72
+
73
+
74
+ def write_context_markdown_export(
75
+ root: Path,
76
+ bundle: ContextBundle,
77
+ output_path: Path | None = None,
78
+ ) -> Path:
79
+ """Write markdown export to disk and return the output path."""
80
+ if output_path is None:
81
+ resolved = bundle.packet_dir / "context_export.md"
82
+ elif output_path.is_absolute():
83
+ resolved = output_path
84
+ else:
85
+ resolved = root / output_path
86
+
87
+ resolved.parent.mkdir(parents=True, exist_ok=True)
88
+ content = render_context_markdown_export(root, bundle)
89
+ resolved.write_text(content, encoding="utf-8")
90
+ return resolved
91
+
92
+
93
+ def _render_source_content(source_path: Path) -> str:
94
+ suffix = source_path.suffix.lower()
95
+ if suffix in {".csv", ".xls", ".xlsx"}:
96
+ return SpreadsheetExtractor().extract(source_path)
97
+ if suffix == ".docx":
98
+ return DocsExtractor().extract(source_path)
99
+ if suffix == ".pdf":
100
+ return PdfExtractor().extract(source_path)
101
+ try:
102
+ return source_path.read_text(encoding="utf-8")
103
+ except UnicodeDecodeError:
104
+ return f"[context_export: binary or non-utf8 source {source_path.name}]"
@@ -0,0 +1,32 @@
1
+ from pathlib import Path
2
+
3
+ # The presence of this file identifies a valid Forge repository root.
4
+ _REPO_MARKER = "docs/runtime/PROJECT_RULES.md"
5
+
6
+
7
+ def find_repo_root(start: Path) -> Path:
8
+ """Walk upward from `start` until the repository root marker is found.
9
+
10
+ Raises FileNotFoundError if no marker is found before reaching the filesystem root.
11
+ """
12
+ current = start.resolve()
13
+ while True:
14
+ if (current / _REPO_MARKER).exists():
15
+ return current
16
+ parent = current.parent
17
+ if parent == current:
18
+ raise FileNotFoundError(
19
+ f"Could not find repository root. "
20
+ f"No '{_REPO_MARKER}' found in '{start}' or any parent directory."
21
+ )
22
+ current = parent
23
+
24
+
25
+ def resolve_repo_root(repo_option: str | None = None) -> Path:
26
+ """Return the repository root path.
27
+
28
+ Uses `repo_option` directly if provided, otherwise auto-detects from cwd.
29
+ """
30
+ if repo_option is not None:
31
+ return Path(repo_option).resolve()
32
+ return find_repo_root(Path.cwd())
@@ -0,0 +1,36 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+ from grain.domain.errors import ConfigError, MissingPathError
6
+
7
+ MANIFEST_PATH = "docs/runtime/docs_manifest.yaml"
8
+
9
+
10
+ def load_manifest(root: Path) -> dict:
11
+ """Load and parse docs_manifest.yaml from the repository root.
12
+
13
+ Args:
14
+ root: Repository root path.
15
+
16
+ Returns:
17
+ Parsed manifest contents as a dict.
18
+
19
+ Raises:
20
+ MissingPathError: If the manifest file does not exist.
21
+ ConfigError: If the file exists but contains invalid YAML.
22
+ """
23
+ manifest_file = root / MANIFEST_PATH
24
+ if not manifest_file.exists():
25
+ raise MissingPathError(
26
+ f"Manifest not found: {MANIFEST_PATH}",
27
+ detail=str(manifest_file),
28
+ )
29
+ try:
30
+ with manifest_file.open("r", encoding="utf-8") as f:
31
+ return yaml.safe_load(f) or {}
32
+ except yaml.YAMLError as exc:
33
+ raise ConfigError(
34
+ f"Manifest is not valid YAML: {MANIFEST_PATH}",
35
+ detail=str(exc),
36
+ ) from exc