hyperloop 0.4.0__tar.gz → 0.5.0__tar.gz

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 (64) hide show
  1. hyperloop-0.5.0/CHANGELOG.md +30 -0
  2. {hyperloop-0.4.0 → hyperloop-0.5.0}/PKG-INFO +3 -1
  3. {hyperloop-0.4.0 → hyperloop-0.5.0}/README.md +2 -0
  4. {hyperloop-0.4.0 → hyperloop-0.5.0}/base/implementer.yaml +3 -1
  5. hyperloop-0.5.0/base/kustomization.yaml +7 -0
  6. {hyperloop-0.4.0 → hyperloop-0.5.0}/base/pm.yaml +3 -1
  7. {hyperloop-0.4.0 → hyperloop-0.5.0}/base/process-improver.yaml +3 -1
  8. {hyperloop-0.4.0 → hyperloop-0.5.0}/base/process.yaml +3 -1
  9. {hyperloop-0.4.0 → hyperloop-0.5.0}/base/rebase-resolver.yaml +3 -1
  10. {hyperloop-0.4.0 → hyperloop-0.5.0}/base/verifier.yaml +3 -1
  11. {hyperloop-0.4.0 → hyperloop-0.5.0}/pyproject.toml +1 -1
  12. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/cli.py +18 -26
  13. hyperloop-0.5.0/src/hyperloop/compose.py +288 -0
  14. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/config.py +7 -1
  15. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_compose.py +167 -15
  16. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_loop.py +3 -3
  17. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_serial_agents.py +6 -6
  18. {hyperloop-0.4.0 → hyperloop-0.5.0}/uv.lock +1 -1
  19. hyperloop-0.4.0/CHANGELOG.md +0 -19
  20. hyperloop-0.4.0/src/hyperloop/compose.py +0 -126
  21. {hyperloop-0.4.0 → hyperloop-0.5.0}/.github/workflows/ci.yaml +0 -0
  22. {hyperloop-0.4.0 → hyperloop-0.5.0}/.github/workflows/release.yaml +0 -0
  23. {hyperloop-0.4.0 → hyperloop-0.5.0}/.gitignore +0 -0
  24. {hyperloop-0.4.0 → hyperloop-0.5.0}/.pre-commit-config.yaml +0 -0
  25. {hyperloop-0.4.0 → hyperloop-0.5.0}/.python-version +0 -0
  26. {hyperloop-0.4.0 → hyperloop-0.5.0}/CLAUDE.md +0 -0
  27. {hyperloop-0.4.0 → hyperloop-0.5.0}/specs/spec.md +0 -0
  28. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/__init__.py +0 -0
  29. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/__main__.py +0 -0
  30. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/adapters/__init__.py +0 -0
  31. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/adapters/git_state.py +0 -0
  32. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/adapters/local.py +0 -0
  33. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/adapters/serial.py +0 -0
  34. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/domain/__init__.py +0 -0
  35. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/domain/decide.py +0 -0
  36. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/domain/deps.py +0 -0
  37. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/domain/model.py +0 -0
  38. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/domain/pipeline.py +0 -0
  39. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/loop.py +0 -0
  40. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/ports/__init__.py +0 -0
  41. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/ports/pr.py +0 -0
  42. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/ports/runtime.py +0 -0
  43. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/ports/serial.py +0 -0
  44. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/ports/state.py +0 -0
  45. {hyperloop-0.4.0 → hyperloop-0.5.0}/src/hyperloop/pr.py +0 -0
  46. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/__init__.py +0 -0
  47. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/fakes/__init__.py +0 -0
  48. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/fakes/pr.py +0 -0
  49. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/fakes/runtime.py +0 -0
  50. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/fakes/serial.py +0 -0
  51. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/fakes/state.py +0 -0
  52. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_cli.py +0 -0
  53. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_config.py +0 -0
  54. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_decide.py +0 -0
  55. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_deps.py +0 -0
  56. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_e2e.py +0 -0
  57. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_fakes.py +0 -0
  58. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_git_state.py +0 -0
  59. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_local_runtime.py +0 -0
  60. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_model.py +0 -0
  61. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_pipeline.py +0 -0
  62. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_pr.py +0 -0
  63. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_smoke.py +0 -0
  64. {hyperloop-0.4.0 → hyperloop-0.5.0}/tests/test_state_contract.py +0 -0
@@ -0,0 +1,30 @@
1
+ # CHANGELOG
2
+
3
+ <!-- version list -->
4
+
5
+ ## v0.5.0 (2026-04-15)
6
+
7
+ ### Features
8
+
9
+ - Make base_ref configurable via config file and HYPERLOOP_BASE_REF env var
10
+ ([`8efcc0f`](https://github.com/jsell-rh/hyperloop/commit/8efcc0f4292229525e4aa8831561982ad5384220))
11
+
12
+ - Replace local base/ loading with kustomize-based prompt composition
13
+ ([`9644afb`](https://github.com/jsell-rh/hyperloop/commit/9644afb89a96eee564f997222d86201312bcb63e))
14
+
15
+
16
+ ## v0.4.0 (2026-04-15)
17
+
18
+
19
+ ## v0.3.0 (2026-04-15)
20
+
21
+
22
+ ## v0.2.0 (2026-04-15)
23
+
24
+
25
+ ## v0.1.1 (2026-04-15)
26
+
27
+
28
+ ## v0.1.0 (2026-04-15)
29
+
30
+ - Initial Release
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperloop
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Orchestrator that walks tasks through composable process pipelines using AI agents
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: pyyaml>=6.0.3
@@ -21,6 +21,7 @@ Walks tasks through composable process pipelines using AI agents. You write spec
21
21
 
22
22
  - Python 3.12+
23
23
  - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
24
+ - [kustomize](https://kubectl.docs.kubernetes.io/installation/kustomize/) (`kustomize` on PATH) — resolves agent definitions at startup
24
25
  - `git`
25
26
  - `gh` CLI (optional, for GitHub PR operations)
26
27
 
@@ -222,6 +223,7 @@ All task state is tracked in git. Every commit includes `Spec-Ref` and `Task-Ref
222
223
  # .hyperloop.yaml
223
224
 
224
225
  overlay: .hyperloop/agents/ # local path or git URL to kustomization dir
226
+ base_ref: github.com/jsell-rh/hyperloop//base?ref=v1.0.0 # kustomize remote base (env: HYPERLOOP_BASE_REF)
225
227
 
226
228
  target:
227
229
  repo: owner/repo # GitHub repo (default: inferred from git remote)
@@ -6,6 +6,7 @@ Walks tasks through composable process pipelines using AI agents. You write spec
6
6
 
7
7
  - Python 3.12+
8
8
  - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
9
+ - [kustomize](https://kubectl.docs.kubernetes.io/installation/kustomize/) (`kustomize` on PATH) — resolves agent definitions at startup
9
10
  - `git`
10
11
  - `gh` CLI (optional, for GitHub PR operations)
11
12
 
@@ -207,6 +208,7 @@ All task state is tracked in git. Every commit includes `Spec-Ref` and `Task-Ref
207
208
  # .hyperloop.yaml
208
209
 
209
210
  overlay: .hyperloop/agents/ # local path or git URL to kustomization dir
211
+ base_ref: github.com/jsell-rh/hyperloop//base?ref=v1.0.0 # kustomize remote base (env: HYPERLOOP_BASE_REF)
210
212
 
211
213
  target:
212
214
  repo: owner/repo # GitHub repo (default: inferred from git remote)
@@ -1,5 +1,7 @@
1
+ apiVersion: hyperloop.io/v1
1
2
  kind: Agent
2
- name: implementer
3
+ metadata:
4
+ name: implementer
3
5
  prompt: |
4
6
  You are a worker agent implementing a task.
5
7
 
@@ -0,0 +1,7 @@
1
+ resources:
2
+ - process.yaml
3
+ - implementer.yaml
4
+ - verifier.yaml
5
+ - process-improver.yaml
6
+ - rebase-resolver.yaml
7
+ - pm.yaml
@@ -1,5 +1,7 @@
1
+ apiVersion: hyperloop.io/v1
1
2
  kind: Agent
2
- name: pm
3
+ metadata:
4
+ name: pm
3
5
  prompt: |
4
6
  You are a product manager creating tasks from specs.
5
7
 
@@ -1,5 +1,7 @@
1
+ apiVersion: hyperloop.io/v1
1
2
  kind: Agent
2
- name: process-improver
3
+ metadata:
4
+ name: process-improver
3
5
  prompt: |
4
6
  You improve the development process based on review findings.
5
7
 
@@ -1,5 +1,7 @@
1
+ apiVersion: hyperloop.io/v1
1
2
  kind: Process
2
- name: default
3
+ metadata:
4
+ name: default
3
5
 
4
6
  intake:
5
7
  - role: pm
@@ -1,5 +1,7 @@
1
+ apiVersion: hyperloop.io/v1
1
2
  kind: Agent
2
- name: rebase-resolver
3
+ metadata:
4
+ name: rebase-resolver
3
5
  prompt: |
4
6
  You resolve merge conflicts on a worker branch.
5
7
 
@@ -1,5 +1,7 @@
1
+ apiVersion: hyperloop.io/v1
1
2
  kind: Agent
2
- name: verifier
3
+ metadata:
4
+ name: verifier
3
5
  prompt: |
4
6
  You are a code reviewer verifying a task implementation.
5
7
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hyperloop"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Orchestrator that walks tasks through composable process pipelines using AI agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -54,6 +54,7 @@ def _config_table(cfg: Config) -> Table:
54
54
  table.add_row("base_branch", cfg.base_branch)
55
55
  table.add_row("specs_dir", cfg.specs_dir)
56
56
  table.add_row("overlay", cfg.overlay or "[dim]none[/dim]")
57
+ table.add_row("base_ref", cfg.base_ref)
57
58
  table.add_row("runtime", cfg.runtime)
58
59
  table.add_row("max_workers", str(cfg.max_workers))
59
60
  table.add_row("auto_merge", str(cfg.auto_merge))
@@ -66,35 +67,26 @@ def _config_table(cfg: Config) -> Table:
66
67
  return table
67
68
 
68
69
 
69
- def _make_composer(state: object) -> object | None:
70
- """Construct a PromptComposer using the base/ directory.
70
+ def _make_composer(cfg: object, state: object) -> object:
71
+ """Construct a PromptComposer via kustomize build.
71
72
 
72
- Tries two locations:
73
- 1. Development: relative to this file (src/hyperloop/../../base)
74
- 2. Installed package: via importlib.resources
73
+ Runs ``kustomize build`` on the configured overlay (or the default
74
+ hyperloop base if no overlay is set).
75
75
 
76
- Returns None if base/ directory cannot be found.
77
- """
78
- from hyperloop.compose import PromptComposer
79
-
80
- # Development path: relative to this source file
81
- dev_base = Path(__file__).resolve().parent.parent.parent / "base"
82
- if dev_base.is_dir():
83
- return PromptComposer(base_dir=dev_base, state=state) # type: ignore[arg-type]
76
+ Args:
77
+ cfg: Config object with an ``overlay`` attribute.
78
+ state: StateStore instance for reading process overlays at spawn time.
84
79
 
85
- # Installed package: try importlib.resources
86
- try:
87
- import importlib.resources as pkg_resources
80
+ Returns:
81
+ A PromptComposer with resolved templates.
82
+ """
83
+ from hyperloop.compose import PromptComposer, check_kustomize_available
88
84
 
89
- ref = pkg_resources.files("hyperloop").joinpath("../../base")
90
- base_path = Path(str(ref))
91
- if base_path.is_dir():
92
- return PromptComposer(base_dir=base_path, state=state) # type: ignore[arg-type]
93
- except (ImportError, TypeError, FileNotFoundError):
94
- pass
85
+ check_kustomize_available()
95
86
 
96
- console.print("[dim]Warning: base/ directory not found — prompts will be empty.[/dim]")
97
- return None
87
+ overlay = getattr(cfg, "overlay", None)
88
+ base_ref = getattr(cfg, "base_ref", "github.com/jsell-rh/hyperloop//base?ref=main")
89
+ return PromptComposer.from_kustomize(overlay, state, base_ref=base_ref) # type: ignore[arg-type]
98
90
 
99
91
 
100
92
  @app.command()
@@ -197,8 +189,8 @@ def run(
197
189
  runtime = LocalRuntime(repo_path=str(repo_path))
198
190
  serial_runner = SubprocessSerialRunner(repo_path=str(repo_path))
199
191
 
200
- # Resolve base/ directory for agent prompt definitions
201
- composer = _make_composer(state)
192
+ # Resolve agent definitions via kustomize build
193
+ composer = _make_composer(cfg, state)
202
194
 
203
195
  def _on_cycle(summary: dict[str, object]) -> None:
204
196
  """Print a rich status line after each orchestrator cycle."""
@@ -0,0 +1,288 @@
1
+ """Prompt composition — kustomize-resolved templates + runtime context.
2
+
3
+ Three layers composed at spawn time:
4
+ 1. Base + project overlay via kustomize (resolved at startup)
5
+ 2. Process overlay from specs/prompts/{role}-overlay.yaml (injected at spawn time)
6
+ 3. Task context: spec content, findings, traceability refs (spec_ref, task_id)
7
+
8
+ The orchestrator runs ``kustomize build`` at startup to resolve layers 1+2 into
9
+ AgentTemplate objects. Layer 3 (process overlay + task context) is injected at
10
+ spawn time because it changes during the loop.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import shutil
17
+ import subprocess
18
+ import tempfile
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, cast
22
+
23
+ import yaml
24
+
25
+ if TYPE_CHECKING:
26
+ from hyperloop.ports.state import StateStore
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ HYPERLOOP_BASE_REF = "github.com/jsell-rh/hyperloop//base?ref=main"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class AgentTemplate:
35
+ """A resolved agent definition from kustomize build."""
36
+
37
+ name: str
38
+ prompt: str
39
+ annotations: dict[str, str]
40
+
41
+
42
+ class PromptComposer:
43
+ """Composes agent prompts from kustomize-resolved templates + runtime context."""
44
+
45
+ def __init__(self, templates: dict[str, AgentTemplate], state: StateStore) -> None:
46
+ """
47
+ Args:
48
+ templates: role name -> resolved agent definition (from kustomize build).
49
+ state: StateStore for reading process overlays and spec files at spawn time.
50
+ """
51
+ self._templates = templates
52
+ self._state = state
53
+
54
+ @classmethod
55
+ def from_kustomize(
56
+ cls,
57
+ overlay: str | None,
58
+ state: StateStore,
59
+ base_ref: str = HYPERLOOP_BASE_REF,
60
+ ) -> PromptComposer:
61
+ """Resolve templates via kustomize build, then construct.
62
+
63
+ Args:
64
+ overlay: Path or git URL to a kustomization directory. If None,
65
+ a temporary kustomization referencing the hyperloop base
66
+ is created and built.
67
+ state: StateStore for reading process overlays at spawn time.
68
+ base_ref: Kustomize remote resource for the base definitions.
69
+ Configurable via .hyperloop.yaml or HYPERLOOP_BASE_REF env var.
70
+
71
+ Returns:
72
+ A PromptComposer with resolved templates.
73
+
74
+ Raises:
75
+ RuntimeError: If kustomize build fails.
76
+ """
77
+ raw_yaml = _kustomize_build(overlay, base_ref=base_ref)
78
+ templates = _parse_multi_doc(raw_yaml)
79
+ return cls(templates, state)
80
+
81
+ def compose(
82
+ self,
83
+ role: str,
84
+ task_id: str,
85
+ spec_ref: str,
86
+ findings: str,
87
+ ) -> str:
88
+ """Compose the full prompt for a worker.
89
+
90
+ Layers:
91
+ 1. Resolved template prompt (from kustomize, includes base + project overlay)
92
+ 2. Process overlay from specs/prompts/{role}-overlay.yaml (if exists)
93
+ 3. Task context: spec content, findings, traceability refs
94
+
95
+ Args:
96
+ role: Agent role name (e.g. "implementer", "verifier").
97
+ task_id: Task identifier (e.g. "task-027").
98
+ spec_ref: Path to the originating spec file (e.g. "specs/persistence.md").
99
+ findings: Findings from prior rounds (empty string if none).
100
+
101
+ Returns:
102
+ The composed prompt string ready to pass to a worker.
103
+
104
+ Raises:
105
+ ValueError: If the role has no resolved template.
106
+ """
107
+ if role not in self._templates:
108
+ msg = f"Unknown role '{role}': no agent template resolved for this role"
109
+ raise ValueError(msg)
110
+
111
+ template = self._templates[role]
112
+ prompt = template.prompt.replace("{spec_ref}", spec_ref).replace("{task_id}", task_id)
113
+
114
+ # Layer 2: Process overlay (from target repo specs/prompts/)
115
+ overlay_path = f"specs/prompts/{role}-overlay.yaml"
116
+ overlay_content = self._state.read_file(overlay_path)
117
+ overlay_text = ""
118
+ if overlay_content is not None:
119
+ overlay_text = self._extract_overlay_prompt(overlay_content)
120
+
121
+ # Layer 3: Task context — spec content
122
+ spec_content = self._state.read_file(spec_ref)
123
+
124
+ # Assemble the final prompt
125
+ sections: list[str] = [prompt.rstrip()]
126
+
127
+ if overlay_text:
128
+ sections.append(f"## Process Overlay\n{overlay_text}")
129
+
130
+ if spec_content is not None:
131
+ sections.append(f"## Spec\n{spec_content}")
132
+ else:
133
+ sections.append(
134
+ f"## Spec\n[Spec file '{spec_ref}' not found. Proceed with available context.]"
135
+ )
136
+
137
+ if findings:
138
+ sections.append(f"## Findings\n{findings}")
139
+
140
+ return "\n\n".join(sections) + "\n"
141
+
142
+ @staticmethod
143
+ def _extract_overlay_prompt(raw_yaml: str) -> str:
144
+ """Extract prompt or content from overlay YAML.
145
+
146
+ Overlay files may contain a 'prompt' field (like agent definitions)
147
+ or raw text content. Handles both.
148
+ """
149
+ try:
150
+ doc = yaml.safe_load(raw_yaml)
151
+ if isinstance(doc, dict) and "prompt" in doc:
152
+ return str(cast("dict[str, object]", doc)["prompt"]).strip()
153
+ except yaml.YAMLError:
154
+ pass
155
+ # Fall back to raw content
156
+ return raw_yaml.strip()
157
+
158
+
159
+ def _kustomize_build(overlay: str | None, base_ref: str = HYPERLOOP_BASE_REF) -> str:
160
+ """Run ``kustomize build`` and return the raw YAML output.
161
+
162
+ Args:
163
+ overlay: Path or git URL to a kustomization directory.
164
+ If None, builds from a temporary kustomization that
165
+ references the hyperloop base.
166
+ base_ref: Kustomize remote resource for the base definitions.
167
+
168
+ Returns:
169
+ The multi-document YAML output from kustomize build.
170
+
171
+ Raises:
172
+ RuntimeError: If kustomize build exits non-zero.
173
+ """
174
+ if overlay is not None:
175
+ return _run_kustomize(overlay)
176
+
177
+ # No overlay — build a temp kustomization referencing the base
178
+ with tempfile.TemporaryDirectory() as tmp:
179
+ kustomization = Path(tmp) / "kustomization.yaml"
180
+ kustomization.write_text(f"resources:\n - {base_ref}\n")
181
+ return _run_kustomize(tmp)
182
+
183
+
184
+ def _run_kustomize(target: str) -> str:
185
+ """Execute ``kustomize build <target>`` and return stdout.
186
+
187
+ Raises:
188
+ RuntimeError: If the command fails.
189
+ """
190
+ result = subprocess.run(
191
+ ["kustomize", "build", target],
192
+ capture_output=True,
193
+ text=True,
194
+ timeout=120,
195
+ )
196
+ if result.returncode != 0:
197
+ msg = f"kustomize build failed (exit {result.returncode}): {result.stderr.strip()}"
198
+ raise RuntimeError(msg)
199
+ return result.stdout
200
+
201
+
202
+ def _extract_name(doc: dict[str, object]) -> str:
203
+ """Extract the resource name from a YAML document.
204
+
205
+ Supports both kustomize-style ``metadata.name`` and legacy top-level
206
+ ``name`` for backward compatibility.
207
+ """
208
+ metadata = doc.get("metadata")
209
+ if isinstance(metadata, dict) and "name" in metadata:
210
+ return str(metadata["name"])
211
+ return str(doc.get("name", ""))
212
+
213
+
214
+ def _parse_multi_doc(raw: str) -> dict[str, AgentTemplate]:
215
+ """Parse multi-document YAML output into AgentTemplate objects.
216
+
217
+ Only documents with ``kind: Agent`` are extracted. Process definitions
218
+ and other kinds are ignored for prompt composition purposes.
219
+
220
+ Args:
221
+ raw: Multi-document YAML string from kustomize build.
222
+
223
+ Returns:
224
+ A dict mapping agent name -> AgentTemplate.
225
+ """
226
+ templates: dict[str, AgentTemplate] = {}
227
+ for doc in yaml.safe_load_all(raw):
228
+ if not isinstance(doc, dict):
229
+ continue
230
+ if doc.get("kind") != "Agent":
231
+ continue
232
+ name = _extract_name(doc)
233
+ prompt = doc.get("prompt", "")
234
+ annotations = doc.get("annotations", {})
235
+ if not isinstance(annotations, dict):
236
+ annotations = {}
237
+ templates[name] = AgentTemplate(
238
+ name=name,
239
+ prompt=str(prompt),
240
+ annotations={str(k): str(v) for k, v in annotations.items()},
241
+ )
242
+ return templates
243
+
244
+
245
+ def check_kustomize_available() -> None:
246
+ """Check that kustomize is on PATH.
247
+
248
+ Raises:
249
+ SystemExit: If kustomize is not found.
250
+ """
251
+ if shutil.which("kustomize") is None:
252
+ msg = (
253
+ "Error: kustomize CLI not found. "
254
+ "Install it: https://kubectl.docs.kubernetes.io/installation/kustomize/"
255
+ )
256
+ raise SystemExit(msg)
257
+
258
+
259
+ def load_templates_from_dir(base_dir: str | Path) -> dict[str, AgentTemplate]:
260
+ """Load agent templates directly from a directory of YAML files.
261
+
262
+ This is used for testing and as a fallback when kustomize is not available.
263
+ It reads all ``*.yaml`` files in ``base_dir`` and extracts Agent definitions.
264
+
265
+ Args:
266
+ base_dir: Path to the directory containing base agent YAML files.
267
+
268
+ Returns:
269
+ A dict mapping agent name -> AgentTemplate.
270
+ """
271
+ templates: dict[str, AgentTemplate] = {}
272
+ base_path = Path(base_dir)
273
+ for yaml_file in base_path.glob("*.yaml"):
274
+ if yaml_file.name == "kustomization.yaml":
275
+ continue
276
+ with open(yaml_file) as f:
277
+ doc = yaml.safe_load(f)
278
+ if doc and doc.get("kind") == "Agent" and "prompt" in doc:
279
+ name = _extract_name(doc)
280
+ annotations = doc.get("annotations", {})
281
+ if not isinstance(annotations, dict):
282
+ annotations = {}
283
+ templates[name] = AgentTemplate(
284
+ name=name,
285
+ prompt=doc["prompt"],
286
+ annotations={str(k): str(v) for k, v in annotations.items()},
287
+ )
288
+ return templates
@@ -6,11 +6,14 @@ returns a frozen Config dataclass. CLI arguments can override file values.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import os
9
10
  from dataclasses import dataclass
10
11
  from typing import TYPE_CHECKING, cast
11
12
 
12
13
  import yaml
13
14
 
15
+ DEFAULT_BASE_REF = "github.com/jsell-rh/hyperloop//base?ref=main"
16
+
14
17
  if TYPE_CHECKING:
15
18
  from pathlib import Path
16
19
 
@@ -27,6 +30,7 @@ class Config:
27
30
  base_branch: str # default: "main"
28
31
  specs_dir: str # default: "specs"
29
32
  overlay: str | None # path or git URL to kustomization dir
33
+ base_ref: str # kustomize remote base ref (env: HYPERLOOP_BASE_REF)
30
34
  runtime: str # "local" (v1 only)
31
35
  max_workers: int # default: 6
32
36
  auto_merge: bool # default: True
@@ -44,6 +48,7 @@ def _defaults() -> dict[str, object]:
44
48
  "base_branch": "main",
45
49
  "specs_dir": "specs",
46
50
  "overlay": None,
51
+ "base_ref": os.environ.get("HYPERLOOP_BASE_REF", DEFAULT_BASE_REF),
47
52
  "runtime": "local",
48
53
  "max_workers": 6,
49
54
  "auto_merge": True,
@@ -63,7 +68,7 @@ def _flatten_yaml(raw: dict[str, object]) -> dict[str, object]:
63
68
  flat: dict[str, object] = {}
64
69
 
65
70
  # Top-level scalars
66
- for key in ("overlay", "poll_interval", "max_rounds", "max_rebase_attempts"):
71
+ for key in ("overlay", "base_ref", "poll_interval", "max_rounds", "max_rebase_attempts"):
67
72
  if key in raw:
68
73
  flat[key] = raw[key]
69
74
 
@@ -150,6 +155,7 @@ def load_config(
150
155
  base_branch=str(values["base_branch"]),
151
156
  specs_dir=str(values["specs_dir"]),
152
157
  overlay=values["overlay"] if values["overlay"] is not None else None, # type: ignore[arg-type]
158
+ base_ref=str(values["base_ref"]),
153
159
  runtime=str(values["runtime"]),
154
160
  max_workers=int(values["max_workers"]), # type: ignore[arg-type]
155
161
  auto_merge=bool(values["auto_merge"]),
@@ -1,15 +1,17 @@
1
- """Tests for prompt composition — base + overlay + task context.
1
+ """Tests for prompt composition — resolved templates + overlay + task context.
2
2
 
3
- Uses InMemoryStateStore with pre-loaded files. No real filesystem.
3
+ Uses InMemoryStateStore with pre-loaded files and pre-resolved AgentTemplate
4
+ objects. No kustomize dependency — unit tests skip the kustomize build step.
4
5
  """
5
6
 
6
7
  from __future__ import annotations
7
8
 
9
+ import shutil
8
10
  from pathlib import Path
9
11
 
10
12
  import pytest
11
13
 
12
- from hyperloop.compose import PromptComposer
14
+ from hyperloop.compose import AgentTemplate, PromptComposer, load_templates_from_dir
13
15
  from tests.fakes.state import InMemoryStateStore
14
16
 
15
17
  # The base/ dir lives at the repo root, adjacent to src/
@@ -21,6 +23,11 @@ BASE_DIR = Path(__file__).parent.parent / "base"
21
23
  # ---------------------------------------------------------------------------
22
24
 
23
25
 
26
+ def _templates() -> dict[str, AgentTemplate]:
27
+ """Load base templates from the repo's base/ directory."""
28
+ return load_templates_from_dir(BASE_DIR)
29
+
30
+
24
31
  def _state_with_spec(
25
32
  spec_ref: str = "specs/widget.md", spec_content: str = "Build a widget."
26
33
  ) -> InMemoryStateStore:
@@ -40,7 +47,7 @@ class TestBasePromptOnly:
40
47
 
41
48
  def test_compose_returns_base_prompt_content(self) -> None:
42
49
  state = _state_with_spec()
43
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
50
+ composer = PromptComposer(templates=_templates(), state=state)
44
51
 
45
52
  result = composer.compose(
46
53
  role="implementer",
@@ -54,7 +61,7 @@ class TestBasePromptOnly:
54
61
 
55
62
  def test_compose_includes_spec_content(self) -> None:
56
63
  state = _state_with_spec()
57
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
64
+ composer = PromptComposer(templates=_templates(), state=state)
58
65
 
59
66
  result = composer.compose(
60
67
  role="implementer",
@@ -67,7 +74,7 @@ class TestBasePromptOnly:
67
74
 
68
75
  def test_compose_includes_no_findings_when_empty(self) -> None:
69
76
  state = _state_with_spec()
70
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
77
+ composer = PromptComposer(templates=_templates(), state=state)
71
78
 
72
79
  result = composer.compose(
73
80
  role="implementer",
@@ -85,7 +92,7 @@ class TestTemplateVariables:
85
92
 
86
93
  def test_spec_ref_is_replaced(self) -> None:
87
94
  state = _state_with_spec()
88
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
95
+ composer = PromptComposer(templates=_templates(), state=state)
89
96
 
90
97
  result = composer.compose(
91
98
  role="implementer",
@@ -99,7 +106,7 @@ class TestTemplateVariables:
99
106
 
100
107
  def test_task_id_is_replaced(self) -> None:
101
108
  state = _state_with_spec()
102
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
109
+ composer = PromptComposer(templates=_templates(), state=state)
103
110
 
104
111
  result = composer.compose(
105
112
  role="implementer",
@@ -120,7 +127,7 @@ class TestProcessOverlay:
120
127
  overlay_content = "prompt: |\n Always run linter before submitting.\n"
121
128
  state.set_file("specs/prompts/implementer-overlay.yaml", overlay_content)
122
129
 
123
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
130
+ composer = PromptComposer(templates=_templates(), state=state)
124
131
 
125
132
  result = composer.compose(
126
133
  role="implementer",
@@ -136,7 +143,7 @@ class TestProcessOverlay:
136
143
  state = _state_with_spec()
137
144
  # No overlay file set
138
145
 
139
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
146
+ composer = PromptComposer(templates=_templates(), state=state)
140
147
 
141
148
  result = composer.compose(
142
149
  role="implementer",
@@ -154,7 +161,7 @@ class TestFindings:
154
161
 
155
162
  def test_findings_are_appended(self) -> None:
156
163
  state = _state_with_spec()
157
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
164
+ composer = PromptComposer(templates=_templates(), state=state)
158
165
 
159
166
  result = composer.compose(
160
167
  role="implementer",
@@ -172,7 +179,7 @@ class TestUnknownRole:
172
179
 
173
180
  def test_unknown_role_raises_value_error(self) -> None:
174
181
  state = _state_with_spec()
175
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
182
+ composer = PromptComposer(templates=_templates(), state=state)
176
183
 
177
184
  with pytest.raises(ValueError, match=r"(?i)unknown.*role.*nonexistent"):
178
185
  composer.compose(
@@ -189,7 +196,7 @@ class TestMissingSpecRef:
189
196
  def test_missing_spec_still_composes(self) -> None:
190
197
  state = InMemoryStateStore()
191
198
  # No spec file set — spec_ref points to nothing
192
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
199
+ composer = PromptComposer(templates=_templates(), state=state)
193
200
 
194
201
  result = composer.compose(
195
202
  role="implementer",
@@ -203,7 +210,7 @@ class TestMissingSpecRef:
203
210
 
204
211
  def test_missing_spec_notes_absence(self) -> None:
205
212
  state = InMemoryStateStore()
206
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
213
+ composer = PromptComposer(templates=_templates(), state=state)
207
214
 
208
215
  result = composer.compose(
209
216
  role="implementer",
@@ -228,7 +235,7 @@ class TestAllRoles:
228
235
  )
229
236
  def test_all_base_roles_compose(self, role: str) -> None:
230
237
  state = _state_with_spec()
231
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
238
+ composer = PromptComposer(templates=_templates(), state=state)
232
239
 
233
240
  result = composer.compose(
234
241
  role=role,
@@ -238,3 +245,148 @@ class TestAllRoles:
238
245
  )
239
246
 
240
247
  assert len(result) > 0
248
+
249
+
250
+ class TestLoadTemplatesFromDir:
251
+ """load_templates_from_dir reads YAML files and builds AgentTemplate objects."""
252
+
253
+ def test_loads_all_base_agents(self) -> None:
254
+ templates = load_templates_from_dir(BASE_DIR)
255
+ assert "implementer" in templates
256
+ assert "verifier" in templates
257
+ assert "pm" in templates
258
+ assert "process-improver" in templates
259
+ assert "rebase-resolver" in templates
260
+
261
+ def test_skips_non_agent_kinds(self) -> None:
262
+ """Process definitions (kind: Process) are not loaded as templates."""
263
+ templates = load_templates_from_dir(BASE_DIR)
264
+ assert "default" not in templates # process.yaml has name: default
265
+
266
+ def test_template_has_prompt(self) -> None:
267
+ templates = load_templates_from_dir(BASE_DIR)
268
+ impl = templates["implementer"]
269
+ assert "You are a worker agent implementing a task" in impl.prompt
270
+
271
+ def test_template_has_annotations(self) -> None:
272
+ templates = load_templates_from_dir(BASE_DIR)
273
+ impl = templates["implementer"]
274
+ assert "ambient.io/persona" in impl.annotations
275
+
276
+
277
+ class TestAgentTemplate:
278
+ """AgentTemplate is a frozen dataclass."""
279
+
280
+ def test_frozen(self) -> None:
281
+ t = AgentTemplate(name="test", prompt="hello", annotations={})
282
+ with pytest.raises(AttributeError):
283
+ t.name = "other" # type: ignore[misc]
284
+
285
+ def test_equality(self) -> None:
286
+ a = AgentTemplate(name="x", prompt="p", annotations={"k": "v"})
287
+ b = AgentTemplate(name="x", prompt="p", annotations={"k": "v"})
288
+ assert a == b
289
+
290
+
291
+ class TestParseMultiDoc:
292
+ """_parse_multi_doc extracts Agent definitions from multi-document YAML."""
293
+
294
+ def test_parses_agent_definitions(self) -> None:
295
+ from hyperloop.compose import _parse_multi_doc
296
+
297
+ raw = """\
298
+ apiVersion: hyperloop.io/v1
299
+ kind: Agent
300
+ metadata:
301
+ name: implementer
302
+ prompt: |
303
+ You are a worker.
304
+ annotations:
305
+ ambient.io/persona: ""
306
+ ---
307
+ apiVersion: hyperloop.io/v1
308
+ kind: Process
309
+ metadata:
310
+ name: default
311
+ pipeline:
312
+ - role: implementer
313
+ ---
314
+ apiVersion: hyperloop.io/v1
315
+ kind: Agent
316
+ metadata:
317
+ name: verifier
318
+ prompt: |
319
+ You are a reviewer.
320
+ annotations: {}
321
+ """
322
+ templates = _parse_multi_doc(raw)
323
+ assert "implementer" in templates
324
+ assert "verifier" in templates
325
+ assert "default" not in templates # Process kind skipped
326
+
327
+ def test_handles_empty_input(self) -> None:
328
+ from hyperloop.compose import _parse_multi_doc
329
+
330
+ templates = _parse_multi_doc("")
331
+ assert templates == {}
332
+
333
+
334
+ class TestKustomizeIntegration:
335
+ """Integration tests that require kustomize on PATH."""
336
+
337
+ @pytest.mark.skipif(
338
+ not shutil.which("kustomize"),
339
+ reason="kustomize CLI not available",
340
+ )
341
+ def test_from_kustomize_with_local_base(self, tmp_path: Path) -> None:
342
+ """Build from a local kustomization that references the base dir."""
343
+ import os
344
+
345
+ # kustomize requires relative paths for local resources
346
+ rel_base = os.path.relpath(BASE_DIR, tmp_path)
347
+ kustomization = tmp_path / "kustomization.yaml"
348
+ kustomization.write_text(f"resources:\n - {rel_base}\n")
349
+
350
+ state = _state_with_spec()
351
+ composer = PromptComposer.from_kustomize(str(tmp_path), state)
352
+
353
+ result = composer.compose(
354
+ role="implementer",
355
+ task_id="task-001",
356
+ spec_ref="specs/widget.md",
357
+ findings="",
358
+ )
359
+ assert "You are a worker agent implementing a task" in result
360
+
361
+ @pytest.mark.skipif(
362
+ not shutil.which("kustomize"),
363
+ reason="kustomize CLI not available",
364
+ )
365
+ def test_from_kustomize_no_overlay_fetches_base(self) -> None:
366
+ """When overlay is None, builds from the hyperloop base remote resource."""
367
+ state = _state_with_spec()
368
+ # This actually hits GitHub — skip in CI if network is unavailable
369
+ try:
370
+ composer = PromptComposer.from_kustomize(None, state)
371
+ except RuntimeError:
372
+ pytest.skip("Network unavailable — cannot fetch remote base")
373
+
374
+ result = composer.compose(
375
+ role="implementer",
376
+ task_id="task-001",
377
+ spec_ref="specs/widget.md",
378
+ findings="",
379
+ )
380
+ assert len(result) > 0
381
+
382
+
383
+ class TestCheckKustomize:
384
+ """check_kustomize_available raises SystemExit when kustomize is missing."""
385
+
386
+ def test_raises_when_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
387
+ from hyperloop.compose import check_kustomize_available
388
+
389
+ monkeypatch.setattr(shutil, "which", lambda _name: None)
390
+
391
+ with pytest.raises(SystemExit, match="kustomize CLI not found"):
392
+ check_kustomize_available()
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING
11
11
  if TYPE_CHECKING:
12
12
  from collections.abc import Callable
13
13
 
14
- from hyperloop.compose import PromptComposer
14
+ from hyperloop.compose import PromptComposer, load_templates_from_dir
15
15
  from hyperloop.domain.model import (
16
16
  ActionStep,
17
17
  GateStep,
@@ -999,7 +999,7 @@ class TestPromptComposition:
999
999
  state.add_task(_task())
1000
1000
  state.set_file("specs/task-001.md", "Build a widget.")
1001
1001
 
1002
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
1002
+ composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
1003
1003
  orch = _make_orchestrator(state, runtime, composer=composer)
1004
1004
 
1005
1005
  # Cycle 1: spawn implementer
@@ -1028,7 +1028,7 @@ class TestPromptComposition:
1028
1028
  state.set_file("specs/task-001.md", "Build a widget.")
1029
1029
  state.store_findings("task-001", "Missing null check.\n")
1030
1030
 
1031
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
1031
+ composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
1032
1032
  orch = _make_orchestrator(state, runtime, composer=composer)
1033
1033
 
1034
1034
  orch.run_cycle()
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from pathlib import Path
11
11
 
12
- from hyperloop.compose import PromptComposer
12
+ from hyperloop.compose import PromptComposer, load_templates_from_dir
13
13
  from hyperloop.domain.model import (
14
14
  LoopStep,
15
15
  Phase,
@@ -230,7 +230,7 @@ class TestPMIntake:
230
230
  serial = FakeSerialRunner()
231
231
  state.set_file("specs/widget.md", "Widget spec content")
232
232
 
233
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
233
+ composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
234
234
  orch = _make_orchestrator(state, runtime, serial_runner=serial, composer=composer)
235
235
 
236
236
  orch.run_cycle()
@@ -246,7 +246,7 @@ class TestPMIntake:
246
246
  state.set_file("specs/widget.md", "Widget spec content")
247
247
  state.add_task(_task(spec_ref="specs/widget.md"))
248
248
 
249
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
249
+ composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
250
250
  orch = _make_orchestrator(state, runtime, serial_runner=serial, composer=composer)
251
251
 
252
252
  orch.run_cycle()
@@ -288,7 +288,7 @@ class TestPMIntake:
288
288
  state.set_file("specs/widget.md", "Widget spec")
289
289
  state.add_task(_task(spec_ref="specs/widget.md"))
290
290
 
291
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
291
+ composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
292
292
  orch = _make_orchestrator(state, runtime, serial_runner=serial, composer=composer)
293
293
 
294
294
  orch.run_cycle()
@@ -316,7 +316,7 @@ class TestProcessImprover:
316
316
  state.add_task(_task())
317
317
  state.set_file("specs/widget.md", "Widget spec")
318
318
 
319
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
319
+ composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
320
320
  orch = _make_orchestrator(
321
321
  state, runtime, serial_runner=serial, composer=composer, max_rounds=50
322
322
  )
@@ -345,7 +345,7 @@ class TestProcessImprover:
345
345
  state.add_task(_task())
346
346
  state.set_file("specs/widget.md", "Widget spec")
347
347
 
348
- composer = PromptComposer(base_dir=BASE_DIR, state=state)
348
+ composer = PromptComposer(templates=load_templates_from_dir(BASE_DIR), state=state)
349
349
  orch = _make_orchestrator(state, runtime, serial_runner=serial, composer=composer)
350
350
 
351
351
  # Cycle 1: spawn implementer
@@ -61,7 +61,7 @@ wheels = [
61
61
 
62
62
  [[package]]
63
63
  name = "hyperloop"
64
- version = "0.2.0"
64
+ version = "0.4.0"
65
65
  source = { editable = "." }
66
66
  dependencies = [
67
67
  { name = "pyyaml" },
@@ -1,19 +0,0 @@
1
- # CHANGELOG
2
-
3
- <!-- version list -->
4
-
5
- ## v0.4.0 (2026-04-15)
6
-
7
-
8
- ## v0.3.0 (2026-04-15)
9
-
10
-
11
- ## v0.2.0 (2026-04-15)
12
-
13
-
14
- ## v0.1.1 (2026-04-15)
15
-
16
-
17
- ## v0.1.0 (2026-04-15)
18
-
19
- - Initial Release
@@ -1,126 +0,0 @@
1
- """Prompt composition — assembles worker prompts from base + overlay + task context.
2
-
3
- Three layers composed at spawn time:
4
- 1. Base prompt from base/{role}.yaml
5
- 2. Process overlay from specs/prompts/{role}-overlay.yaml (if exists)
6
- 3. Task context: spec content, findings, traceability refs (spec_ref, task_id)
7
-
8
- For v1, kustomize integration (project overlay) is skipped. The orchestrator
9
- reads base YAML files directly and injects process overlays + task context.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- from pathlib import Path
15
- from typing import TYPE_CHECKING, cast
16
-
17
- import yaml
18
-
19
- if TYPE_CHECKING:
20
- from hyperloop.ports.state import StateStore
21
-
22
-
23
- class PromptComposer:
24
- """Composes agent prompts from base definitions + overlays + task context."""
25
-
26
- def __init__(self, base_dir: str | Path, state: StateStore) -> None:
27
- """Load base agent definitions from base_dir.
28
-
29
- Args:
30
- base_dir: Path to the directory containing base agent YAML files.
31
- state: StateStore used to read process overlays and spec files
32
- from the target repo.
33
- """
34
- self._base_dir = Path(base_dir)
35
- self._state = state
36
- self._base_prompts: dict[str, str] = {}
37
- self._load_base_definitions()
38
-
39
- def _load_base_definitions(self) -> None:
40
- """Load all base agent YAML files and extract their prompt fields."""
41
- for yaml_file in self._base_dir.glob("*.yaml"):
42
- with open(yaml_file) as f:
43
- doc = yaml.safe_load(f)
44
- if doc and doc.get("kind") == "Agent" and "prompt" in doc:
45
- name = doc["name"]
46
- self._base_prompts[name] = doc["prompt"]
47
-
48
- def compose(
49
- self,
50
- role: str,
51
- task_id: str,
52
- spec_ref: str,
53
- findings: str,
54
- ) -> str:
55
- """Compose the full prompt for a worker.
56
-
57
- Layers:
58
- 1. Base prompt from base/{role}.yaml
59
- 2. Process overlay from specs/prompts/{role}-overlay.yaml (if exists)
60
- 3. Task context: spec content, findings, traceability refs
61
-
62
- Args:
63
- role: Agent role name (e.g. "implementer", "verifier").
64
- task_id: Task identifier (e.g. "task-027").
65
- spec_ref: Path to the originating spec file (e.g. "specs/persistence.md").
66
- findings: Findings from prior rounds (empty string if none).
67
-
68
- Returns:
69
- The composed prompt string ready to pass to a worker.
70
-
71
- Raises:
72
- ValueError: If the role has no base agent definition.
73
- """
74
- # Layer 1: Base prompt
75
- if role not in self._base_prompts:
76
- msg = f"Unknown role '{role}': no base agent definition found in {self._base_dir}"
77
- raise ValueError(msg)
78
-
79
- base_prompt = self._base_prompts[role]
80
-
81
- # Replace template variables
82
- prompt = base_prompt.replace("{spec_ref}", spec_ref).replace("{task_id}", task_id)
83
-
84
- # Layer 2: Process overlay (from target repo specs/prompts/)
85
- overlay_path = f"specs/prompts/{role}-overlay.yaml"
86
- overlay_content = self._state.read_file(overlay_path)
87
- overlay_text = ""
88
- if overlay_content is not None:
89
- overlay_text = self._extract_overlay_prompt(overlay_content)
90
-
91
- # Layer 3: Task context — spec content
92
- spec_content = self._state.read_file(spec_ref)
93
-
94
- # Assemble the final prompt
95
- sections: list[str] = [prompt.rstrip()]
96
-
97
- if overlay_text:
98
- sections.append(f"## Process Overlay\n{overlay_text}")
99
-
100
- if spec_content is not None:
101
- sections.append(f"## Spec\n{spec_content}")
102
- else:
103
- sections.append(
104
- f"## Spec\n[Spec file '{spec_ref}' not found. Proceed with available context.]"
105
- )
106
-
107
- if findings:
108
- sections.append(f"## Findings\n{findings}")
109
-
110
- return "\n\n".join(sections) + "\n"
111
-
112
- @staticmethod
113
- def _extract_overlay_prompt(raw_yaml: str) -> str:
114
- """Extract prompt or content from overlay YAML.
115
-
116
- Overlay files may contain a 'prompt' field (like agent definitions)
117
- or raw text content. Handles both.
118
- """
119
- try:
120
- doc = yaml.safe_load(raw_yaml)
121
- if isinstance(doc, dict) and "prompt" in doc:
122
- return str(cast("dict[str, object]", doc)["prompt"]).strip()
123
- except yaml.YAMLError:
124
- pass
125
- # Fall back to raw content
126
- return raw_yaml.strip()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes