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,91 @@
1
+ """Shared JSON schemas for MCP tools and resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ # JSON Schema definitions for MCP tool inputs/outputs
9
+ LOOP_SPEC_SCHEMA: dict[str, Any] = {
10
+ "type": "object",
11
+ "properties": {
12
+ "name": {"type": "string", "description": "Loop identifier"},
13
+ "version": {"type": "string", "default": "1.0"},
14
+ "goal": {"type": "string", "description": "Loop objective"},
15
+ "pattern": {
16
+ "type": "string",
17
+ "enum": [
18
+ "sequential",
19
+ "supervisor_worker",
20
+ "parallel_fan_out",
21
+ "handoff",
22
+ "evaluator_optimizer",
23
+ "custom",
24
+ ],
25
+ "default": "sequential",
26
+ },
27
+ "agents": {
28
+ "type": "array",
29
+ "items": {"$ref": "#/$defs/AgentRole"},
30
+ },
31
+ "steps": {
32
+ "type": "array",
33
+ "items": {"$ref": "#/$defs/StepSpec"},
34
+ },
35
+ "policy": {"$ref": "#/$defs/Policy"},
36
+ },
37
+ "required": ["name", "goal", "agents", "steps"],
38
+ "$defs": {
39
+ "AgentRole": {
40
+ "type": "object",
41
+ "properties": {
42
+ "name": {"type": "string"},
43
+ "description": {"type": "string"},
44
+ "capabilities": {
45
+ "type": "array",
46
+ "items": {"type": "string"},
47
+ },
48
+ },
49
+ "required": ["name"],
50
+ },
51
+ "StepSpec": {
52
+ "type": "object",
53
+ "properties": {
54
+ "name": {"type": "string"},
55
+ "description": {"type": "string"},
56
+ "agent": {"type": "string"},
57
+ "dependencies": {
58
+ "type": "array",
59
+ "items": {
60
+ "type": "object",
61
+ "properties": {
62
+ "step_name": {"type": "string"},
63
+ },
64
+ },
65
+ },
66
+ },
67
+ "required": ["name", "agent"],
68
+ },
69
+ "Policy": {
70
+ "type": "object",
71
+ "properties": {
72
+ "max_turns": {"type": "integer", "minimum": 1},
73
+ "max_total_time_seconds": {"type": "number"},
74
+ },
75
+ },
76
+ },
77
+ }
78
+
79
+ RUN_RESULT_SCHEMA: dict[str, Any] = {
80
+ "type": "object",
81
+ "properties": {
82
+ "status": {
83
+ "type": "string",
84
+ "enum": ["completed", "failed", "cancelled", "running"],
85
+ },
86
+ "run_id": {"type": "string"},
87
+ "turns": {"type": "integer"},
88
+ "steps_completed": {"type": "integer"},
89
+ "error": {"type": "string"},
90
+ },
91
+ }
@@ -0,0 +1 @@
1
+ """Plugin system for extensible loop engineering."""
@@ -0,0 +1,90 @@
1
+ """Plugin base class and protocol definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, Protocol, runtime_checkable
7
+
8
+ from loopengt.plugins.manifest import PluginManifest
9
+
10
+
11
+ @runtime_checkable
12
+ class PluginProtocol(Protocol):
13
+ """Protocol that all loopengt plugins must satisfy."""
14
+
15
+ @property
16
+ def manifest(self) -> PluginManifest: ...
17
+
18
+ def activate(self, context: dict[str, Any]) -> None: ...
19
+
20
+ def deactivate(self) -> None: ...
21
+
22
+
23
+ class Plugin(ABC):
24
+ """Abstract base class for loopengt plugins.
25
+
26
+ Provides lifecycle hooks and metadata. Subclass this to create
27
+ new plugins.
28
+
29
+ Example::
30
+
31
+ class MyPlugin(Plugin):
32
+ @property
33
+ def manifest(self) -> PluginManifest:
34
+ return PluginManifest(
35
+ name="my-plugin",
36
+ version="1.0.0",
37
+ description="Does cool things",
38
+ )
39
+
40
+ def activate(self, context):
41
+ # Set up resources
42
+ pass
43
+
44
+ def deactivate(self):
45
+ # Tear down resources
46
+ pass
47
+ """
48
+
49
+ _active: bool = False
50
+
51
+ @property
52
+ @abstractmethod
53
+ def manifest(self) -> PluginManifest:
54
+ """Return the plugin's metadata manifest."""
55
+ ...
56
+
57
+ @abstractmethod
58
+ def activate(self, context: dict[str, Any]) -> None:
59
+ """Called when the plugin is loaded and activated.
60
+
61
+ Parameters
62
+ ----------
63
+ context:
64
+ Shared context dict with config, adapters, etc.
65
+ """
66
+ ...
67
+
68
+ @abstractmethod
69
+ def deactivate(self) -> None:
70
+ """Called when the plugin is being unloaded."""
71
+ ...
72
+
73
+ @property
74
+ def is_active(self) -> bool:
75
+ """Whether this plugin is currently active."""
76
+ return self._active
77
+
78
+ def on_loop_start(self, loop_name: str, context: dict[str, Any]) -> None:
79
+ """Hook called when a loop starts. Override if needed."""
80
+
81
+ def on_loop_complete(
82
+ self, loop_name: str, result: Any
83
+ ) -> None:
84
+ """Hook called when a loop completes. Override if needed."""
85
+
86
+ def on_error(self, error: Exception, context: dict[str, Any]) -> None:
87
+ """Hook called on errors. Override if needed."""
88
+
89
+ def __repr__(self) -> str:
90
+ return f"<Plugin {self.manifest.name} v{self.manifest.version}>"
@@ -0,0 +1,130 @@
1
+ """Dynamic plugin loading with validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from typing import Any
7
+
8
+ import structlog
9
+
10
+ from loopengt.plugins.base import Plugin, PluginProtocol
11
+ from loopengt.plugins.manifest import PluginManifest
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+
16
+ class PluginLoadError(Exception):
17
+ """Raised when a plugin cannot be loaded."""
18
+
19
+
20
+ class PluginLoader:
21
+ """Dynamically loads plugins from dotted import paths.
22
+
23
+ Validates that loaded classes conform to the ``PluginProtocol``
24
+ and have valid manifests.
25
+
26
+ Usage::
27
+
28
+ loader = PluginLoader()
29
+ plugin = loader.load("loopengt.adapters.generic.terminal.TerminalAdapter")
30
+ """
31
+
32
+ def __init__(self) -> None:
33
+ self._log = logger.bind(component="plugin_loader")
34
+ self._cache: dict[str, Plugin] = {}
35
+
36
+ def load(self, import_path: str) -> Plugin:
37
+ """Load a plugin class from a dotted import path.
38
+
39
+ Parameters
40
+ ----------
41
+ import_path:
42
+ Fully-qualified dotted path, e.g.
43
+ ``"loopengt.adapters.generic.terminal.TerminalAdapter"``
44
+
45
+ Returns
46
+ -------
47
+ Plugin
48
+ An *unactivated* plugin instance.
49
+
50
+ Raises
51
+ ------
52
+ PluginLoadError
53
+ If the module/class cannot be imported or does not satisfy
54
+ the plugin protocol.
55
+ """
56
+ if import_path in self._cache:
57
+ return self._cache[import_path]
58
+
59
+ try:
60
+ module_path, class_name = import_path.rsplit(".", 1)
61
+ module = importlib.import_module(module_path)
62
+ cls = getattr(module, class_name)
63
+ except (ImportError, AttributeError, ValueError) as exc:
64
+ raise PluginLoadError(
65
+ f"Cannot import plugin from '{import_path}': {exc}"
66
+ ) from exc
67
+
68
+ # Instantiate
69
+ try:
70
+ instance = cls()
71
+ except Exception as exc:
72
+ raise PluginLoadError(
73
+ f"Cannot instantiate plugin '{import_path}': {exc}"
74
+ ) from exc
75
+
76
+ # Validate
77
+ if not isinstance(instance, (Plugin, PluginProtocol)):
78
+ raise PluginLoadError(
79
+ f"'{import_path}' does not implement the Plugin protocol"
80
+ )
81
+
82
+ # Validate manifest
83
+ try:
84
+ manifest = instance.manifest
85
+ if not isinstance(manifest, PluginManifest):
86
+ raise PluginLoadError(
87
+ f"'{import_path}' manifest is not a PluginManifest"
88
+ )
89
+ except Exception as exc:
90
+ raise PluginLoadError(
91
+ f"'{import_path}' has invalid manifest: {exc}"
92
+ ) from exc
93
+
94
+ self._cache[import_path] = instance
95
+ self._log.info(
96
+ "plugin.loaded",
97
+ path=import_path,
98
+ name=manifest.name,
99
+ version=manifest.version,
100
+ )
101
+ return instance
102
+
103
+ def load_many(self, import_paths: list[str]) -> list[Plugin]:
104
+ """Load multiple plugins. Skips failures with warnings."""
105
+ plugins = []
106
+ for path in import_paths:
107
+ try:
108
+ plugins.append(self.load(path))
109
+ except PluginLoadError as exc:
110
+ self._log.warning("plugin.load_failed", path=path, error=str(exc))
111
+ return plugins
112
+
113
+ def reload(self, import_path: str) -> Plugin:
114
+ """Force-reload a plugin (hot-reload for development)."""
115
+ self._cache.pop(import_path, None)
116
+
117
+ module_path, _ = import_path.rsplit(".", 1)
118
+ try:
119
+ module = importlib.import_module(module_path)
120
+ importlib.reload(module)
121
+ except Exception as exc:
122
+ raise PluginLoadError(
123
+ f"Cannot reload module '{module_path}': {exc}"
124
+ ) from exc
125
+
126
+ return self.load(import_path)
127
+
128
+ def clear_cache(self) -> None:
129
+ """Clear the loader cache."""
130
+ self._cache.clear()
@@ -0,0 +1,70 @@
1
+ """Plugin metadata manifest model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class PluginManifest(BaseModel):
11
+ """Metadata describing a loopengt plugin.
12
+
13
+ Every plugin must provide a manifest with at least a name, version,
14
+ and description.
15
+ """
16
+
17
+ model_config = ConfigDict(frozen=True)
18
+
19
+ name: str = Field(
20
+ ..., min_length=1, max_length=128, description="Plugin identifier"
21
+ )
22
+ version: str = Field(default="0.1.0", description="Semantic version")
23
+ description: str = Field(
24
+ default="", max_length=2048, description="Human-readable description"
25
+ )
26
+ author: str = Field(default="", description="Author name or organisation")
27
+ license: str = Field(default="", description="SPDX license identifier")
28
+ homepage: str = Field(default="", description="URL to project homepage")
29
+
30
+ # Capability declarations
31
+ capabilities: list[str] = Field(
32
+ default_factory=list,
33
+ description=(
34
+ "Capabilities this plugin provides. Standard values: "
35
+ "'adapter', 'mcp_tools', 'templates', 'skills', 'verifiers'"
36
+ ),
37
+ )
38
+
39
+ # Entry point groups this plugin contributes to
40
+ entry_points: list[str] = Field(
41
+ default_factory=list,
42
+ description="Entry-point groups: loopengt.adapters, loopengt.mcp_tools, …",
43
+ )
44
+
45
+ # Configuration schema
46
+ config_schema: dict[str, Any] | None = Field(
47
+ default=None,
48
+ description="JSON Schema for this plugin's configuration",
49
+ )
50
+
51
+ # Version constraints
52
+ min_loopengt_version: str | None = Field(
53
+ default=None,
54
+ description="Minimum loopengt version required (e.g. '0.1.0')",
55
+ )
56
+ max_loopengt_version: str | None = Field(
57
+ default=None,
58
+ description="Maximum loopengt version supported",
59
+ )
60
+
61
+ # Dependencies on other plugins
62
+ dependencies: list[str] = Field(
63
+ default_factory=list,
64
+ description="Names of plugins this one depends on",
65
+ )
66
+
67
+ metadata: dict[str, Any] = Field(
68
+ default_factory=dict,
69
+ description="Arbitrary plugin metadata",
70
+ )
@@ -0,0 +1,146 @@
1
+ """Entry-point based plugin discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any
7
+
8
+ import structlog
9
+
10
+ from loopengt.plugins.base import Plugin, PluginProtocol
11
+ from loopengt.plugins.manifest import PluginManifest
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+
16
+ class PluginRegistry:
17
+ """Discovers and manages loopengt plugins via entry points.
18
+
19
+ Scans the ``loopengt.adapters``, ``loopengt.mcp_tools``,
20
+ ``loopengt.templates``, ``loopengt.skills``, and ``loopengt.verifiers``
21
+ entry-point groups.
22
+
23
+ Usage::
24
+
25
+ registry = PluginRegistry()
26
+ registry.discover()
27
+ adapter = registry.get("terminal", group="loopengt.adapters")
28
+ """
29
+
30
+ ENTRY_POINT_GROUPS = [
31
+ "loopengt.adapters",
32
+ "loopengt.mcp_tools",
33
+ "loopengt.templates",
34
+ "loopengt.skills",
35
+ "loopengt.verifiers",
36
+ ]
37
+
38
+ def __init__(self) -> None:
39
+ self._plugins: dict[str, dict[str, Any]] = {
40
+ group: {} for group in self.ENTRY_POINT_GROUPS
41
+ }
42
+ self._activated: dict[str, Plugin] = {}
43
+ self._log = logger.bind(component="plugin_registry")
44
+
45
+ # ------------------------------------------------------------------
46
+ # Discovery
47
+ # ------------------------------------------------------------------
48
+
49
+ def discover(self) -> dict[str, list[str]]:
50
+ """Discover all registered plugins across entry-point groups.
51
+
52
+ Returns a dict mapping group name → list of plugin names.
53
+ """
54
+ discovered: dict[str, list[str]] = {}
55
+
56
+ for group in self.ENTRY_POINT_GROUPS:
57
+ eps = self._load_entry_points(group)
58
+ names = []
59
+ for ep in eps:
60
+ self._plugins[group][ep.name] = ep
61
+ names.append(ep.name)
62
+ self._log.debug(
63
+ "plugin.discovered", group=group, name=ep.name
64
+ )
65
+ discovered[group] = names
66
+
67
+ return discovered
68
+
69
+ def get(self, name: str, group: str = "loopengt.adapters") -> Any:
70
+ """Load and return a plugin class by name and group."""
71
+ if group not in self._plugins:
72
+ raise KeyError(f"Unknown plugin group: {group}")
73
+
74
+ ep = self._plugins[group].get(name)
75
+ if ep is None:
76
+ raise KeyError(
77
+ f"Plugin '{name}' not found in group '{group}'"
78
+ )
79
+
80
+ return ep.load()
81
+
82
+ def list_plugins(
83
+ self, group: str | None = None
84
+ ) -> dict[str, list[str]]:
85
+ """List discovered plugin names, optionally filtered by group."""
86
+ if group:
87
+ return {group: list(self._plugins.get(group, {}).keys())}
88
+ return {g: list(plugins.keys()) for g, plugins in self._plugins.items()}
89
+
90
+ # ------------------------------------------------------------------
91
+ # Activation
92
+ # ------------------------------------------------------------------
93
+
94
+ def activate(
95
+ self, name: str, group: str, context: dict[str, Any] | None = None
96
+ ) -> Plugin:
97
+ """Load, instantiate, and activate a plugin."""
98
+ plugin_cls = self.get(name, group)
99
+ instance = plugin_cls()
100
+
101
+ if isinstance(instance, Plugin):
102
+ instance.activate(context or {})
103
+ instance._active = True
104
+ self._activated[f"{group}:{name}"] = instance
105
+ self._log.info("plugin.activated", group=group, name=name)
106
+ else:
107
+ self._log.warning(
108
+ "plugin.not_plugin_subclass",
109
+ group=group,
110
+ name=name,
111
+ )
112
+
113
+ return instance
114
+
115
+ def deactivate(self, name: str, group: str) -> None:
116
+ """Deactivate a plugin."""
117
+ key = f"{group}:{name}"
118
+ instance = self._activated.pop(key, None)
119
+ if instance:
120
+ instance.deactivate()
121
+ instance._active = False
122
+ self._log.info("plugin.deactivated", group=group, name=name)
123
+
124
+ def deactivate_all(self) -> None:
125
+ """Deactivate all active plugins."""
126
+ for key, instance in list(self._activated.items()):
127
+ instance.deactivate()
128
+ instance._active = False
129
+ self._activated.clear()
130
+
131
+ # ------------------------------------------------------------------
132
+ # Internals
133
+ # ------------------------------------------------------------------
134
+
135
+ @staticmethod
136
+ def _load_entry_points(group: str) -> list[Any]:
137
+ """Load entry points for a given group."""
138
+ if sys.version_info >= (3, 12):
139
+ from importlib.metadata import entry_points
140
+
141
+ return list(entry_points(group=group))
142
+ else:
143
+ from importlib.metadata import entry_points
144
+
145
+ eps = entry_points()
146
+ return list(eps.get(group, []))
@@ -0,0 +1,60 @@
1
+ # LOOPENGT — Loop Engineering Architect
2
+
3
+ You are **LOOPENGT**, the Loop Engineering Architect. Your purpose is to
4
+ design optimal agent loop specifications given a natural-language goal.
5
+
6
+ ## Core Principles
7
+
8
+ 1. **Minimal agents** — each agent must justify its existence with a
9
+ distinct capability no other agent provides.
10
+ 2. **Clear handoff contracts** — every step must define what it receives
11
+ and what it produces.
12
+ 3. **Verification gates** — critical boundaries must have explicit pass/fail
13
+ checks before the loop continues.
14
+ 4. **Graceful degradation** — failures should be retried with backoff, and
15
+ the loop should produce partial results rather than nothing.
16
+ 5. **Observable by default** — every step emits structured trace events.
17
+
18
+ ## Design Process
19
+
20
+ When given a goal:
21
+
22
+ 1. **Classify** the task type (code generation, review, research, data
23
+ processing, multi-agent collaboration).
24
+ 2. **Select pattern** — choose the orchestration pattern that best fits:
25
+ - `sequential` — linear step-by-step
26
+ - `supervisor_worker` — central coordinator with specialists
27
+ - `parallel_fan_out` — independent concurrent tasks
28
+ - `handoff` — enrichment pipeline
29
+ - `evaluator_optimizer` — iterative quality improvement
30
+ 3. **Define agents** — minimum viable set with clear roles.
31
+ 4. **Design steps** — with dependencies, prompt templates, and tool bindings.
32
+ 5. **Set policies** — max turns, retry budgets, timeout, verification gates.
33
+ 6. **Specify stop conditions** — when is the loop "done"?
34
+
35
+ ## Output Format
36
+
37
+ Produce two artifacts:
38
+
39
+ ### 1. `LOOP_DESIGN.md`
40
+ A human-readable design document with:
41
+ - Goal restatement
42
+ - Pattern rationale
43
+ - Agent descriptions
44
+ - Step-by-step walkthrough
45
+ - Verification strategy
46
+ - Risk assessment
47
+
48
+ ### 2. `loop.yaml`
49
+ A valid YAML file conforming to the LoopSpec schema with all fields
50
+ populated.
51
+
52
+ ## Quality Checklist
53
+
54
+ Before finalising, verify:
55
+ - [ ] Every step references an existing agent
56
+ - [ ] Agent tools are declared in the top-level `tools` list
57
+ - [ ] Dependencies form a DAG (no cycles)
58
+ - [ ] At least one stop condition is defined
59
+ - [ ] Timeout is set for the overall loop
60
+ - [ ] Retry policy is appropriate for the task criticality
@@ -0,0 +1 @@
1
+ """Prompt templates for loop engineering."""
@@ -0,0 +1 @@
1
+ """Storage backends package."""
@@ -0,0 +1,84 @@
1
+ """JSONL streaming storage for events and logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import structlog
10
+
11
+ logger = structlog.get_logger(__name__)
12
+
13
+
14
+ class JSONLStorage:
15
+ """Append-only JSONL file storage.
16
+
17
+ Each record is a single JSON object written as one line. Suitable
18
+ for streaming event logs and trace data.
19
+
20
+ Usage::
21
+
22
+ storage = JSONLStorage(Path(".loopengt/events.jsonl"))
23
+ storage.append({"event": "step.start", "step": "plan"})
24
+ records = storage.read_all()
25
+ """
26
+
27
+ def __init__(self, path: Path) -> None:
28
+ self._path = path
29
+ self._path.parent.mkdir(parents=True, exist_ok=True)
30
+ self._log = logger.bind(component="jsonl", path=str(path))
31
+
32
+ def append(self, record: dict[str, Any]) -> None:
33
+ """Append a single record to the JSONL file."""
34
+ line = json.dumps(record, default=str, ensure_ascii=False)
35
+ with open(self._path, "a", encoding="utf-8") as f:
36
+ f.write(line + "\n")
37
+
38
+ def append_many(self, records: list[dict[str, Any]]) -> None:
39
+ """Append multiple records efficiently."""
40
+ with open(self._path, "a", encoding="utf-8") as f:
41
+ for record in records:
42
+ line = json.dumps(record, default=str, ensure_ascii=False)
43
+ f.write(line + "\n")
44
+
45
+ def read_all(self) -> list[dict[str, Any]]:
46
+ """Read all records from the file."""
47
+ if not self._path.exists():
48
+ return []
49
+
50
+ records: list[dict[str, Any]] = []
51
+ with open(self._path, encoding="utf-8") as f:
52
+ for line_no, line in enumerate(f, 1):
53
+ line = line.strip()
54
+ if not line:
55
+ continue
56
+ try:
57
+ records.append(json.loads(line))
58
+ except json.JSONDecodeError:
59
+ self._log.warning(
60
+ "jsonl.parse_error", line=line_no, path=str(self._path)
61
+ )
62
+ return records
63
+
64
+ def read_last(self, n: int = 10) -> list[dict[str, Any]]:
65
+ """Read the last *n* records (tail)."""
66
+ all_records = self.read_all()
67
+ return all_records[-n:]
68
+
69
+ def count(self) -> int:
70
+ """Return the number of records in the file."""
71
+ if not self._path.exists():
72
+ return 0
73
+ with open(self._path, encoding="utf-8") as f:
74
+ return sum(1 for line in f if line.strip())
75
+
76
+ def truncate(self) -> None:
77
+ """Clear all records from the file."""
78
+ with open(self._path, "w", encoding="utf-8"):
79
+ pass
80
+
81
+ @property
82
+ def path(self) -> Path:
83
+ """Return the file path."""
84
+ return self._path