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.
Files changed (65) hide show
  1. opencode_arch/__init__.py +3 -0
  2. opencode_arch/artifacts/__init__.py +48 -0
  3. opencode_arch/artifacts/context.py +451 -0
  4. opencode_arch/artifacts/diagrams.py +451 -0
  5. opencode_arch/artifacts/selector.py +331 -0
  6. opencode_arch/artifacts/templates.py +444 -0
  7. opencode_arch/cli/__init__.py +1 -0
  8. opencode_arch/cli/bench.py +25 -0
  9. opencode_arch/cli/calibrate.py +208 -0
  10. opencode_arch/cli/confidence.py +66 -0
  11. opencode_arch/cli/docs.py +333 -0
  12. opencode_arch/cli/docs_validator.py +295 -0
  13. opencode_arch/cli/export_data.py +133 -0
  14. opencode_arch/cli/extract.py +93 -0
  15. opencode_arch/cli/gap_analyzer.py +107 -0
  16. opencode_arch/cli/generate.py +68 -0
  17. opencode_arch/cli/launch.py +264 -0
  18. opencode_arch/cli/main.py +360 -0
  19. opencode_arch/cli/metrics.py +186 -0
  20. opencode_arch/cli/prompts.py +20 -0
  21. opencode_arch/cli/regen_loop.py +1028 -0
  22. opencode_arch/context/__init__.py +29 -0
  23. opencode_arch/context/formatter.py +492 -0
  24. opencode_arch/context/pipeline_bridge.py +201 -0
  25. opencode_arch/extract/__init__.py +8 -0
  26. opencode_arch/extract/constraint_detector.py +398 -0
  27. opencode_arch/extract/from_artifacts.py +837 -0
  28. opencode_arch/extract/from_code.py +646 -0
  29. opencode_arch/extract/route_detector.py +400 -0
  30. opencode_arch/extract/table_parser.py +177 -0
  31. opencode_arch/learning/__init__.py +19 -0
  32. opencode_arch/learning/adapter.py +157 -0
  33. opencode_arch/learning/assessor.py +170 -0
  34. opencode_arch/learning/classifier.py +144 -0
  35. opencode_arch/learning/lessons.py +139 -0
  36. opencode_arch/learning/maintainer.py +281 -0
  37. opencode_arch/learning/patterns.py +51 -0
  38. opencode_arch/mcp/__init__.py +1 -0
  39. opencode_arch/mcp/__main__.py +8 -0
  40. opencode_arch/mcp/server.py +183 -0
  41. opencode_arch/mcp/tools/__init__.py +1 -0
  42. opencode_arch/mcp/tools/check.py +159 -0
  43. opencode_arch/mcp/tools/extract.py +107 -0
  44. opencode_arch/mcp/tools/feedback.py +65 -0
  45. opencode_arch/mcp/tools/generate.py +104 -0
  46. opencode_arch/mcp/tools/group.py +62 -0
  47. opencode_arch/mcp/tools/ingest.py +101 -0
  48. opencode_arch/mcp/tools/require.py +77 -0
  49. opencode_arch/mcp/tools/scan.py +53 -0
  50. opencode_arch/mcp/tools/slice.py +235 -0
  51. opencode_arch/mcp/tools/validate.py +59 -0
  52. opencode_arch/prompts/__init__.py +1 -0
  53. opencode_arch/prompts/regen.py +36 -0
  54. opencode_arch/runner/__init__.py +5 -0
  55. opencode_arch/runner/base.py +21 -0
  56. opencode_arch/runner/opencode.py +66 -0
  57. opencode_arch/telemetry/__init__.py +6 -0
  58. opencode_arch/telemetry/collector.py +40 -0
  59. opencode_arch/telemetry/recorder.py +12 -0
  60. opencode_arch/telemetry/store.py +537 -0
  61. opencode_arch-1.0.0.dist-info/METADATA +247 -0
  62. opencode_arch-1.0.0.dist-info/RECORD +65 -0
  63. opencode_arch-1.0.0.dist-info/WHEEL +4 -0
  64. opencode_arch-1.0.0.dist-info/entry_points.txt +2 -0
  65. 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)