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.
- hyperloop-0.6.0/CHANGELOG.md +33 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/PKG-INFO +3 -1
- {hyperloop-0.4.0 → hyperloop-0.6.0}/README.md +2 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/base/implementer.yaml +3 -1
- hyperloop-0.6.0/base/kustomization.yaml +7 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/base/pm.yaml +3 -1
- {hyperloop-0.4.0 → hyperloop-0.6.0}/base/process-improver.yaml +3 -1
- {hyperloop-0.4.0 → hyperloop-0.6.0}/base/process.yaml +3 -1
- {hyperloop-0.4.0 → hyperloop-0.6.0}/base/rebase-resolver.yaml +3 -1
- {hyperloop-0.4.0 → hyperloop-0.6.0}/base/verifier.yaml +3 -1
- {hyperloop-0.4.0 → hyperloop-0.6.0}/pyproject.toml +1 -1
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/adapters/git_state.py +6 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/cli.py +19 -26
- hyperloop-0.6.0/src/hyperloop/compose.py +288 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/config.py +7 -1
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/loop.py +79 -1
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/ports/state.py +4 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_compose.py +167 -15
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_loop.py +237 -3
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_serial_agents.py +6 -6
- {hyperloop-0.4.0 → hyperloop-0.6.0}/uv.lock +1 -1
- hyperloop-0.4.0/CHANGELOG.md +0 -19
- hyperloop-0.4.0/src/hyperloop/compose.py +0 -126
- {hyperloop-0.4.0 → hyperloop-0.6.0}/.github/workflows/ci.yaml +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/.github/workflows/release.yaml +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/.gitignore +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/.pre-commit-config.yaml +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/.python-version +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/CLAUDE.md +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/specs/spec.md +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/__init__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/__main__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/adapters/__init__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/adapters/local.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/adapters/serial.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/domain/__init__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/domain/decide.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/domain/deps.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/domain/model.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/domain/pipeline.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/ports/__init__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/ports/pr.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/ports/runtime.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/ports/serial.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/src/hyperloop/pr.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/__init__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/fakes/__init__.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/fakes/pr.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/fakes/runtime.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/fakes/serial.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/fakes/state.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_cli.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_config.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_decide.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_deps.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_e2e.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_fakes.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_git_state.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_local_runtime.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_model.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_pipeline.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_pr.py +0 -0
- {hyperloop-0.4.0 → hyperloop-0.6.0}/tests/test_smoke.py +0 -0
- {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.
|
|
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)
|
|
@@ -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
|
|
70
|
-
"""Construct a PromptComposer
|
|
70
|
+
def _make_composer(cfg: object, state: object) -> object:
|
|
71
|
+
"""Construct a PromptComposer via kustomize build.
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
80
|
+
Returns:
|
|
81
|
+
A PromptComposer with resolved templates.
|
|
82
|
+
"""
|
|
83
|
+
from hyperloop.compose import PromptComposer, check_kustomize_available
|
|
88
84
|
|
|
89
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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"]),
|