hyperloop 0.4.0__tar.gz → 0.6.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.6.0/CHANGELOG.md +33 -0
  2. {hyperloop-0.4.0 → hyperloop-0.6.0}/PKG-INFO +3 -1
  3. {hyperloop-0.4.0 → hyperloop-0.6.0}/README.md +2 -0
  4. {hyperloop-0.4.0 → hyperloop-0.6.0}/base/implementer.yaml +3 -1
  5. hyperloop-0.6.0/base/kustomization.yaml +7 -0
  6. {hyperloop-0.4.0 → hyperloop-0.6.0}/base/pm.yaml +3 -1
  7. {hyperloop-0.4.0 → hyperloop-0.6.0}/base/process-improver.yaml +3 -1
  8. {hyperloop-0.4.0 → hyperloop-0.6.0}/base/process.yaml +3 -1
  9. {hyperloop-0.4.0 → hyperloop-0.6.0}/base/rebase-resolver.yaml +3 -1
  10. {hyperloop-0.4.0 → hyperloop-0.6.0}/base/verifier.yaml +3 -1
  11. {hyperloop-0.4.0 → hyperloop-0.6.0}/pyproject.toml +1 -1
  12. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/adapters/git_state.py +6 -0
  13. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/cli.py +19 -26
  14. hyperloop-0.6.0/src/hyperloop/compose.py +288 -0
  15. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/config.py +7 -1
  16. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/loop.py +79 -1
  17. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/ports/state.py +4 -0
  18. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_compose.py +167 -15
  19. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_loop.py +237 -3
  20. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_serial_agents.py +6 -6
  21. {hyperloop-0.4.0 → hyperloop-0.6.0}/uv.lock +1 -1
  22. hyperloop-0.4.0/CHANGELOG.md +0 -19
  23. hyperloop-0.4.0/src/hyperloop/compose.py +0 -126
  24. {hyperloop-0.4.0 → hyperloop-0.6.0}/.github/workflows/ci.yaml +0 -0
  25. {hyperloop-0.4.0 → hyperloop-0.6.0}/.github/workflows/release.yaml +0 -0
  26. {hyperloop-0.4.0 → hyperloop-0.6.0}/.gitignore +0 -0
  27. {hyperloop-0.4.0 → hyperloop-0.6.0}/.pre-commit-config.yaml +0 -0
  28. {hyperloop-0.4.0 → hyperloop-0.6.0}/.python-version +0 -0
  29. {hyperloop-0.4.0 → hyperloop-0.6.0}/CLAUDE.md +0 -0
  30. {hyperloop-0.4.0 → hyperloop-0.6.0}/specs/spec.md +0 -0
  31. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/__init__.py +0 -0
  32. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/__main__.py +0 -0
  33. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/adapters/__init__.py +0 -0
  34. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/adapters/local.py +0 -0
  35. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/adapters/serial.py +0 -0
  36. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/domain/__init__.py +0 -0
  37. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/domain/decide.py +0 -0
  38. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/domain/deps.py +0 -0
  39. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/domain/model.py +0 -0
  40. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/domain/pipeline.py +0 -0
  41. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/ports/__init__.py +0 -0
  42. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/ports/pr.py +0 -0
  43. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/ports/runtime.py +0 -0
  44. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/ports/serial.py +0 -0
  45. {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/pr.py +0 -0
  46. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/__init__.py +0 -0
  47. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/fakes/__init__.py +0 -0
  48. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/fakes/pr.py +0 -0
  49. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/fakes/runtime.py +0 -0
  50. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/fakes/serial.py +0 -0
  51. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/fakes/state.py +0 -0
  52. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_cli.py +0 -0
  53. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_config.py +0 -0
  54. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_decide.py +0 -0
  55. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_deps.py +0 -0
  56. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_e2e.py +0 -0
  57. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_fakes.py +0 -0
  58. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_git_state.py +0 -0
  59. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_local_runtime.py +0 -0
  60. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_model.py +0 -0
  61. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_pipeline.py +0 -0
  62. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_pr.py +0 -0
  63. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_smoke.py +0 -0
  64. {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_state_contract.py +0 -0
@@ -0,0 +1,33 @@
1
+ # CHANGELOG
2
+
3
+ <!-- version list -->
4
+
5
+ ## v0.6.0 (2026-04-15)
6
+
7
+
8
+ ## v0.5.0 (2026-04-15)
9
+
10
+ ### Features
11
+
12
+ - Make base_ref configurable via config file and HYPERLOOP_BASE_REF env var
13
+ ([`8efcc0f`](https://github.com/jsell-rh/hyperloop/commit/8efcc0f4292229525e4aa8831561982ad5384220))
14
+
15
+ - Replace local base/ loading with kustomize-based prompt composition
16
+ ([`9644afb`](https://github.com/jsell-rh/hyperloop/commit/9644afb89a96eee564f997222d86201312bcb63e))
17
+
18
+
19
+ ## v0.4.0 (2026-04-15)
20
+
21
+
22
+ ## v0.3.0 (2026-04-15)
23
+
24
+
25
+ ## v0.2.0 (2026-04-15)
26
+
27
+
28
+ ## v0.1.1 (2026-04-15)
29
+
30
+
31
+ ## v0.1.0 (2026-04-15)
32
+
33
+ - Initial Release
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperloop
3
- Version: 0.4.0
3
+ Version: 0.6.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.6.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"
@@ -213,6 +213,12 @@ class GitStateStore:
213
213
  return None
214
214
  return file_path.read_text()
215
215
 
216
+ def set_task_pr(self, task_id: str, pr_url: str) -> None:
217
+ """Set the PR URL on a task file."""
218
+ fm, body = self._read_task_file(task_id)
219
+ fm["pr"] = pr_url
220
+ self._write_task_file(task_id, fm, body)
221
+
216
222
  def commit(self, message: str) -> None:
217
223
  """Stage all changes and create a git commit."""
218
224
  self._git("add", "-A")
@@ -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."""
@@ -236,6 +228,7 @@ def run(
236
228
  serial_runner=serial_runner,
237
229
  poll_interval=cfg.poll_interval,
238
230
  on_cycle=_on_cycle,
231
+ max_rebase_attempts=cfg.max_rebase_attempts,
239
232
  )
240
233
 
241
234
  # 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"]),