opencode-arch 1.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.
- opencode_arch/__init__.py +3 -0
- opencode_arch/artifacts/__init__.py +48 -0
- opencode_arch/artifacts/context.py +451 -0
- opencode_arch/artifacts/diagrams.py +451 -0
- opencode_arch/artifacts/selector.py +331 -0
- opencode_arch/artifacts/templates.py +444 -0
- opencode_arch/cli/__init__.py +1 -0
- opencode_arch/cli/bench.py +25 -0
- opencode_arch/cli/calibrate.py +208 -0
- opencode_arch/cli/confidence.py +66 -0
- opencode_arch/cli/docs.py +333 -0
- opencode_arch/cli/docs_validator.py +295 -0
- opencode_arch/cli/export_data.py +133 -0
- opencode_arch/cli/extract.py +93 -0
- opencode_arch/cli/gap_analyzer.py +107 -0
- opencode_arch/cli/generate.py +68 -0
- opencode_arch/cli/launch.py +264 -0
- opencode_arch/cli/main.py +360 -0
- opencode_arch/cli/metrics.py +186 -0
- opencode_arch/cli/prompts.py +20 -0
- opencode_arch/cli/regen_loop.py +1028 -0
- opencode_arch/context/__init__.py +29 -0
- opencode_arch/context/formatter.py +492 -0
- opencode_arch/context/pipeline_bridge.py +201 -0
- opencode_arch/extract/__init__.py +8 -0
- opencode_arch/extract/constraint_detector.py +398 -0
- opencode_arch/extract/from_artifacts.py +837 -0
- opencode_arch/extract/from_code.py +646 -0
- opencode_arch/extract/route_detector.py +400 -0
- opencode_arch/extract/table_parser.py +177 -0
- opencode_arch/learning/__init__.py +19 -0
- opencode_arch/learning/adapter.py +157 -0
- opencode_arch/learning/assessor.py +170 -0
- opencode_arch/learning/classifier.py +144 -0
- opencode_arch/learning/lessons.py +139 -0
- opencode_arch/learning/maintainer.py +281 -0
- opencode_arch/learning/patterns.py +51 -0
- opencode_arch/mcp/__init__.py +1 -0
- opencode_arch/mcp/__main__.py +8 -0
- opencode_arch/mcp/server.py +183 -0
- opencode_arch/mcp/tools/__init__.py +1 -0
- opencode_arch/mcp/tools/check.py +159 -0
- opencode_arch/mcp/tools/extract.py +107 -0
- opencode_arch/mcp/tools/feedback.py +65 -0
- opencode_arch/mcp/tools/generate.py +104 -0
- opencode_arch/mcp/tools/group.py +62 -0
- opencode_arch/mcp/tools/ingest.py +101 -0
- opencode_arch/mcp/tools/require.py +77 -0
- opencode_arch/mcp/tools/scan.py +53 -0
- opencode_arch/mcp/tools/slice.py +235 -0
- opencode_arch/mcp/tools/validate.py +59 -0
- opencode_arch/prompts/__init__.py +1 -0
- opencode_arch/prompts/regen.py +36 -0
- opencode_arch/runner/__init__.py +5 -0
- opencode_arch/runner/base.py +21 -0
- opencode_arch/runner/opencode.py +66 -0
- opencode_arch/telemetry/__init__.py +6 -0
- opencode_arch/telemetry/collector.py +40 -0
- opencode_arch/telemetry/recorder.py +12 -0
- opencode_arch/telemetry/store.py +537 -0
- opencode_arch-1.0.0.dist-info/METADATA +247 -0
- opencode_arch-1.0.0.dist-info/RECORD +65 -0
- opencode_arch-1.0.0.dist-info/WHEEL +4 -0
- opencode_arch-1.0.0.dist-info/entry_points.txt +2 -0
- opencode_arch-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Export training corpus from .architecture/ artifacts.
|
|
2
|
+
|
|
3
|
+
Collects (model, manifest, metrics) triples from one or more repos
|
|
4
|
+
and writes them as JSONL for LLM training.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_export_data(
|
|
14
|
+
repos: list[str],
|
|
15
|
+
output: str = "corpus.jsonl",
|
|
16
|
+
include_telemetry: bool = False,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Export .architecture/ artifacts from repos to JSONL.
|
|
19
|
+
|
|
20
|
+
Each line is a JSON object with:
|
|
21
|
+
- repo: repository name
|
|
22
|
+
- model_yaml: raw YAML content of .architecture-model.yaml
|
|
23
|
+
- manifest: parsed manifest.json dict
|
|
24
|
+
- metrics: parsed metrics.json dict
|
|
25
|
+
- telemetry_records: (optional) list of telemetry DB records
|
|
26
|
+
"""
|
|
27
|
+
records = []
|
|
28
|
+
|
|
29
|
+
for repo_str in repos:
|
|
30
|
+
repo = Path(repo_str).resolve()
|
|
31
|
+
if not repo.is_dir():
|
|
32
|
+
print(f"Warning: {repo} is not a directory, skipping", file=sys.stderr)
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
model_path = repo / ".architecture-model.yaml"
|
|
36
|
+
arch_dir = repo / ".architecture"
|
|
37
|
+
|
|
38
|
+
if not model_path.exists() and not arch_dir.exists():
|
|
39
|
+
print(f"Warning: No .architecture-model.yaml or .architecture/ in {repo}, skipping",
|
|
40
|
+
file=sys.stderr)
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
record: dict = {"repo": repo.name}
|
|
44
|
+
|
|
45
|
+
# Model YAML (raw text for training)
|
|
46
|
+
if model_path.exists():
|
|
47
|
+
record["model_yaml"] = model_path.read_text()
|
|
48
|
+
|
|
49
|
+
# Manifest
|
|
50
|
+
manifest_path = arch_dir / "manifest.json" if arch_dir.exists() else None
|
|
51
|
+
if manifest_path and manifest_path.exists():
|
|
52
|
+
record["manifest"] = json.loads(manifest_path.read_text())
|
|
53
|
+
|
|
54
|
+
# Metrics
|
|
55
|
+
metrics_path = arch_dir / "metrics.json" if arch_dir.exists() else None
|
|
56
|
+
if metrics_path and metrics_path.exists():
|
|
57
|
+
record["metrics"] = json.loads(metrics_path.read_text())
|
|
58
|
+
|
|
59
|
+
# Sub-block artifacts
|
|
60
|
+
if arch_dir and arch_dir.exists():
|
|
61
|
+
blocks = {}
|
|
62
|
+
for block_dir in sorted(arch_dir.iterdir()):
|
|
63
|
+
if block_dir.is_dir():
|
|
64
|
+
block = {}
|
|
65
|
+
block_model = block_dir / ".architecture-model.yaml"
|
|
66
|
+
if block_model.exists():
|
|
67
|
+
block["model_yaml"] = block_model.read_text()
|
|
68
|
+
block_manifest = block_dir / "manifest.json"
|
|
69
|
+
if block_manifest.exists():
|
|
70
|
+
block["manifest"] = json.loads(block_manifest.read_text())
|
|
71
|
+
block_metrics = block_dir / "metrics.json"
|
|
72
|
+
if block_metrics.exists():
|
|
73
|
+
block["metrics"] = json.loads(block_metrics.read_text())
|
|
74
|
+
if block:
|
|
75
|
+
blocks[block_dir.name] = block
|
|
76
|
+
if blocks:
|
|
77
|
+
record["blocks"] = blocks
|
|
78
|
+
|
|
79
|
+
# Requirements
|
|
80
|
+
req_path = arch_dir / "requirements.yaml" if arch_dir.exists() else None
|
|
81
|
+
if req_path and req_path.exists():
|
|
82
|
+
try:
|
|
83
|
+
import yaml
|
|
84
|
+
req_data = yaml.safe_load(req_path.read_text()) or {}
|
|
85
|
+
record["requirements"] = req_data.get("requirements", [])
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
# Feedback
|
|
90
|
+
feedback_path = arch_dir / "feedback.jsonl" if arch_dir.exists() else None
|
|
91
|
+
if feedback_path and feedback_path.exists():
|
|
92
|
+
try:
|
|
93
|
+
feedback_entries = []
|
|
94
|
+
for line in feedback_path.read_text().splitlines():
|
|
95
|
+
if line.strip():
|
|
96
|
+
feedback_entries.append(json.loads(line))
|
|
97
|
+
if feedback_entries:
|
|
98
|
+
record["feedback"] = feedback_entries
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
# Telemetry records (from SQLite DB)
|
|
103
|
+
if include_telemetry:
|
|
104
|
+
try:
|
|
105
|
+
from opencode_arch.telemetry.store import TelemetryStore
|
|
106
|
+
store = TelemetryStore()
|
|
107
|
+
all_records = store.query(repo=repo.name)
|
|
108
|
+
if all_records:
|
|
109
|
+
record["telemetry_records"] = [
|
|
110
|
+
r._asdict() if hasattr(r, '_asdict') else r
|
|
111
|
+
for r in all_records
|
|
112
|
+
]
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
records.append(record)
|
|
117
|
+
|
|
118
|
+
if not records:
|
|
119
|
+
print("No data found to export.", file=sys.stderr)
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
# Write JSONL
|
|
123
|
+
output_path = Path(output)
|
|
124
|
+
with output_path.open("w") as f:
|
|
125
|
+
for record in records:
|
|
126
|
+
f.write(json.dumps(record, default=str) + "\n")
|
|
127
|
+
|
|
128
|
+
print(f"Exported {len(records)} repo(s) to {output_path}")
|
|
129
|
+
total_size = output_path.stat().st_size
|
|
130
|
+
if total_size > 1024 * 1024:
|
|
131
|
+
print(f" Size: {total_size / 1024 / 1024:.1f} MB")
|
|
132
|
+
else:
|
|
133
|
+
print(f" Size: {total_size / 1024:.1f} KB")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Extract command - full architecture extraction loop."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from opencode_arch.runner.base import RunnerBackend
|
|
10
|
+
from opencode_arch.cli.prompts import EXTRACT_PROMPT
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def run_extract(
|
|
14
|
+
repo_path: str,
|
|
15
|
+
runner: RunnerBackend,
|
|
16
|
+
budget: int = 4000,
|
|
17
|
+
focus: str = "all",
|
|
18
|
+
target_score: int = 80,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
"""Run the full extraction loop.
|
|
21
|
+
|
|
22
|
+
1. Validates repo exists
|
|
23
|
+
2. Calls runner with extraction prompt
|
|
24
|
+
3. Parses YAML from output
|
|
25
|
+
4. Validates and stores via tool APIs
|
|
26
|
+
5. Returns metrics
|
|
27
|
+
"""
|
|
28
|
+
path = Path(repo_path)
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return {"success": False, "error": f"Path does not exist: {repo_path}"}
|
|
31
|
+
|
|
32
|
+
start_time = time.time()
|
|
33
|
+
|
|
34
|
+
prompt = EXTRACT_PROMPT.format(
|
|
35
|
+
repo_path=str(path.resolve()),
|
|
36
|
+
focus=focus,
|
|
37
|
+
budget=budget,
|
|
38
|
+
target_score=target_score,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
result = await runner.run(prompt=prompt, repo_path=str(path))
|
|
42
|
+
elapsed = time.time() - start_time
|
|
43
|
+
|
|
44
|
+
if not result.success:
|
|
45
|
+
return {
|
|
46
|
+
"success": False,
|
|
47
|
+
"error": f"Runner failed: {result.output[:500]}",
|
|
48
|
+
"time_seconds": elapsed,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
yaml_content = _extract_yaml_from_output(result.output)
|
|
52
|
+
if not yaml_content:
|
|
53
|
+
return {
|
|
54
|
+
"success": False,
|
|
55
|
+
"error": "No YAML model found in agent output",
|
|
56
|
+
"time_seconds": elapsed,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
from opencode_arch.mcp.tools.extract import store_extraction
|
|
60
|
+
store_result = await store_extraction(
|
|
61
|
+
repo_path=str(path),
|
|
62
|
+
model_yaml=yaml_content,
|
|
63
|
+
context_tokens=budget,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"success": store_result.get("stored", False),
|
|
68
|
+
"score": store_result.get("score", 0),
|
|
69
|
+
"tokens_used": budget,
|
|
70
|
+
"time_seconds": elapsed,
|
|
71
|
+
"iterations": 1,
|
|
72
|
+
"issues": store_result.get("issues", []),
|
|
73
|
+
"path": store_result.get("path", ""),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_yaml_from_output(output: str) -> str | None:
|
|
78
|
+
"""Extract YAML content from agent output (between ```yaml fences)."""
|
|
79
|
+
match = re.search(r"```ya?ml\s*\n(.*?)```", output, re.DOTALL)
|
|
80
|
+
if match:
|
|
81
|
+
return match.group(1).strip()
|
|
82
|
+
|
|
83
|
+
match = re.search(r"```\s*\n(.*?)```", output, re.DOTALL)
|
|
84
|
+
if match:
|
|
85
|
+
content = match.group(1).strip()
|
|
86
|
+
if "meta:" in content or "entities:" in content:
|
|
87
|
+
return content
|
|
88
|
+
|
|
89
|
+
match = re.search(r"(meta:\s*\n.*?)(?:\n\n|\Z)", output, re.DOTALL)
|
|
90
|
+
if match:
|
|
91
|
+
return match.group(1).strip()
|
|
92
|
+
|
|
93
|
+
return None
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Gap analyzer — maps test failure output to actionable feedback for the next iteration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Common failure patterns and their interpretations
|
|
8
|
+
_PATTERNS: list[tuple[re.Pattern, str]] = [
|
|
9
|
+
(re.compile(r"NameError: name '(\w+)' is not defined"),
|
|
10
|
+
"Missing symbol: {0}"),
|
|
11
|
+
(re.compile(r"ImportError: cannot import name '(\w+)' from '(\w+)'"),
|
|
12
|
+
"Missing export: '{0}' not available in module '{1}'"),
|
|
13
|
+
(re.compile(r"ModuleNotFoundError: No module named '(\S+)'"),
|
|
14
|
+
"Missing module: {0}"),
|
|
15
|
+
(re.compile(r"AttributeError: (?:type object |)'(\w+)' (?:object )?has no attribute '(\w+)'"),
|
|
16
|
+
"Missing attribute '{1}' on '{0}'"),
|
|
17
|
+
(re.compile(r"AttributeError: module '(\w+)' has no attribute '(\w+)'"),
|
|
18
|
+
"Missing attribute '{1}' on module '{0}'"),
|
|
19
|
+
(re.compile(r"AssertionError: (.+?) != (.+)"),
|
|
20
|
+
"Wrong value: got {0}, expected {1}"),
|
|
21
|
+
(re.compile(r"AssertionError: (False|0) is not true"),
|
|
22
|
+
"Assertion failed: expression evaluated to False"),
|
|
23
|
+
(re.compile(r"TypeError: (\w+)\(\) (?:takes|got|missing) (.+)"),
|
|
24
|
+
"Signature mismatch for {0}: {1}"),
|
|
25
|
+
(re.compile(r"TypeError: (\w+)\(\) (.+)"),
|
|
26
|
+
"Type error in {0}: {1}"),
|
|
27
|
+
(re.compile(r"KeyError: '(\w+)'"),
|
|
28
|
+
"Missing key: '{0}'"),
|
|
29
|
+
(re.compile(r"ValueError: (.+)"),
|
|
30
|
+
"Value error: {0}"),
|
|
31
|
+
(re.compile(r"IndentationError: (.+)"),
|
|
32
|
+
"Syntax issue: indentation error — {0}"),
|
|
33
|
+
(re.compile(r"SyntaxError: (.+)"),
|
|
34
|
+
"Syntax error: {0}"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def analyze_gaps(test_output: str, model_context: str = "") -> str:
|
|
39
|
+
"""Parse test failure output and produce enrichment feedback.
|
|
40
|
+
|
|
41
|
+
Maps common failure patterns to structured feedback for the next
|
|
42
|
+
iteration prompt. Returns a formatted string summarizing what needs
|
|
43
|
+
fixing.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
test_output: Raw pytest output (stdout + stderr).
|
|
47
|
+
model_context: Optional architecture model context for cross-referencing.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Formatted feedback string with identified gaps.
|
|
51
|
+
"""
|
|
52
|
+
if not test_output.strip():
|
|
53
|
+
return "No test output to analyze."
|
|
54
|
+
|
|
55
|
+
gaps: list[str] = []
|
|
56
|
+
seen: set[str] = set()
|
|
57
|
+
|
|
58
|
+
for line in test_output.splitlines():
|
|
59
|
+
line_stripped = line.strip()
|
|
60
|
+
for pattern, template in _PATTERNS:
|
|
61
|
+
m = pattern.search(line_stripped)
|
|
62
|
+
if m:
|
|
63
|
+
msg = template.format(*m.groups())
|
|
64
|
+
if msg not in seen:
|
|
65
|
+
seen.add(msg)
|
|
66
|
+
gaps.append(f"- {msg}")
|
|
67
|
+
break # One pattern per line
|
|
68
|
+
|
|
69
|
+
# Extract FAILED test names for additional context
|
|
70
|
+
failed_tests = _extract_failed_tests(test_output)
|
|
71
|
+
if failed_tests:
|
|
72
|
+
gaps.append("")
|
|
73
|
+
gaps.append("Failed tests:")
|
|
74
|
+
for t in failed_tests[:20]: # Cap at 20
|
|
75
|
+
gaps.append(f" - {t}")
|
|
76
|
+
|
|
77
|
+
if not gaps:
|
|
78
|
+
# Generic fallback
|
|
79
|
+
return _generic_feedback(test_output)
|
|
80
|
+
|
|
81
|
+
return "\n".join(gaps)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _extract_failed_tests(output: str) -> list[str]:
|
|
85
|
+
"""Extract FAILED test names from pytest output."""
|
|
86
|
+
failed: list[str] = []
|
|
87
|
+
for line in output.splitlines():
|
|
88
|
+
# pytest FAILED line: "FAILED test_file.py::test_name - ..."
|
|
89
|
+
if line.startswith("FAILED "):
|
|
90
|
+
parts = line.split(" - ", 1)
|
|
91
|
+
name = parts[0].replace("FAILED ", "").strip()
|
|
92
|
+
failed.append(name)
|
|
93
|
+
# Short test summary: "FAILED test_x.py::test_y"
|
|
94
|
+
elif "FAILED" in line and "::" in line:
|
|
95
|
+
match = re.search(r"FAILED\s+([\w/.]+::\w+)", line)
|
|
96
|
+
if match:
|
|
97
|
+
failed.append(match.group(1))
|
|
98
|
+
return list(dict.fromkeys(failed)) # dedupe preserving order
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _generic_feedback(output: str) -> str:
|
|
102
|
+
"""Produce generic feedback when no specific patterns match."""
|
|
103
|
+
# Look for the short summary line
|
|
104
|
+
for line in output.splitlines():
|
|
105
|
+
if "failed" in line.lower() and ("passed" in line.lower() or "error" in line.lower()):
|
|
106
|
+
return f"Tests failed but no specific error patterns matched.\nSummary: {line.strip()}\nReview the test assertions and ensure all expected values match."
|
|
107
|
+
return "Tests failed. Review the output and ensure all assertions pass."
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Generate command - test-guided code generation loop."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from opencode_arch.runner.base import RunnerBackend
|
|
9
|
+
from opencode_arch.cli.prompts import GENERATE_PROMPT
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def run_generate(
|
|
13
|
+
repo_path: str,
|
|
14
|
+
runner: RunnerBackend,
|
|
15
|
+
max_iter: int = 3,
|
|
16
|
+
test_command: str | None = None,
|
|
17
|
+
) -> dict[str, Any]:
|
|
18
|
+
"""Run the test-guided code generation loop."""
|
|
19
|
+
path = Path(repo_path)
|
|
20
|
+
if not path.exists():
|
|
21
|
+
return {"error": f"Path does not exist: {repo_path}", "passed": False}
|
|
22
|
+
|
|
23
|
+
start_time = time.time()
|
|
24
|
+
iterations = 0
|
|
25
|
+
last_test_result = {}
|
|
26
|
+
|
|
27
|
+
from opencode_arch.mcp.tools.generate import run_tests_on_generated_code
|
|
28
|
+
|
|
29
|
+
for i in range(max_iter):
|
|
30
|
+
iterations = i + 1
|
|
31
|
+
|
|
32
|
+
prompt = GENERATE_PROMPT.format(repo_path=str(path.resolve()), max_iter=max_iter)
|
|
33
|
+
if i > 0 and last_test_result.get("failures"):
|
|
34
|
+
failures_str = "\n".join(last_test_result["failures"][:10])
|
|
35
|
+
prompt += f"\n\nPrevious failures (iteration {i}):\n{failures_str}\nFix these issues."
|
|
36
|
+
|
|
37
|
+
await runner.run(prompt=prompt, repo_path=str(path))
|
|
38
|
+
|
|
39
|
+
test_result = await run_tests_on_generated_code(repo_path=str(path), test_command=test_command)
|
|
40
|
+
last_test_result = test_result
|
|
41
|
+
|
|
42
|
+
if test_result.get("passed") or test_result.get("pass_rate", 0) == 1.0:
|
|
43
|
+
break
|
|
44
|
+
if test_result.get("total_tests", 0) == 0:
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
elapsed = time.time() - start_time
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
from opencode_arch.telemetry.store import TelemetryStore
|
|
51
|
+
store = TelemetryStore()
|
|
52
|
+
store.record(
|
|
53
|
+
tool="architect_generate", repo=path.name,
|
|
54
|
+
context_tokens=0, output_quality=int(last_test_result.get("pass_rate", 0) * 100),
|
|
55
|
+
iterations=iterations,
|
|
56
|
+
)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
"passed": last_test_result.get("passed", False),
|
|
62
|
+
"pass_rate": last_test_result.get("pass_rate", 0.0),
|
|
63
|
+
"total_tests": last_test_result.get("total_tests", 0),
|
|
64
|
+
"passed_tests": last_test_result.get("passed_tests", 0),
|
|
65
|
+
"failures": last_test_result.get("failures", []),
|
|
66
|
+
"iterations": iterations,
|
|
67
|
+
"time_seconds": elapsed,
|
|
68
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Interactive launch — architecture-aware development session.
|
|
2
|
+
|
|
3
|
+
Pre-flight: scan, slice, persist, update CONTEXT.md, then exec opencode.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Markers for the architecture section in CONTEXT.md
|
|
18
|
+
CONTEXT_START = "<!-- opencode-arch:start -->"
|
|
19
|
+
CONTEXT_END = "<!-- opencode-arch:end -->"
|
|
20
|
+
|
|
21
|
+
# Minimal opencode.json template
|
|
22
|
+
OPENCODE_JSON_TEMPLATE = {
|
|
23
|
+
"name": "opencode-arch",
|
|
24
|
+
"version": "1.0.0",
|
|
25
|
+
"description": "Architecture-aware development tools",
|
|
26
|
+
"mcp": {
|
|
27
|
+
"command": "python",
|
|
28
|
+
"args": ["-m", "opencode_arch.mcp.server"],
|
|
29
|
+
"tools": [
|
|
30
|
+
{"name": "architect_scan", "description": "Scan repository AST to generate manifest"},
|
|
31
|
+
{"name": "architect_slice", "description": "Get token-compressed architecture context"},
|
|
32
|
+
{"name": "architect_validate", "description": "Validate architecture model (0-100)"},
|
|
33
|
+
{"name": "architect_extract", "description": "Store validated architecture model"},
|
|
34
|
+
{"name": "architect_generate", "description": "Run tests on generated code"},
|
|
35
|
+
{"name": "architect_group", "description": "Auto-group modules into components"},
|
|
36
|
+
{"name": "architect_check", "description": "Verify model representativeness"},
|
|
37
|
+
{"name": "architect_require", "description": "Capture functional requirement"},
|
|
38
|
+
{"name": "architect_feedback", "description": "Record feedback for training"},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_launch(repo_path: str | None = None, skip_exec: bool = False) -> dict:
|
|
45
|
+
"""Run pre-flight checks and launch interactive OpenCode session.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
repo_path: Target repository (default: current directory)
|
|
49
|
+
skip_exec: If True, do pre-flight only without exec (for testing)
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Pre-flight results dict (only if skip_exec=True)
|
|
53
|
+
"""
|
|
54
|
+
repo = Path(repo_path or ".").resolve()
|
|
55
|
+
if not repo.is_dir():
|
|
56
|
+
print(f"Error: {repo} is not a directory", file=sys.stderr)
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
start = time.time()
|
|
60
|
+
results = {"repo": str(repo), "steps": []}
|
|
61
|
+
|
|
62
|
+
# Step 1: Scan
|
|
63
|
+
manifest = None
|
|
64
|
+
try:
|
|
65
|
+
from architecture_model.manifest.generator import generate_manifest
|
|
66
|
+
manifest = generate_manifest(repo)
|
|
67
|
+
results["modules"] = len(manifest.modules)
|
|
68
|
+
results["interfaces"] = len(manifest.interfaces)
|
|
69
|
+
results["steps"].append("scan")
|
|
70
|
+
_status(f"Scanned {len(manifest.modules)} modules")
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
_warn(f"Scan skipped: {exc}")
|
|
73
|
+
|
|
74
|
+
# Step 2: Load or bootstrap model
|
|
75
|
+
model = None
|
|
76
|
+
model_path = repo / ".architecture-model.yaml"
|
|
77
|
+
try:
|
|
78
|
+
if model_path.exists():
|
|
79
|
+
from architecture_model.core.parser import load_model
|
|
80
|
+
model = load_model(model_path)
|
|
81
|
+
_status(f"Model loaded: {len(model.entities.components)} components")
|
|
82
|
+
elif manifest:
|
|
83
|
+
from architecture_model.manifest.grouping import create_components_from_manifest
|
|
84
|
+
from architecture_model.core.types import ArchitectureModel, Entities, ModelMeta
|
|
85
|
+
from architecture_model.orchestration.auto_enrich import enrich_from_manifest
|
|
86
|
+
|
|
87
|
+
components = create_components_from_manifest(manifest)
|
|
88
|
+
model = ArchitectureModel(
|
|
89
|
+
meta=ModelMeta(project=repo.name, schema_version="1.3"),
|
|
90
|
+
entities=Entities(components=components),
|
|
91
|
+
relationships=[],
|
|
92
|
+
)
|
|
93
|
+
enrich_from_manifest(model, manifest)
|
|
94
|
+
_status(f"Bootstrapped model: {len(components)} components")
|
|
95
|
+
results["steps"].append("model")
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
_warn(f"Model load/bootstrap skipped: {exc}")
|
|
98
|
+
|
|
99
|
+
# Step 3: Slice context
|
|
100
|
+
context_slice = ""
|
|
101
|
+
if model and manifest:
|
|
102
|
+
try:
|
|
103
|
+
from architecture_model.integrations.llm_context import format_model_context
|
|
104
|
+
from opencode_arch.mcp.tools.slice import compute_adaptive_budget
|
|
105
|
+
adaptive_budget = compute_adaptive_budget(len(manifest.modules))
|
|
106
|
+
context_slice = format_model_context(model, budget=adaptive_budget, detail="standard")
|
|
107
|
+
results["context_tokens"] = len(context_slice) // 4 # rough estimate
|
|
108
|
+
results["steps"].append("slice")
|
|
109
|
+
except Exception:
|
|
110
|
+
# Fallback: build a compact summary
|
|
111
|
+
try:
|
|
112
|
+
comps = model.entities.components
|
|
113
|
+
lines = [f"## Architecture: {len(comps)} components"]
|
|
114
|
+
for c in comps[:20]:
|
|
115
|
+
files = ", ".join(getattr(c, 'files', [])[:3])
|
|
116
|
+
lines.append(f"- **{c.name}** ({c.id}): {files}")
|
|
117
|
+
context_slice = "\n".join(lines)
|
|
118
|
+
results["steps"].append("slice_fallback")
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
# Step 4: Compute representativeness
|
|
123
|
+
rep = None
|
|
124
|
+
if model and manifest:
|
|
125
|
+
try:
|
|
126
|
+
from architecture_model.core.representativeness import compute_representativeness
|
|
127
|
+
rep = compute_representativeness(model, manifest.modules, manifest.interfaces)
|
|
128
|
+
results["representativeness"] = rep.overall
|
|
129
|
+
results["steps"].append("repr")
|
|
130
|
+
_status(f"Representativeness: {rep.overall:.1f}%")
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
_warn(f"Representativeness skipped: {exc}")
|
|
133
|
+
|
|
134
|
+
# Step 5: Persist to .architecture/
|
|
135
|
+
if model and manifest:
|
|
136
|
+
try:
|
|
137
|
+
from architecture_model.persistence.store import save_project
|
|
138
|
+
save_project(repo, model, manifest, representativeness=rep)
|
|
139
|
+
results["steps"].append("persist")
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
_warn(f"Persistence skipped: {exc}")
|
|
142
|
+
|
|
143
|
+
# Step 6: Update CONTEXT.md
|
|
144
|
+
_update_context_md(repo, model, context_slice, rep, manifest)
|
|
145
|
+
results["steps"].append("context_md")
|
|
146
|
+
|
|
147
|
+
# Step 7: Ensure opencode.json
|
|
148
|
+
_ensure_opencode_json(repo)
|
|
149
|
+
results["steps"].append("opencode_json")
|
|
150
|
+
|
|
151
|
+
elapsed = time.time() - start
|
|
152
|
+
results["elapsed_s"] = round(elapsed, 2)
|
|
153
|
+
_status(f"Ready ({elapsed:.1f}s)")
|
|
154
|
+
|
|
155
|
+
if skip_exec:
|
|
156
|
+
return results
|
|
157
|
+
|
|
158
|
+
# Step 8: Exec opencode
|
|
159
|
+
opencode_bin = shutil.which("opencode")
|
|
160
|
+
if not opencode_bin:
|
|
161
|
+
print("Error: 'opencode' not found in PATH. Install OpenCode first.", file=sys.stderr)
|
|
162
|
+
print(" See: https://opencode.ai", file=sys.stderr)
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
|
|
165
|
+
os.chdir(repo)
|
|
166
|
+
os.execvp(opencode_bin, [opencode_bin])
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _update_context_md(
|
|
170
|
+
repo: Path,
|
|
171
|
+
model,
|
|
172
|
+
context_slice: str,
|
|
173
|
+
rep,
|
|
174
|
+
manifest,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Update CONTEXT.md with architecture section between markers."""
|
|
177
|
+
context_path = repo / "CONTEXT.md"
|
|
178
|
+
|
|
179
|
+
# Build architecture section
|
|
180
|
+
lines = [CONTEXT_START]
|
|
181
|
+
lines.append("# Architecture (auto-managed by opencode-arch)")
|
|
182
|
+
lines.append("")
|
|
183
|
+
|
|
184
|
+
if model:
|
|
185
|
+
comp_count = len(model.entities.components)
|
|
186
|
+
rel_count = len(model.relationships) if model.relationships else 0
|
|
187
|
+
lines.append(f"**Model:** {comp_count} components | {rel_count} relationships")
|
|
188
|
+
|
|
189
|
+
if rep:
|
|
190
|
+
lines.append(f"**Score:** {rep.overall:.1f}% "
|
|
191
|
+
f"(FC={rep.file_coverage:.0f}% RA={rep.relationship_accuracy:.0f}% "
|
|
192
|
+
f"BC={rep.boundary_coherence:.0f}% BV={rep.behavioral_coverage:.0f}%)")
|
|
193
|
+
|
|
194
|
+
if manifest:
|
|
195
|
+
lines.append(f"**Codebase:** {len(manifest.modules)} modules | "
|
|
196
|
+
f"{len(manifest.interfaces)} import edges")
|
|
197
|
+
|
|
198
|
+
# Requirements count
|
|
199
|
+
req_path = repo / ".architecture" / "requirements.yaml"
|
|
200
|
+
if req_path.exists():
|
|
201
|
+
try:
|
|
202
|
+
import yaml
|
|
203
|
+
reqs = yaml.safe_load(req_path.read_text()) or {}
|
|
204
|
+
req_list = reqs.get("requirements", [])
|
|
205
|
+
lines.append(f"**Requirements:** {len(req_list)} tracked")
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
lines.append("")
|
|
210
|
+
|
|
211
|
+
if context_slice:
|
|
212
|
+
lines.append("## Component Map")
|
|
213
|
+
lines.append("")
|
|
214
|
+
lines.append(context_slice)
|
|
215
|
+
lines.append("")
|
|
216
|
+
|
|
217
|
+
lines.append("## Development Guidelines")
|
|
218
|
+
lines.append("")
|
|
219
|
+
lines.append("- Use `architect_slice` for focused context on specific components")
|
|
220
|
+
lines.append("- Use `architect_check` after significant changes to verify model accuracy")
|
|
221
|
+
lines.append("- Use `architect_require` to capture functional requirements from discussion")
|
|
222
|
+
lines.append("- Use `architect_feedback` to record corrections or rate tool quality")
|
|
223
|
+
lines.append("- Components are auto-grouped by import affinity — respect boundaries")
|
|
224
|
+
lines.append(CONTEXT_END)
|
|
225
|
+
|
|
226
|
+
arch_section = "\n".join(lines) + "\n"
|
|
227
|
+
|
|
228
|
+
# Read existing CONTEXT.md
|
|
229
|
+
if context_path.exists():
|
|
230
|
+
content = context_path.read_text()
|
|
231
|
+
# Replace existing section or append
|
|
232
|
+
if CONTEXT_START in content:
|
|
233
|
+
# Replace between markers
|
|
234
|
+
before = content[:content.index(CONTEXT_START)]
|
|
235
|
+
after_marker = content[content.index(CONTEXT_END) + len(CONTEXT_END):]
|
|
236
|
+
content = before + arch_section + after_marker
|
|
237
|
+
else:
|
|
238
|
+
# Append at end
|
|
239
|
+
if not content.endswith("\n"):
|
|
240
|
+
content += "\n"
|
|
241
|
+
content += "\n" + arch_section
|
|
242
|
+
else:
|
|
243
|
+
content = arch_section
|
|
244
|
+
|
|
245
|
+
context_path.write_text(content)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _ensure_opencode_json(repo: Path) -> None:
|
|
249
|
+
"""Ensure opencode.json exists in repo for MCP tool registration."""
|
|
250
|
+
opencode_json_path = repo / "opencode.json"
|
|
251
|
+
if not opencode_json_path.exists():
|
|
252
|
+
opencode_json_path.write_text(
|
|
253
|
+
json.dumps(OPENCODE_JSON_TEMPLATE, indent=2) + "\n"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _status(msg: str) -> None:
|
|
258
|
+
"""Print a status line."""
|
|
259
|
+
print(f" \u2713 {msg}", file=sys.stderr)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _warn(msg: str) -> None:
|
|
263
|
+
"""Print a warning line."""
|
|
264
|
+
print(f" ! {msg}", file=sys.stderr)
|