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,400 @@
1
+ """
2
+ AST-based route handler detection for Python web frameworks.
3
+
4
+ Scans Python source files and extracts decorated route handlers for
5
+ FastAPI, Flask, and Django projects.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import sys
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+
16
+ @dataclass
17
+ class RouteInfo:
18
+ """Information about a detected route handler."""
19
+
20
+ method: str # GET, POST, PUT, DELETE, PATCH
21
+ path: str # "/articles/{slug}"
22
+ function_name: str # "get_article"
23
+ docstring: str # First line of function docstring
24
+ file: str # Relative path to the file
25
+ is_authenticated: bool # True if has auth dependency/decorator
26
+ framework: str # "fastapi", "flask", "django"
27
+
28
+
29
+ # HTTP methods recognized from decorator attribute names (FastAPI/Flask style).
30
+ _HTTP_METHODS = frozenset({"get", "post", "put", "delete", "patch", "options", "head"})
31
+
32
+
33
+ def detect_routes(
34
+ project_root: Path, web_layer_dirs: list[str] | None = None
35
+ ) -> list[RouteInfo]:
36
+ """Scan Python files for route handler declarations.
37
+
38
+ Args:
39
+ project_root: Root directory of the project.
40
+ web_layer_dirs: Optional list of directories to restrict scanning
41
+ (e.g., ["app/api"]). If None, scans all .py files.
42
+
43
+ Returns:
44
+ List of RouteInfo for each detected route handler.
45
+ """
46
+ root = Path(project_root)
47
+ py_files = _collect_python_files(root, web_layer_dirs)
48
+ routes: list[RouteInfo] = []
49
+
50
+ for py_file in py_files:
51
+ tree = _parse_file(py_file)
52
+ if tree is None:
53
+ continue
54
+ rel_path = str(py_file.relative_to(root))
55
+ routes.extend(_extract_fastapi_routes(tree, rel_path))
56
+ routes.extend(_extract_flask_routes(tree, rel_path))
57
+ if py_file.name == "urls.py":
58
+ routes.extend(_extract_django_routes(tree, rel_path))
59
+
60
+ return routes
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # File collection
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def _collect_python_files(
69
+ root: Path, web_layer_dirs: list[str] | None
70
+ ) -> list[Path]:
71
+ """Collect Python files to scan, optionally restricted to given dirs."""
72
+ if web_layer_dirs:
73
+ files: list[Path] = []
74
+ for dir_name in web_layer_dirs:
75
+ target = root / dir_name
76
+ if target.is_dir():
77
+ files.extend(sorted(target.rglob("*.py")))
78
+ return files
79
+ return sorted(root.rglob("*.py"))
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Parsing
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def _parse_file(path: Path) -> ast.Module | None:
88
+ """Parse a Python file, returning None on failure."""
89
+ try:
90
+ source = path.read_text(encoding="utf-8")
91
+ return ast.parse(source, filename=str(path))
92
+ except (SyntaxError, UnicodeDecodeError, OSError) as exc:
93
+ print(f"route_detector: skipping {path} ({exc})", file=sys.stderr)
94
+ return None
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # FastAPI extraction
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ def _extract_fastapi_routes(tree: ast.Module, rel_path: str) -> list[RouteInfo]:
103
+ """Extract routes from FastAPI-style decorators."""
104
+ routes: list[RouteInfo] = []
105
+
106
+ for node in ast.walk(tree):
107
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
108
+ continue
109
+ for decorator in node.decorator_list:
110
+ info = _parse_fastapi_decorator(decorator)
111
+ if info is None:
112
+ continue
113
+ method, path = info
114
+ routes.append(
115
+ RouteInfo(
116
+ method=method.upper(),
117
+ path=path,
118
+ function_name=node.name,
119
+ docstring=_get_docstring(node),
120
+ file=rel_path,
121
+ is_authenticated=_has_auth_dependency(node),
122
+ framework="fastapi",
123
+ )
124
+ )
125
+ return routes
126
+
127
+
128
+ def _parse_fastapi_decorator(node: ast.expr) -> tuple[str, str] | None:
129
+ """Return (method, path) if the decorator is a FastAPI route call."""
130
+ if not isinstance(node, ast.Call):
131
+ return None
132
+ func = node.func
133
+ if not isinstance(func, ast.Attribute):
134
+ return None
135
+ if func.attr not in _HTTP_METHODS:
136
+ return None
137
+ # First positional arg is the path
138
+ path = _get_first_string_arg(node)
139
+ if path is None:
140
+ path = ""
141
+ return func.attr, path
142
+
143
+
144
+ def _has_auth_dependency(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
145
+ """Check if a function has auth-related Depends() or Security() parameters."""
146
+ for arg in _all_function_args(node):
147
+ if arg.annotation is None:
148
+ continue
149
+ if _is_auth_annotation(arg.annotation):
150
+ return True
151
+ # Check default values
152
+ # Also check defaults
153
+ defaults = _collect_defaults(node)
154
+ for default in defaults:
155
+ if _is_auth_call(default):
156
+ return True
157
+ # Check route decorator dependencies=[Depends(...)] kwarg
158
+ for decorator in node.decorator_list:
159
+ if isinstance(decorator, ast.Call):
160
+ for kw in decorator.keywords:
161
+ if kw.arg == "dependencies" and isinstance(kw.value, ast.List):
162
+ for elt in kw.value.elts:
163
+ if _is_auth_call(elt):
164
+ return True
165
+ return False
166
+
167
+
168
+ def _all_function_args(
169
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
170
+ ) -> list[ast.arg]:
171
+ """Get all arguments from a function definition."""
172
+ args = node.args
173
+ return args.posonlyargs + args.args + args.kwonlyargs
174
+
175
+
176
+ def _collect_defaults(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.expr]:
177
+ """Collect all default values from function arguments."""
178
+ args = node.args
179
+ return list(args.defaults) + list(args.kw_defaults)
180
+
181
+
182
+ def _is_auth_annotation(ann: ast.expr) -> bool:
183
+ """Check if an annotation references auth (e.g., Depends(get_current_user))."""
184
+ if isinstance(ann, ast.Call):
185
+ return _is_auth_call(ann)
186
+ return False
187
+
188
+
189
+ def _is_auth_call(node: ast.expr) -> bool:
190
+ """Check if a Call node is Depends(auth...) or Security(...)."""
191
+ if not isinstance(node, ast.Call):
192
+ return False
193
+ func_name = _get_call_name(node)
194
+ if func_name == "Security":
195
+ return True
196
+ if func_name == "Depends":
197
+ if node.args:
198
+ arg = node.args[0]
199
+ # Direct name: Depends(get_current_user)
200
+ arg_name = _get_node_name(arg)
201
+ if arg_name and _is_auth_name(arg_name):
202
+ return True
203
+ # Factory call: Depends(get_current_user_authorizer())
204
+ if isinstance(arg, ast.Call):
205
+ call_name = _get_call_name(arg)
206
+ if call_name and _is_auth_name(call_name):
207
+ return True
208
+ return False
209
+
210
+
211
+ def _is_auth_name(name: str) -> bool:
212
+ """Check if a name looks auth-related."""
213
+ lower = name.lower()
214
+ return (
215
+ "auth" in lower
216
+ or "current_user" in lower
217
+ or "permission" in lower
218
+ or "login_required" in lower
219
+ )
220
+
221
+
222
+ def _get_call_name(node: ast.Call) -> str:
223
+ """Get the simple name of a Call's function."""
224
+ if isinstance(node.func, ast.Name):
225
+ return node.func.id
226
+ if isinstance(node.func, ast.Attribute):
227
+ return node.func.attr
228
+ return ""
229
+
230
+
231
+ def _get_node_name(node: ast.expr) -> str:
232
+ """Get the name string from a Name or Attribute node."""
233
+ if isinstance(node, ast.Name):
234
+ return node.id
235
+ if isinstance(node, ast.Attribute):
236
+ return node.attr
237
+ return ""
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Flask extraction
242
+ # ---------------------------------------------------------------------------
243
+
244
+
245
+ def _extract_flask_routes(tree: ast.Module, rel_path: str) -> list[RouteInfo]:
246
+ """Extract routes from Flask-style @app.route() decorators."""
247
+ routes: list[RouteInfo] = []
248
+
249
+ for node in ast.walk(tree):
250
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
251
+ continue
252
+ for decorator in node.decorator_list:
253
+ info = _parse_flask_decorator(decorator)
254
+ if info is None:
255
+ continue
256
+ methods, path = info
257
+ for method in methods:
258
+ routes.append(
259
+ RouteInfo(
260
+ method=method.upper(),
261
+ path=path,
262
+ function_name=node.name,
263
+ docstring=_get_docstring(node),
264
+ file=rel_path,
265
+ is_authenticated=_has_flask_auth_decorator(node),
266
+ framework="flask",
267
+ )
268
+ )
269
+ return routes
270
+
271
+
272
+ def _parse_flask_decorator(node: ast.expr) -> tuple[list[str], str] | None:
273
+ """Return (methods, path) if the decorator is a Flask route call."""
274
+ if not isinstance(node, ast.Call):
275
+ return None
276
+ func = node.func
277
+ if not isinstance(func, ast.Attribute):
278
+ return None
279
+ if func.attr != "route":
280
+ return None
281
+ path = _get_first_string_arg(node)
282
+ if path is None:
283
+ return None
284
+ methods = _get_flask_methods(node)
285
+ return methods, path
286
+
287
+
288
+ def _get_flask_methods(node: ast.Call) -> list[str]:
289
+ """Extract the methods= keyword from a Flask route decorator."""
290
+ for kw in node.keywords:
291
+ if kw.arg == "methods":
292
+ if isinstance(kw.value, ast.List):
293
+ methods: list[str] = []
294
+ for elt in kw.value.elts:
295
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
296
+ methods.append(elt.value)
297
+ if methods:
298
+ return methods
299
+ return ["GET"]
300
+
301
+
302
+ def _has_flask_auth_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
303
+ """Check if a Flask handler has login_required or similar auth decorator."""
304
+ for decorator in node.decorator_list:
305
+ name = ""
306
+ if isinstance(decorator, ast.Name):
307
+ name = decorator.id
308
+ elif isinstance(decorator, ast.Attribute):
309
+ name = decorator.attr
310
+ elif isinstance(decorator, ast.Call):
311
+ if isinstance(decorator.func, ast.Name):
312
+ name = decorator.func.id
313
+ elif isinstance(decorator.func, ast.Attribute):
314
+ name = decorator.func.attr
315
+ if _is_auth_name(name):
316
+ return True
317
+ return False
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Django extraction
322
+ # ---------------------------------------------------------------------------
323
+
324
+
325
+ def _extract_django_routes(tree: ast.Module, rel_path: str) -> list[RouteInfo]:
326
+ """Extract routes from Django urlpatterns assignments."""
327
+ routes: list[RouteInfo] = []
328
+
329
+ for node in ast.walk(tree):
330
+ if not isinstance(node, ast.Assign):
331
+ continue
332
+ # Look for: urlpatterns = [...]
333
+ if not any(
334
+ isinstance(t, ast.Name) and t.id == "urlpatterns" for t in node.targets
335
+ ):
336
+ continue
337
+ if not isinstance(node.value, ast.List):
338
+ continue
339
+ for elt in node.value.elts:
340
+ info = _parse_django_path_call(elt)
341
+ if info:
342
+ routes.append(
343
+ RouteInfo(
344
+ method="GET",
345
+ path=info[0],
346
+ function_name=info[1],
347
+ docstring="",
348
+ file=rel_path,
349
+ is_authenticated=False,
350
+ framework="django",
351
+ )
352
+ )
353
+ return routes
354
+
355
+
356
+ def _parse_django_path_call(node: ast.expr) -> tuple[str, str] | None:
357
+ """Parse a path() or re_path() call in urlpatterns."""
358
+ if not isinstance(node, ast.Call):
359
+ return None
360
+ func_name = _get_call_name(node)
361
+ if func_name not in ("path", "re_path"):
362
+ return None
363
+ if len(node.args) < 2:
364
+ return None
365
+ # First arg is the route string
366
+ route_arg = node.args[0]
367
+ if not isinstance(route_arg, ast.Constant) or not isinstance(
368
+ route_arg.value, str
369
+ ):
370
+ return None
371
+ route = route_arg.value
372
+ # Second arg is the view function
373
+ view_name = _get_node_name(node.args[1])
374
+ if not view_name:
375
+ # Try dotted access e.g., views.article_list
376
+ if isinstance(node.args[1], ast.Attribute):
377
+ view_name = node.args[1].attr
378
+ return route, view_name
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # Utilities
383
+ # ---------------------------------------------------------------------------
384
+
385
+
386
+ def _get_first_string_arg(node: ast.Call) -> str | None:
387
+ """Get the first positional string argument from a Call node."""
388
+ if node.args:
389
+ first = node.args[0]
390
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
391
+ return first.value
392
+ return None
393
+
394
+
395
+ def _get_docstring(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
396
+ """Get the first line of a function's docstring, or empty string."""
397
+ ds = ast.get_docstring(node)
398
+ if ds:
399
+ return ds.split("\n")[0].strip()
400
+ return ""
@@ -0,0 +1,177 @@
1
+ """
2
+ Markdown table parser — extracts structured data from pipe-delimited tables.
3
+
4
+ Used by the artifact extractors to pull entity data from Tier 1 markdown docs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Any
11
+
12
+
13
+ def parse_tables(markdown: str) -> list[list[dict[str, str]]]:
14
+ """
15
+ Parse all markdown tables in the text.
16
+
17
+ Returns a list of tables, where each table is a list of row-dicts
18
+ keyed by normalized header names.
19
+ """
20
+ tables: list[list[dict[str, str]]] = []
21
+ lines = markdown.split("\n")
22
+ i = 0
23
+
24
+ while i < len(lines):
25
+ # Look for a header row (contains pipes)
26
+ if "|" in lines[i]:
27
+ header_line = lines[i].strip()
28
+ # Check next line is separator (dashes)
29
+ if i + 1 < len(lines) and re.match(r"^\s*\|[\s\-:|]+\|\s*$", lines[i + 1]):
30
+ headers = _parse_row(header_line)
31
+ if headers:
32
+ table_rows: list[dict[str, str]] = []
33
+ i += 2 # skip header + separator
34
+
35
+ while i < len(lines) and "|" in lines[i]:
36
+ row_line = lines[i].strip()
37
+ if re.match(r"^\s*\|[\s\-:|]+\|\s*$", row_line):
38
+ i += 1
39
+ continue
40
+ values = _parse_row(row_line)
41
+ if values:
42
+ row_dict = {}
43
+ for idx, h in enumerate(headers):
44
+ key = _normalize_header(h)
45
+ row_dict[key] = values[idx] if idx < len(values) else ""
46
+ table_rows.append(row_dict)
47
+ i += 1
48
+
49
+ if table_rows:
50
+ tables.append(table_rows)
51
+ continue
52
+ i += 1
53
+
54
+ return tables
55
+
56
+
57
+ def find_table_after_heading(markdown: str, heading_pattern: str) -> list[dict[str, str]]:
58
+ """
59
+ Find the first table that appears after a heading matching the pattern.
60
+
61
+ Args:
62
+ markdown: Full markdown text
63
+ heading_pattern: Regex pattern to match against heading text (case-insensitive)
64
+
65
+ Returns:
66
+ List of row dicts, or empty list if no matching table found.
67
+ """
68
+ lines = markdown.split("\n")
69
+ heading_re = re.compile(heading_pattern, re.IGNORECASE)
70
+ found_heading = False
71
+
72
+ i = 0
73
+ while i < len(lines):
74
+ line = lines[i].strip()
75
+
76
+ # Check for heading (only match ## or deeper, skip # document titles)
77
+ if line.startswith("##"):
78
+ heading_text = re.sub(r"^#+\s*", "", line)
79
+ if heading_re.search(heading_text):
80
+ found_heading = True
81
+ i += 1
82
+ continue
83
+ elif found_heading:
84
+ # Hit next heading without finding a table
85
+ return []
86
+
87
+ # If we've found the heading, look for a table
88
+ if found_heading and "|" in line:
89
+ if i + 1 < len(lines) and re.match(r"^\s*\|[\s\-:|]+\|\s*$", lines[i + 1]):
90
+ headers = _parse_row(line)
91
+ if headers:
92
+ table_rows: list[dict[str, str]] = []
93
+ i += 2
94
+
95
+ while i < len(lines) and "|" in lines[i]:
96
+ row_line = lines[i].strip()
97
+ if re.match(r"^\s*\|[\s\-:|]+\|\s*$", row_line):
98
+ i += 1
99
+ continue
100
+ values = _parse_row(row_line)
101
+ if values:
102
+ row_dict = {}
103
+ for idx, h in enumerate(headers):
104
+ key = _normalize_header(h)
105
+ row_dict[key] = values[idx] if idx < len(values) else ""
106
+ table_rows.append(row_dict)
107
+ i += 1
108
+
109
+ return table_rows
110
+ i += 1
111
+
112
+ return []
113
+
114
+
115
+ def extract_sections(markdown: str, level: int = 2) -> dict[str, str]:
116
+ """
117
+ Split markdown into sections by heading level.
118
+
119
+ Returns dict mapping heading text -> section content (including sub-headings).
120
+ """
121
+ sections: dict[str, str] = {}
122
+ prefix = "#" * level
123
+ lines = markdown.split("\n")
124
+ current_heading = ""
125
+ current_lines: list[str] = []
126
+
127
+ for line in lines:
128
+ if line.startswith(prefix + " ") and not line.startswith(prefix + "# "):
129
+ # Save previous section
130
+ if current_heading:
131
+ sections[current_heading] = "\n".join(current_lines).strip()
132
+ current_heading = re.sub(r"^#+\s*", "", line.strip())
133
+ current_lines = []
134
+ else:
135
+ current_lines.append(line)
136
+
137
+ # Save last section
138
+ if current_heading:
139
+ sections[current_heading] = "\n".join(current_lines).strip()
140
+
141
+ return sections
142
+
143
+
144
+ def extract_list_items(text: str) -> list[str]:
145
+ """Extract bullet/numbered list items from text."""
146
+ items: list[str] = []
147
+ for line in text.split("\n"):
148
+ line = line.strip()
149
+ # Match: - item, * item, 1. item, 1) item
150
+ m = re.match(r"^(?:[-*]|\d+[.)]) \s*(.*)", line)
151
+ if m:
152
+ items.append(m.group(1).strip())
153
+ return items
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Internal
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ def _parse_row(line: str) -> list[str]:
162
+ """Parse a pipe-delimited row into cell values."""
163
+ line = line.strip()
164
+ if line.startswith("|"):
165
+ line = line[1:]
166
+ if line.endswith("|"):
167
+ line = line[:-1]
168
+ cells = [cell.strip() for cell in line.split("|")]
169
+ return cells
170
+
171
+
172
+ def _normalize_header(header: str) -> str:
173
+ """Normalize header to snake_case key."""
174
+ h = header.strip().lower()
175
+ h = re.sub(r"[^a-z0-9]+", "_", h)
176
+ h = h.strip("_")
177
+ return h
@@ -0,0 +1,19 @@
1
+ """Learning loop: pattern classification, adaptation, assessment, and maintenance."""
2
+ from opencode_arch.learning.classifier import classify_failures
3
+ from opencode_arch.learning.adapter import get_adaptations, apply_adaptations
4
+ from opencode_arch.learning.assessor import generate_report_card, ReportCard
5
+ from opencode_arch.learning.lessons import extract_lessons, Lesson
6
+ from opencode_arch.learning.maintainer import detect_drift, auto_fix_drift, DriftFlag
7
+
8
+ __all__ = [
9
+ "classify_failures",
10
+ "get_adaptations",
11
+ "apply_adaptations",
12
+ "generate_report_card",
13
+ "ReportCard",
14
+ "extract_lessons",
15
+ "Lesson",
16
+ "detect_drift",
17
+ "auto_fix_drift",
18
+ "DriftFlag",
19
+ ]