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.
- grain/__init__.py +0 -0
- grain/adapters/__init__.py +0 -0
- grain/adapters/adapter_config.py +207 -0
- grain/adapters/capabilities.py +199 -0
- grain/adapters/export.py +104 -0
- grain/adapters/filesystem.py +32 -0
- grain/adapters/manifest.py +36 -0
- grain/adapters/model_config.py +205 -0
- grain/cli/__init__.py +81 -0
- grain/cli/adapter.py +115 -0
- grain/cli/context.py +282 -0
- grain/cli/docs.py +89 -0
- grain/cli/error_handler.py +39 -0
- grain/cli/init.py +45 -0
- grain/cli/model.py +202 -0
- grain/cli/onboard.py +61 -0
- grain/cli/orchestrate.py +204 -0
- grain/cli/output.py +54 -0
- grain/cli/phase.py +67 -0
- grain/cli/prompt.py +63 -0
- grain/cli/review.py +183 -0
- grain/cli/task.py +303 -0
- grain/cli/workflow.py +188 -0
- grain/domain/__init__.py +33 -0
- grain/domain/adapters.py +178 -0
- grain/domain/context.py +147 -0
- grain/domain/documents.py +81 -0
- grain/domain/errors.py +42 -0
- grain/domain/onboard.py +12 -0
- grain/domain/orchestrator.py +69 -0
- grain/domain/packets.py +111 -0
- grain/domain/routing.py +221 -0
- grain/domain/scan_result.py +19 -0
- grain/domain/workflow.py +27 -0
- grain/domain/workflow_loop.py +73 -0
- grain/services/__init__.py +0 -0
- grain/services/codebase_scanner.py +191 -0
- grain/services/context_service.py +557 -0
- grain/services/docs_extractor.py +82 -0
- grain/services/docs_service.py +167 -0
- grain/services/graph_service.py +524 -0
- grain/services/handoff_service.py +414 -0
- grain/services/init_service.py +223 -0
- grain/services/model_service.py +82 -0
- grain/services/onboard_doc_generator.py +134 -0
- grain/services/onboard_service.py +59 -0
- grain/services/orchestration_service.py +460 -0
- grain/services/pdf_extractor.py +41 -0
- grain/services/prompt_service.py +73 -0
- grain/services/review_service.py +238 -0
- grain/services/spreadsheet_extractor.py +90 -0
- grain/services/structural_intelligence_service.py +494 -0
- grain/services/task_prepare_service.py +59 -0
- grain/services/task_service.py +302 -0
- grain/services/workflow_loop_config_service.py +168 -0
- grain/services/workflow_loop_service.py +525 -0
- grain/services/workflow_run_service.py +258 -0
- grain/services/workflow_service.py +367 -0
- grain/templates/__init__.py +0 -0
- grain/templates/loader.py +18 -0
- grain/validators/__init__.py +3 -0
- grain/validators/authority_validator.py +58 -0
- grain/validators/doc_existence_validator.py +30 -0
- grain/validators/manifest_validator.py +102 -0
- grain/validators/orchestrator_validator.py +58 -0
- grain/validators/packet_validator.py +108 -0
- grain_kit-0.1.0.dist-info/METADATA +580 -0
- grain_kit-0.1.0.dist-info/RECORD +71 -0
- grain_kit-0.1.0.dist-info/WHEEL +5 -0
- grain_kit-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
grain/adapters/export.py
ADDED
|
@@ -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
|