hyperloop 0.3.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.3.0 → hyperloop-0.5.0}/PKG-INFO +3 -1
  3. {hyperloop-0.3.0 → hyperloop-0.5.0}/README.md +2 -0
  4. {hyperloop-0.3.0 → hyperloop-0.5.0}/base/implementer.yaml +3 -1
  5. hyperloop-0.5.0/base/kustomization.yaml +7 -0
  6. {hyperloop-0.3.0 → hyperloop-0.5.0}/base/pm.yaml +3 -1
  7. {hyperloop-0.3.0 → hyperloop-0.5.0}/base/process-improver.yaml +3 -1
  8. {hyperloop-0.3.0 → hyperloop-0.5.0}/base/process.yaml +3 -1
  9. {hyperloop-0.3.0 → hyperloop-0.5.0}/base/rebase-resolver.yaml +3 -1
  10. {hyperloop-0.3.0 → hyperloop-0.5.0}/base/verifier.yaml +3 -1
  11. {hyperloop-0.3.0 → hyperloop-0.5.0}/pyproject.toml +1 -1
  12. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/adapters/git_state.py +6 -0
  13. hyperloop-0.5.0/src/hyperloop/adapters/serial.py +50 -0
  14. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/cli.py +58 -0
  15. hyperloop-0.5.0/src/hyperloop/compose.py +288 -0
  16. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/config.py +7 -1
  17. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/loop.py +147 -12
  18. hyperloop-0.5.0/src/hyperloop/ports/serial.py +24 -0
  19. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/ports/state.py +4 -0
  20. hyperloop-0.5.0/tests/fakes/serial.py +47 -0
  21. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/fakes/state.py +10 -0
  22. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_compose.py +167 -15
  23. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_e2e.py +6 -0
  24. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_loop.py +144 -3
  25. hyperloop-0.5.0/tests/test_serial_agents.py +454 -0
  26. {hyperloop-0.3.0 → hyperloop-0.5.0}/uv.lock +1 -1
  27. hyperloop-0.3.0/CHANGELOG.md +0 -16
  28. hyperloop-0.3.0/src/hyperloop/compose.py +0 -126
  29. {hyperloop-0.3.0 → hyperloop-0.5.0}/.github/workflows/ci.yaml +0 -0
  30. {hyperloop-0.3.0 → hyperloop-0.5.0}/.github/workflows/release.yaml +0 -0
  31. {hyperloop-0.3.0 → hyperloop-0.5.0}/.gitignore +0 -0
  32. {hyperloop-0.3.0 → hyperloop-0.5.0}/.pre-commit-config.yaml +0 -0
  33. {hyperloop-0.3.0 → hyperloop-0.5.0}/.python-version +0 -0
  34. {hyperloop-0.3.0 → hyperloop-0.5.0}/CLAUDE.md +0 -0
  35. {hyperloop-0.3.0 → hyperloop-0.5.0}/specs/spec.md +0 -0
  36. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/__init__.py +0 -0
  37. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/__main__.py +0 -0
  38. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/adapters/__init__.py +0 -0
  39. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/adapters/local.py +0 -0
  40. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/domain/__init__.py +0 -0
  41. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/domain/decide.py +0 -0
  42. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/domain/deps.py +0 -0
  43. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/domain/model.py +0 -0
  44. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/domain/pipeline.py +0 -0
  45. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/ports/__init__.py +0 -0
  46. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/ports/pr.py +0 -0
  47. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/ports/runtime.py +0 -0
  48. {hyperloop-0.3.0 → hyperloop-0.5.0}/src/hyperloop/pr.py +0 -0
  49. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/__init__.py +0 -0
  50. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/fakes/__init__.py +0 -0
  51. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/fakes/pr.py +0 -0
  52. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/fakes/runtime.py +0 -0
  53. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_cli.py +0 -0
  54. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_config.py +0 -0
  55. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_decide.py +0 -0
  56. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_deps.py +0 -0
  57. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_fakes.py +0 -0
  58. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_git_state.py +0 -0
  59. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_local_runtime.py +0 -0
  60. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_model.py +0 -0
  61. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_pipeline.py +0 -0
  62. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_pr.py +0 -0
  63. {hyperloop-0.3.0 → hyperloop-0.5.0}/tests/test_smoke.py +0 -0
  64. {hyperloop-0.3.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.3.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.3.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"
@@ -200,6 +200,12 @@ class GitStateStore:
200
200
  """Record a last-run marker."""
201
201
  self._epochs[key] = value
202
202
 
203
+ def list_files(self, pattern: str) -> list[str]:
204
+ """List file paths matching a glob pattern relative to the repo root."""
205
+ return sorted(
206
+ str(p.relative_to(self._repo)) for p in self._repo.glob(pattern) if p.is_file()
207
+ )
208
+
203
209
  def read_file(self, path: str) -> str | None:
204
210
  """Read a file from the repo. Returns None if it does not exist."""
205
211
  file_path = self._repo / path
@@ -0,0 +1,50 @@
1
+ """SubprocessSerialRunner — runs serial agents via CLI subprocess on trunk.
2
+
3
+ Used for PM intake and process-improver. Blocks until the agent completes.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import subprocess
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SubprocessSerialRunner:
15
+ """Run a serial agent as a subprocess in the repo directory."""
16
+
17
+ _DEFAULT_CMD = "claude --dangerously-skip-permissions"
18
+
19
+ def __init__(self, repo_path: str, command: str = _DEFAULT_CMD) -> None:
20
+ self._repo_path = repo_path
21
+ self._command = command
22
+
23
+ def run(self, role: str, prompt: str) -> bool:
24
+ """Execute a serial agent with the given prompt. Blocks until complete."""
25
+ logger.info("Running serial agent: %s", role)
26
+
27
+ try:
28
+ result = subprocess.run(
29
+ self._command.split(),
30
+ input=prompt,
31
+ capture_output=True,
32
+ text=True,
33
+ cwd=self._repo_path,
34
+ timeout=600,
35
+ )
36
+ if result.returncode != 0:
37
+ logger.warning(
38
+ "Serial agent %s failed (exit %d): %s",
39
+ role,
40
+ result.returncode,
41
+ result.stderr[:500],
42
+ )
43
+ return False
44
+
45
+ logger.info("Serial agent %s completed successfully", role)
46
+ return True
47
+
48
+ except subprocess.TimeoutExpired:
49
+ logger.warning("Serial agent %s timed out after 600s", role)
50
+ return False
@@ -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,6 +67,28 @@ def _config_table(cfg: Config) -> Table:
66
67
  return table
67
68
 
68
69
 
70
+ def _make_composer(cfg: object, state: object) -> object:
71
+ """Construct a PromptComposer via kustomize build.
72
+
73
+ Runs ``kustomize build`` on the configured overlay (or the default
74
+ hyperloop base if no overlay is set).
75
+
76
+ Args:
77
+ cfg: Config object with an ``overlay`` attribute.
78
+ state: StateStore instance for reading process overlays at spawn time.
79
+
80
+ Returns:
81
+ A PromptComposer with resolved templates.
82
+ """
83
+ from hyperloop.compose import PromptComposer, check_kustomize_available
84
+
85
+ check_kustomize_available()
86
+
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]
90
+
91
+
69
92
  @app.command()
70
93
  def run(
71
94
  path: Path = typer.Option(
@@ -143,6 +166,7 @@ def run(
143
166
  # 5. Construct runtime and state store, run loop
144
167
  from hyperloop.adapters.git_state import GitStateStore
145
168
  from hyperloop.adapters.local import LocalRuntime
169
+ from hyperloop.adapters.serial import SubprocessSerialRunner
146
170
  from hyperloop.domain.model import ActionStep, LoopStep, Process, RoleStep
147
171
  from hyperloop.loop import Orchestrator
148
172
 
@@ -163,6 +187,36 @@ def run(
163
187
 
164
188
  state = GitStateStore(repo_path, specs_dir=cfg.specs_dir)
165
189
  runtime = LocalRuntime(repo_path=str(repo_path))
190
+ serial_runner = SubprocessSerialRunner(repo_path=str(repo_path))
191
+
192
+ # Resolve agent definitions via kustomize build
193
+ composer = _make_composer(cfg, state)
194
+
195
+ def _on_cycle(summary: dict[str, object]) -> None:
196
+ """Print a rich status line after each orchestrator cycle."""
197
+ cycle = summary.get("cycle", "?")
198
+ tasks = summary.get("tasks", {})
199
+ workers = summary.get("workers", 0)
200
+ halt = summary.get("halt_reason")
201
+
202
+ if isinstance(tasks, dict):
203
+ total = tasks.get("total", 0)
204
+ done = tasks.get("complete", 0)
205
+ in_prog = tasks.get("in_progress", 0)
206
+ failed = tasks.get("failed", 0)
207
+ task_str = (
208
+ f"[dim]{total} total[/dim] "
209
+ f"[green]{done} done[/green] "
210
+ f"[yellow]{in_prog} active[/yellow] "
211
+ f"[red]{failed} failed[/red]"
212
+ )
213
+ else:
214
+ task_str = "[dim]unknown[/dim]"
215
+
216
+ status = f"[bold]cycle {cycle}[/bold] tasks: {task_str} workers: {workers}"
217
+ if halt:
218
+ status += f" [bold]{halt}[/bold]"
219
+ console.print(status)
166
220
 
167
221
  orchestrator = Orchestrator(
168
222
  state=state,
@@ -170,6 +224,10 @@ def run(
170
224
  process=default_process,
171
225
  max_workers=cfg.max_workers,
172
226
  max_rounds=cfg.max_rounds,
227
+ composer=composer,
228
+ serial_runner=serial_runner,
229
+ poll_interval=cfg.poll_interval,
230
+ on_cycle=_on_cycle,
173
231
  )
174
232
 
175
233
  # 6. Recover and run
@@ -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"]),