loopengt 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. loopengt/__init__.py +31 -0
  2. loopengt/adapters/__init__.py +1 -0
  3. loopengt/adapters/antigravity/__init__.py +1 -0
  4. loopengt/adapters/antigravity/adapter.py +55 -0
  5. loopengt/adapters/antigravity/commands.py +21 -0
  6. loopengt/adapters/base.py +51 -0
  7. loopengt/adapters/claude_code/__init__.py +1 -0
  8. loopengt/adapters/claude_code/adapter.py +55 -0
  9. loopengt/adapters/claude_code/commands.py +16 -0
  10. loopengt/adapters/codex/__init__.py +1 -0
  11. loopengt/adapters/codex/adapter.py +52 -0
  12. loopengt/adapters/codex/commands.py +16 -0
  13. loopengt/adapters/cursor/__init__.py +1 -0
  14. loopengt/adapters/cursor/adapter.py +56 -0
  15. loopengt/adapters/cursor/commands.py +29 -0
  16. loopengt/adapters/generic/__init__.py +1 -0
  17. loopengt/adapters/generic/terminal.py +82 -0
  18. loopengt/cli/__init__.py +1 -0
  19. loopengt/cli/commands/__init__.py +1 -0
  20. loopengt/cli/commands/design.py +171 -0
  21. loopengt/cli/commands/doctor.py +110 -0
  22. loopengt/cli/commands/eval.py +105 -0
  23. loopengt/cli/commands/init.py +131 -0
  24. loopengt/cli/commands/mcp_serve.py +57 -0
  25. loopengt/cli/commands/run.py +99 -0
  26. loopengt/cli/commands/template.py +145 -0
  27. loopengt/cli/commands/trace.py +114 -0
  28. loopengt/cli/formatters.py +125 -0
  29. loopengt/cli/main.py +66 -0
  30. loopengt/core/__init__.py +1 -0
  31. loopengt/core/evals/__init__.py +1 -0
  32. loopengt/core/evals/judges.py +216 -0
  33. loopengt/core/evals/metrics.py +119 -0
  34. loopengt/core/evals/regression.py +157 -0
  35. loopengt/core/memory/__init__.py +1 -0
  36. loopengt/core/memory/retrieval.py +124 -0
  37. loopengt/core/memory/store.py +184 -0
  38. loopengt/core/memory/summarizer.py +97 -0
  39. loopengt/core/models/__init__.py +43 -0
  40. loopengt/core/models/agent.py +126 -0
  41. loopengt/core/models/loop_spec.py +251 -0
  42. loopengt/core/models/policy.py +131 -0
  43. loopengt/core/models/state.py +271 -0
  44. loopengt/core/models/tool.py +105 -0
  45. loopengt/core/runtime/__init__.py +1 -0
  46. loopengt/core/runtime/checkpoint.py +152 -0
  47. loopengt/core/runtime/executor.py +463 -0
  48. loopengt/core/runtime/handoff.py +139 -0
  49. loopengt/core/runtime/scheduler.py +168 -0
  50. loopengt/core/tracing/__init__.py +1 -0
  51. loopengt/core/tracing/events.py +95 -0
  52. loopengt/core/tracing/exporters.py +158 -0
  53. loopengt/core/tracing/store.py +202 -0
  54. loopengt/mcp/__init__.py +1 -0
  55. loopengt/mcp/client/__init__.py +1 -0
  56. loopengt/mcp/client/manager.py +118 -0
  57. loopengt/mcp/client/tools.py +107 -0
  58. loopengt/mcp/server/__init__.py +1 -0
  59. loopengt/mcp/server/prompts.py +82 -0
  60. loopengt/mcp/server/resources.py +75 -0
  61. loopengt/mcp/server/server.py +50 -0
  62. loopengt/mcp/server/tools.py +214 -0
  63. loopengt/mcp/shared/__init__.py +1 -0
  64. loopengt/mcp/shared/schemas.py +91 -0
  65. loopengt/plugins/__init__.py +1 -0
  66. loopengt/plugins/base.py +90 -0
  67. loopengt/plugins/loader.py +130 -0
  68. loopengt/plugins/manifest.py +70 -0
  69. loopengt/plugins/registry.py +146 -0
  70. loopengt/prompts/LOOPENGT.md +60 -0
  71. loopengt/prompts/__init__.py +1 -0
  72. loopengt/storage/__init__.py +1 -0
  73. loopengt/storage/jsonl.py +84 -0
  74. loopengt/storage/sqlite.py +102 -0
  75. loopengt/templates/__init__.py +1 -0
  76. loopengt/templates/builtins/handoff_loop/LOOPENGS.md +10 -0
  77. loopengt/templates/builtins/planner_executor/LOOPENGS.md +29 -0
  78. loopengt/templates/builtins/research_architect/LOOPENGS.md +17 -0
  79. loopengt/templates/builtins/reviewer_retry/LOOPENGS.md +29 -0
  80. loopengt/templates/builtins/supervisor_workers/LOOPENGS.md +29 -0
  81. loopengt/templates/loader.py +38 -0
  82. loopengt/templates/registry.py +85 -0
  83. loopengt-0.1.0.dist-info/METADATA +275 -0
  84. loopengt-0.1.0.dist-info/RECORD +87 -0
  85. loopengt-0.1.0.dist-info/WHEEL +4 -0
  86. loopengt-0.1.0.dist-info/entry_points.txt +8 -0
  87. loopengt-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,110 @@
1
+ """``loopengt doctor`` — diagnose configuration and dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import sys
7
+
8
+ import typer
9
+
10
+ from loopengt.cli.formatters import print_doctor_result, print_header
11
+
12
+
13
+ def doctor_cmd() -> None:
14
+ """Diagnose loopengt configuration, dependencies, and adapters."""
15
+ from pathlib import Path
16
+
17
+ print_header("loopengt doctor", "Checking configuration and dependencies")
18
+
19
+ # 1. Python version
20
+ py_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
21
+ py_ok = sys.version_info >= (3, 11)
22
+ print_doctor_result(
23
+ "Python version",
24
+ py_ok,
25
+ f"{py_version} {'(>= 3.11 required)' if not py_ok else ''}",
26
+ )
27
+
28
+ # 2. Core dependencies
29
+ core_deps = ["pydantic", "typer", "rich", "structlog", "anyio", "yaml"]
30
+ for dep in core_deps:
31
+ try:
32
+ mod = importlib.import_module(dep)
33
+ version = getattr(mod, "__version__", "installed")
34
+ print_doctor_result(f" {dep}", True, version)
35
+ except ImportError:
36
+ print_doctor_result(f" {dep}", False, "not installed")
37
+
38
+ # 3. Optional dependencies
39
+ optional_deps = {
40
+ "mcp": ["fastmcp", "mcp"],
41
+ "llm": ["openai", "httpx"],
42
+ }
43
+ for group, deps in optional_deps.items():
44
+ for dep in deps:
45
+ try:
46
+ mod = importlib.import_module(dep)
47
+ version = getattr(mod, "__version__", "installed")
48
+ print_doctor_result(f" {dep} [{group}]", True, version)
49
+ except ImportError:
50
+ print_doctor_result(
51
+ f" {dep} [{group}]", False, f"install with: uv pip install \"loopengt[{group}]\""
52
+ )
53
+
54
+ # 4. .loopengt/ directory
55
+ loopengt_dir = Path(".loopengt")
56
+ print_doctor_result(
57
+ ".loopengt/ directory",
58
+ loopengt_dir.exists(),
59
+ str(loopengt_dir.resolve()) if loopengt_dir.exists() else "run 'loopengt init'",
60
+ )
61
+
62
+ if loopengt_dir.exists():
63
+ config_path = loopengt_dir / "config.toml"
64
+ print_doctor_result(
65
+ " config.toml",
66
+ config_path.exists(),
67
+ )
68
+
69
+ runs_dir = loopengt_dir / "runs"
70
+ run_count = len(list(runs_dir.glob("*.json"))) if runs_dir.exists() else 0
71
+ print_doctor_result(
72
+ " runs/",
73
+ runs_dir.exists(),
74
+ f"{run_count} trace(s)" if runs_dir.exists() else "",
75
+ )
76
+
77
+ # 5. Plugin entry points
78
+ try:
79
+ if sys.version_info >= (3, 12):
80
+ from importlib.metadata import entry_points
81
+ adapters = entry_points(group="loopengt.adapters")
82
+ else:
83
+ from importlib.metadata import entry_points
84
+ eps = entry_points()
85
+ adapters = eps.get("loopengt.adapters", [])
86
+
87
+ adapter_names = [ep.name for ep in adapters]
88
+ print_doctor_result(
89
+ "Registered adapters",
90
+ len(adapter_names) > 0,
91
+ ", ".join(adapter_names) if adapter_names else "none found",
92
+ )
93
+ except Exception: # noqa: BLE001
94
+ print_doctor_result("Registered adapters", False, "discovery failed")
95
+
96
+ # 6. Environment variables
97
+ import os
98
+
99
+ env_vars = {
100
+ "OPENAI_API_KEY": "OpenAI API key",
101
+ "HF_TOKEN": "Hugging Face API token",
102
+ "LOOPENGT_LLM_PROVIDER": "LLM provider override (e.g. openai, huggingface)",
103
+ }
104
+ for var, desc in env_vars.items():
105
+ present = os.environ.get(var) is not None
106
+ print_doctor_result(
107
+ f" ${var}",
108
+ present,
109
+ desc if not present else "set",
110
+ )
@@ -0,0 +1,105 @@
1
+ """``loopengt eval`` — run evaluations against a completed run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from loopengt.cli.formatters import console, print_error, print_info, print_success
11
+
12
+
13
+ def eval_cmd(
14
+ run_id: str = typer.Argument(..., help="Run ID to evaluate."),
15
+ runs_dir: Path = typer.Option(
16
+ Path(".loopengt/runs"),
17
+ "--runs-dir",
18
+ help="Directory containing run traces.",
19
+ ),
20
+ metrics: list[str] = typer.Option(
21
+ [],
22
+ "--metric",
23
+ "-m",
24
+ help="Specific metrics to evaluate (default: all).",
25
+ ),
26
+ ) -> None:
27
+ """Run evaluations against a completed loop run."""
28
+ trace_path = runs_dir / f"{run_id}.json"
29
+
30
+ if not trace_path.exists():
31
+ candidates = list(runs_dir.glob(f"{run_id}*"))
32
+ if candidates:
33
+ trace_path = candidates[0]
34
+ else:
35
+ print_error(f"Trace not found: {trace_path}")
36
+ raise typer.Exit(code=1)
37
+
38
+ try:
39
+ trace_data = json.loads(trace_path.read_text(encoding="utf-8"))
40
+ except (json.JSONDecodeError, OSError) as exc:
41
+ print_error(f"Failed to read trace: {exc}")
42
+ raise typer.Exit(code=1) from exc
43
+
44
+ print_info(f"Evaluating run {run_id}…")
45
+
46
+ # Run built-in metrics
47
+ results = _evaluate_builtin_metrics(trace_data, metrics or None)
48
+
49
+ # Display results
50
+ from rich.table import Table
51
+
52
+ table = Table(title="Evaluation Results", border_style="green")
53
+ table.add_column("Metric", style="cyan")
54
+ table.add_column("Value", style="white")
55
+ table.add_column("Status", style="green")
56
+
57
+ for name, value, status in results:
58
+ status_style = "green" if status == "pass" else "red"
59
+ table.add_row(name, str(value), f"[{status_style}]{status}[/{status_style}]")
60
+
61
+ console.print(table)
62
+ print_success(f"Evaluation complete for run {run_id}")
63
+
64
+
65
+ def _evaluate_builtin_metrics(
66
+ trace_data: dict, metric_names: list[str] | None = None
67
+ ) -> list[tuple[str, object, str]]:
68
+ """Evaluate built-in metrics against a trace."""
69
+ results: list[tuple[str, object, str]] = []
70
+
71
+ all_metrics = {
72
+ "total_turns": lambda t: (t.get("turn", 0), "pass"),
73
+ "total_steps": lambda t: (len(t.get("step_states", {})), "pass"),
74
+ "completed_steps": lambda t: (
75
+ sum(
76
+ 1
77
+ for s in t.get("step_states", {}).values()
78
+ if s.get("status") == "completed"
79
+ ),
80
+ "pass",
81
+ ),
82
+ "failed_steps": lambda t: (
83
+ (
84
+ n := sum(
85
+ 1
86
+ for s in t.get("step_states", {}).values()
87
+ if s.get("status") == "failed"
88
+ )
89
+ ),
90
+ "fail" if n > 0 else "pass",
91
+ ),
92
+ "run_status": lambda t: (
93
+ t.get("status", "unknown"),
94
+ "pass" if t.get("status") == "completed" else "fail",
95
+ ),
96
+ }
97
+
98
+ selected = metric_names or list(all_metrics.keys())
99
+
100
+ for name in selected:
101
+ if name in all_metrics:
102
+ value, status = all_metrics[name](trace_data)
103
+ results.append((name, value, status))
104
+
105
+ return results
@@ -0,0 +1,131 @@
1
+ """``loopengt init`` — scaffold a new .loopengt/ project directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from loopengt.cli.formatters import print_error, print_info, print_success
11
+
12
+
13
+ def init_cmd(
14
+ directory: Path = typer.Argument(
15
+ Path("."),
16
+ help="Directory to initialise (defaults to current directory).",
17
+ ),
18
+ force: bool = typer.Option(
19
+ False,
20
+ "--force",
21
+ "-f",
22
+ help="Overwrite existing .loopengt/ directory.",
23
+ ),
24
+ ) -> None:
25
+ """Scaffold a new .loopengt/ project directory with default config."""
26
+ target = directory.resolve() / ".loopengt"
27
+
28
+ if target.exists() and not force:
29
+ print_error(
30
+ f"{target} already exists. Use --force to overwrite."
31
+ )
32
+ raise typer.Exit(code=1)
33
+
34
+ if target.exists() and force:
35
+ shutil.rmtree(target)
36
+
37
+ # Create directory structure
38
+ dirs = [
39
+ target,
40
+ target / "templates",
41
+ target / "runs",
42
+ target / "prompts",
43
+ ]
44
+ for d in dirs:
45
+ d.mkdir(parents=True, exist_ok=True)
46
+
47
+ # Write default config
48
+ config_path = target / "config.toml"
49
+ config_path.write_text(
50
+ '# Loop Engineering configuration\n'
51
+ '\n'
52
+ '[general]\n'
53
+ 'default_pattern = "sequential"\n'
54
+ 'trace_format = "jsonl"\n'
55
+ '\n'
56
+ '[llm]\n'
57
+ 'provider = "openai"\n'
58
+ 'model = "gpt-4o"\n'
59
+ 'temperature = 0.7\n'
60
+ '# api_key_env = "OPENAI_API_KEY"\n'
61
+ '\n'
62
+ '[mcp]\n'
63
+ 'transport = "stdio"\n'
64
+ 'host = "localhost"\n'
65
+ 'port = 8765\n'
66
+ '\n'
67
+ '[tracing]\n'
68
+ 'backend = "jsonl"\n'
69
+ 'directory = "runs"\n',
70
+ encoding="utf-8",
71
+ )
72
+
73
+ # Write LOOPENGT prompt
74
+ loopengt_path = target / "prompts" / "LOOPENGT.md"
75
+ loopengt_path.write_text(
76
+ "# LOOPENGT — Loop Engineering Architect\n"
77
+ "\n"
78
+ "You are the Loop Engineering Architect. Given a goal, you design\n"
79
+ "the optimal agent loop by selecting the right pattern, defining\n"
80
+ "agents, steps, tools, and policies.\n"
81
+ "\n"
82
+ "## Principles\n"
83
+ "1. Minimal agents — each must justify its existence\n"
84
+ "2. Clear handoff contracts between steps\n"
85
+ "3. Verification gates at critical boundaries\n"
86
+ "4. Graceful degradation on failure\n"
87
+ "5. Observable by default (structured traces)\n",
88
+ encoding="utf-8",
89
+ )
90
+
91
+ # Copy built-in templates
92
+ _copy_builtin_templates(target / "templates")
93
+
94
+ print_success(f"Initialised .loopengt/ at {target}")
95
+ print_info(" config.toml — edit LLM & tracing settings")
96
+ print_info(" prompts/ — architect & design prompts")
97
+ print_info(" templates/ — loop templates")
98
+ print_info(" runs/ — execution traces")
99
+ print_info("")
100
+ print_info("Next: loopengt design \"your goal here\"")
101
+
102
+
103
+ def _copy_builtin_templates(templates_dir: Path) -> None:
104
+ """Copy built-in templates into the project templates directory."""
105
+ try:
106
+ from importlib.resources import files
107
+
108
+ builtins_root = files("loopengt.templates.builtins")
109
+ # Iterate known template directories
110
+ for template_name in (
111
+ "planner_executor",
112
+ "reviewer_retry",
113
+ "supervisor_workers",
114
+ "research_architect",
115
+ "handoff_loop",
116
+ ):
117
+ try:
118
+ template_pkg = builtins_root.joinpath(template_name) # type: ignore[union-attr]
119
+ dest = templates_dir / template_name
120
+ dest.mkdir(parents=True, exist_ok=True)
121
+
122
+ for filename in ("LOOPENGTS.md", "loop.yaml"):
123
+ resource = template_pkg.joinpath(filename) # type: ignore[union-attr]
124
+ resource_text = resource.read_text(encoding="utf-8") # type: ignore[union-attr]
125
+ (dest / filename).write_text(resource_text, encoding="utf-8")
126
+ except Exception: # noqa: BLE001
127
+ # Template not found — skip silently during init
128
+ continue
129
+ except Exception: # noqa: BLE001
130
+ # importlib.resources not available or templates not packaged yet
131
+ pass
@@ -0,0 +1,57 @@
1
+ """``loopengt mcp`` — start the MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from loopengt.cli.formatters import print_error, print_info
8
+
9
+
10
+ def mcp_cmd(
11
+ transport: str = typer.Option(
12
+ "stdio",
13
+ "--transport",
14
+ "-t",
15
+ help="Transport protocol: stdio or sse.",
16
+ ),
17
+ host: str = typer.Option(
18
+ "localhost",
19
+ "--host",
20
+ help="Host for SSE transport.",
21
+ ),
22
+ port: int = typer.Option(
23
+ 8765,
24
+ "--port",
25
+ "-p",
26
+ help="Port for SSE transport.",
27
+ ),
28
+ ) -> None:
29
+ """Start the loopengt MCP server."""
30
+ print_info(f"Starting MCP server (transport={transport})…")
31
+
32
+ try:
33
+ from loopengt.mcp.server.server import create_server
34
+
35
+ server = create_server()
36
+
37
+ if transport == "stdio":
38
+ import asyncio
39
+
40
+ asyncio.run(server.run_stdio())
41
+ elif transport == "sse":
42
+ import asyncio
43
+
44
+ asyncio.run(server.run_sse(host=host, port=port))
45
+ else:
46
+ print_error(f"Unknown transport: {transport}")
47
+ raise typer.Exit(code=1)
48
+
49
+ except ImportError:
50
+ print_error(
51
+ "MCP dependencies not installed. "
52
+ "Install with: uv pip install \"loopengt[mcp]\""
53
+ )
54
+ raise typer.Exit(code=1)
55
+ except Exception as exc: # noqa: BLE001
56
+ print_error(f"MCP server failed: {exc}")
57
+ raise typer.Exit(code=1) from exc
@@ -0,0 +1,99 @@
1
+ """``loopengt run`` — execute a loop from a YAML spec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ import yaml
10
+
11
+ from loopengt.cli.formatters import print_error, print_info, print_success
12
+
13
+
14
+ def run_cmd(
15
+ spec_path: Path = typer.Argument(
16
+ ..., help="Path to loop.yaml spec file."
17
+ ),
18
+ dry_run: bool = typer.Option(
19
+ False,
20
+ "--dry-run",
21
+ help="Validate the spec and show the execution plan without running.",
22
+ ),
23
+ verbose: bool = typer.Option(
24
+ False, "--verbose", "-V", help="Enable verbose output."
25
+ ),
26
+ ) -> None:
27
+ """Execute a loop from a YAML specification file."""
28
+ if not spec_path.exists():
29
+ print_error(f"Spec file not found: {spec_path}")
30
+ raise typer.Exit(code=1)
31
+
32
+ print_info(f"Loading spec from {spec_path}")
33
+
34
+ try:
35
+ raw = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
36
+ except yaml.YAMLError as exc:
37
+ print_error(f"Invalid YAML: {exc}")
38
+ raise typer.Exit(code=1) from exc
39
+
40
+ # Validate with Pydantic
41
+ try:
42
+ from loopengt.core.models.loop_spec import LoopSpec
43
+
44
+ spec = LoopSpec.model_validate(raw)
45
+ except Exception as exc: # noqa: BLE001
46
+ print_error(f"Spec validation failed: {exc}")
47
+ raise typer.Exit(code=1) from exc
48
+
49
+ print_success(f"Spec validated: {spec.name} v{spec.version}")
50
+ print_info(f" Pattern : {spec.pattern}")
51
+ print_info(f" Agents : {', '.join(spec.agent_names())}")
52
+ print_info(f" Steps : {', '.join(spec.step_names())}")
53
+
54
+ if dry_run:
55
+ print_info("Dry run — skipping execution.")
56
+ return
57
+
58
+ # Execute the loop
59
+ print_info("Starting loop execution…")
60
+ try:
61
+ from loopengt.core.runtime.executor import LoopExecutor
62
+
63
+ executor = LoopExecutor(spec)
64
+ result = asyncio.run(executor.execute())
65
+
66
+ if result.status.value == "completed":
67
+ print_success(f"Loop completed — run ID: {result.run_id}")
68
+ else:
69
+ print_error(f"Loop finished with status: {result.status}")
70
+ if result.error:
71
+ print_error(f" Error: {result.error}")
72
+
73
+ # Save trace
74
+ _save_trace(result, spec_path)
75
+
76
+ except Exception as exc: # noqa: BLE001
77
+ print_error(f"Execution failed: {exc}")
78
+ raise typer.Exit(code=1) from exc
79
+
80
+
81
+ def _save_trace(state: object, spec_path: Path) -> None:
82
+ """Persist the run trace to .loopengt/runs/."""
83
+ import json
84
+
85
+ loopengt_dir = spec_path.parent / ".loopengt" / "runs"
86
+ loopengt_dir.mkdir(parents=True, exist_ok=True)
87
+
88
+ # state is a LoopState; serialise via Pydantic
89
+ try:
90
+ state_dict = state.model_dump(mode="json") # type: ignore[union-attr]
91
+ run_id = state_dict.get("run_id", "unknown")
92
+ trace_path = loopengt_dir / f"{run_id}.json"
93
+ trace_path.write_text(
94
+ json.dumps(state_dict, indent=2, default=str),
95
+ encoding="utf-8",
96
+ )
97
+ print_info(f"Trace saved to {trace_path}")
98
+ except Exception: # noqa: BLE001
99
+ pass
@@ -0,0 +1,145 @@
1
+ """``loopengt template`` — manage loop templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from loopengt.cli.formatters import console, print_error, print_info, print_success
10
+
11
+ template_app = typer.Typer(
12
+ name="template",
13
+ help="Manage loop templates.",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+
18
+ @template_app.command("list")
19
+ def template_list(
20
+ include_builtins: bool = typer.Option(
21
+ True, "--builtins/--no-builtins", help="Include built-in templates."
22
+ ),
23
+ ) -> None:
24
+ """List available loop templates."""
25
+ from rich.table import Table
26
+
27
+ table = Table(title="Available Templates", border_style="blue")
28
+ table.add_column("Name", style="cyan")
29
+ table.add_column("Source", style="dim")
30
+ table.add_column("Description", style="white")
31
+
32
+ # Built-in templates
33
+ if include_builtins:
34
+ builtins = {
35
+ "planner_executor": "Sequential plan-then-execute pattern",
36
+ "reviewer_retry": "Code review with iterative retry on failure",
37
+ "supervisor_workers": "Supervisor delegates to specialised workers",
38
+ "research_architect": "Research → synthesise → architect workflow",
39
+ "handoff_loop": "Sequential agent-to-agent handoff pattern",
40
+ }
41
+ for name, desc in builtins.items():
42
+ table.add_row(name, "builtin", desc)
43
+
44
+ # Project-local templates
45
+ local_dir = Path(".loopengt/templates")
46
+ if local_dir.exists():
47
+ for entry in sorted(local_dir.iterdir()):
48
+ if entry.is_dir() and (entry / "loop.yaml").exists():
49
+ if entry.name not in (
50
+ "planner_executor",
51
+ "reviewer_retry",
52
+ "supervisor_workers",
53
+ "research_architect",
54
+ "handoff_loop",
55
+ ):
56
+ table.add_row(entry.name, "local", "")
57
+
58
+ console.print(table)
59
+
60
+
61
+ @template_app.command("use")
62
+ def template_use(
63
+ name: str = typer.Argument(..., help="Template name to use."),
64
+ output: Path = typer.Option(
65
+ Path("loop.yaml"),
66
+ "--output",
67
+ "-o",
68
+ help="Output path for the loop.yaml.",
69
+ ),
70
+ ) -> None:
71
+ """Create a loop.yaml from a named template."""
72
+ import shutil
73
+
74
+ # Search in project-local templates first, then built-ins
75
+ search_paths = [
76
+ Path(".loopengt/templates") / name,
77
+ ]
78
+
79
+ # Add built-in template paths
80
+ try:
81
+ from importlib.resources import files
82
+
83
+ builtin_path = files("loopengt.templates.builtins").joinpath(name) # type: ignore[union-attr]
84
+ search_paths.append(Path(str(builtin_path)))
85
+ except Exception: # noqa: BLE001
86
+ pass
87
+
88
+ for template_dir in search_paths:
89
+ src = template_dir / "loop.yaml" # type: ignore[operator]
90
+ if hasattr(src, "exists") and src.exists(): # type: ignore[union-attr]
91
+ try:
92
+ if isinstance(src, Path):
93
+ shutil.copy2(src, output)
94
+ else:
95
+ # importlib resource
96
+ output.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") # type: ignore[union-attr]
97
+ print_success(f"Template '{name}' written to {output}")
98
+ return
99
+ except Exception as exc: # noqa: BLE001
100
+ print_error(f"Failed to copy template: {exc}")
101
+ raise typer.Exit(code=1) from exc
102
+
103
+ print_error(f"Template not found: {name}")
104
+ print_info("Use 'loopengt template list' to see available templates.")
105
+ raise typer.Exit(code=1)
106
+
107
+
108
+ @template_app.command("create")
109
+ def template_create(
110
+ name: str = typer.Argument(..., help="Name for the new template."),
111
+ source: Path = typer.Option(
112
+ Path("loop.yaml"),
113
+ "--source",
114
+ "-s",
115
+ help="Source loop.yaml to create template from.",
116
+ ),
117
+ ) -> None:
118
+ """Create a new template from an existing loop.yaml."""
119
+ if not source.exists():
120
+ print_error(f"Source file not found: {source}")
121
+ raise typer.Exit(code=1)
122
+
123
+ template_dir = Path(".loopengt/templates") / name
124
+ template_dir.mkdir(parents=True, exist_ok=True)
125
+
126
+ import shutil
127
+
128
+ shutil.copy2(source, template_dir / "loop.yaml")
129
+
130
+ # Create a stub LOOPENGTS.md
131
+ (template_dir / "LOOPENGTS.md").write_text(
132
+ f"# {name}\n\n"
133
+ f"## Purpose\n"
134
+ f"TODO: Describe the purpose of this template.\n\n"
135
+ f"## Agents\n"
136
+ f"TODO: Document the agents and their roles.\n\n"
137
+ f"## Steps\n"
138
+ f"TODO: Document the execution steps.\n\n"
139
+ f"## Verification\n"
140
+ f"TODO: Describe verification criteria.\n",
141
+ encoding="utf-8",
142
+ )
143
+
144
+ print_success(f"Template '{name}' created at {template_dir}")
145
+ print_info(f"Edit {template_dir / 'LOOPENGTS.md'} to add documentation.")