hyperloop 0.2.0__tar.gz → 0.4.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 (61) hide show
  1. {hyperloop-0.2.0 → hyperloop-0.4.0}/CHANGELOG.md +6 -0
  2. {hyperloop-0.2.0 → hyperloop-0.4.0}/PKG-INFO +1 -1
  3. {hyperloop-0.2.0 → hyperloop-0.4.0}/pyproject.toml +4 -1
  4. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/adapters/git_state.py +6 -0
  5. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/adapters/local.py +32 -10
  6. hyperloop-0.4.0/src/hyperloop/adapters/serial.py +50 -0
  7. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/cli.py +83 -1
  8. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/loop.py +155 -14
  9. hyperloop-0.4.0/src/hyperloop/ports/serial.py +24 -0
  10. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/ports/state.py +4 -0
  11. hyperloop-0.4.0/tests/fakes/serial.py +47 -0
  12. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/fakes/state.py +10 -0
  13. hyperloop-0.4.0/tests/test_e2e.py +597 -0
  14. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_local_runtime.py +3 -3
  15. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_loop.py +141 -0
  16. hyperloop-0.4.0/tests/test_serial_agents.py +454 -0
  17. {hyperloop-0.2.0 → hyperloop-0.4.0}/uv.lock +1 -1
  18. {hyperloop-0.2.0 → hyperloop-0.4.0}/.github/workflows/ci.yaml +0 -0
  19. {hyperloop-0.2.0 → hyperloop-0.4.0}/.github/workflows/release.yaml +0 -0
  20. {hyperloop-0.2.0 → hyperloop-0.4.0}/.gitignore +0 -0
  21. {hyperloop-0.2.0 → hyperloop-0.4.0}/.pre-commit-config.yaml +0 -0
  22. {hyperloop-0.2.0 → hyperloop-0.4.0}/.python-version +0 -0
  23. {hyperloop-0.2.0 → hyperloop-0.4.0}/CLAUDE.md +0 -0
  24. {hyperloop-0.2.0 → hyperloop-0.4.0}/README.md +0 -0
  25. {hyperloop-0.2.0 → hyperloop-0.4.0}/base/implementer.yaml +0 -0
  26. {hyperloop-0.2.0 → hyperloop-0.4.0}/base/pm.yaml +0 -0
  27. {hyperloop-0.2.0 → hyperloop-0.4.0}/base/process-improver.yaml +0 -0
  28. {hyperloop-0.2.0 → hyperloop-0.4.0}/base/process.yaml +0 -0
  29. {hyperloop-0.2.0 → hyperloop-0.4.0}/base/rebase-resolver.yaml +0 -0
  30. {hyperloop-0.2.0 → hyperloop-0.4.0}/base/verifier.yaml +0 -0
  31. {hyperloop-0.2.0 → hyperloop-0.4.0}/specs/spec.md +0 -0
  32. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/__init__.py +0 -0
  33. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/__main__.py +0 -0
  34. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/adapters/__init__.py +0 -0
  35. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/compose.py +0 -0
  36. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/config.py +0 -0
  37. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/domain/__init__.py +0 -0
  38. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/domain/decide.py +0 -0
  39. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/domain/deps.py +0 -0
  40. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/domain/model.py +0 -0
  41. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/domain/pipeline.py +0 -0
  42. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/ports/__init__.py +0 -0
  43. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/ports/pr.py +0 -0
  44. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/ports/runtime.py +0 -0
  45. {hyperloop-0.2.0 → hyperloop-0.4.0}/src/hyperloop/pr.py +0 -0
  46. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/__init__.py +0 -0
  47. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/fakes/__init__.py +0 -0
  48. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/fakes/pr.py +0 -0
  49. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/fakes/runtime.py +0 -0
  50. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_cli.py +0 -0
  51. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_compose.py +0 -0
  52. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_config.py +0 -0
  53. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_decide.py +0 -0
  54. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_deps.py +0 -0
  55. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_fakes.py +0 -0
  56. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_git_state.py +0 -0
  57. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_model.py +0 -0
  58. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_pipeline.py +0 -0
  59. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_pr.py +0 -0
  60. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_smoke.py +0 -0
  61. {hyperloop-0.2.0 → hyperloop-0.4.0}/tests/test_state_contract.py +0 -0
@@ -2,6 +2,12 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.4.0 (2026-04-15)
6
+
7
+
8
+ ## v0.3.0 (2026-04-15)
9
+
10
+
5
11
  ## v0.2.0 (2026-04-15)
6
12
 
7
13
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperloop
3
- Version: 0.2.0
3
+ Version: 0.4.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hyperloop"
3
- version = "0.2.0"
3
+ version = "0.4.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"
@@ -58,6 +58,9 @@ reportMissingTypeStubs = false
58
58
 
59
59
  [tool.pytest.ini_options]
60
60
  testpaths = ["tests"]
61
+ markers = [
62
+ "slow: end-to-end integration tests (real git repos, subprocesses)",
63
+ ]
61
64
 
62
65
  [tool.semantic_release]
63
66
  commit_parser = "conventional"
@@ -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
@@ -56,8 +56,10 @@ class LocalRuntime:
56
56
  # Ensure the parent directory exists
57
57
  os.makedirs(self._worktree_base, exist_ok=True)
58
58
 
59
- # Create the git worktree on a new branch
60
- subprocess.run(
59
+ # Create the git worktree reuse existing branch if it survived
60
+ # a previous pipeline step, otherwise create a new one.
61
+ env = _clean_git_env()
62
+ result = subprocess.run(
61
63
  [
62
64
  "git",
63
65
  "-C",
@@ -65,14 +67,29 @@ class LocalRuntime:
65
67
  "worktree",
66
68
  "add",
67
69
  worktree_path,
68
- "-b",
69
70
  branch,
70
- "HEAD",
71
71
  ],
72
- check=True,
73
72
  capture_output=True,
74
- env=_clean_git_env(),
73
+ env=env,
75
74
  )
75
+ if result.returncode != 0:
76
+ # Branch doesn't exist yet — create it
77
+ subprocess.run(
78
+ [
79
+ "git",
80
+ "-C",
81
+ self._repo_path,
82
+ "worktree",
83
+ "add",
84
+ worktree_path,
85
+ "-b",
86
+ branch,
87
+ "HEAD",
88
+ ],
89
+ check=True,
90
+ capture_output=True,
91
+ env=env,
92
+ )
76
93
 
77
94
  # Write the prompt file
78
95
  prompt_path = os.path.join(worktree_path, "prompt.md")
@@ -120,7 +137,10 @@ class LocalRuntime:
120
137
  return "failed"
121
138
 
122
139
  def reap(self, handle: WorkerHandle) -> WorkerResult:
123
- """Read the result file, clean up worktree and branch, return WorkerResult."""
140
+ """Read the result file, clean up worktree, return WorkerResult.
141
+
142
+ Preserves the branch so later pipeline steps (e.g. merge-pr) can use it.
143
+ """
124
144
  task_id = handle.task_id
125
145
  worktree_path = self._worktrees.get(task_id)
126
146
 
@@ -152,6 +172,7 @@ class LocalRuntime:
152
172
 
153
173
  branch = self._get_worktree_branch(worktree_path)
154
174
  self._cleanup_worktree(task_id, worktree_path, branch)
175
+ self._delete_branch(branch)
155
176
 
156
177
  def find_orphan(self, task_id: str, branch: str) -> WorkerHandle | None:
157
178
  """Check if a worktree exists for the given branch. Return a handle if so."""
@@ -229,7 +250,7 @@ class LocalRuntime:
229
250
  return None
230
251
 
231
252
  def _cleanup_worktree(self, task_id: str, worktree_path: str, branch: str | None) -> None:
232
- """Remove the worktree directory and delete the branch."""
253
+ """Remove the worktree directory. Preserves the branch for later pipeline steps."""
233
254
  # Remove from internal tracking
234
255
  self._processes.pop(task_id, None)
235
256
  self._worktrees.pop(task_id, None)
@@ -264,10 +285,11 @@ class LocalRuntime:
264
285
  env=env,
265
286
  )
266
287
 
267
- # Delete the branch
288
+ def _delete_branch(self, branch: str | None) -> None:
289
+ """Delete a branch from the repo (best-effort, used by cancel)."""
268
290
  if branch:
269
291
  subprocess.run(
270
292
  ["git", "-C", self._repo_path, "branch", "-D", branch],
271
293
  capture_output=True,
272
- env=env,
294
+ env=_clean_git_env(),
273
295
  )
@@ -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
@@ -7,6 +7,7 @@ the orchestrator, and runs the loop with rich status output.
7
7
  from __future__ import annotations
8
8
 
9
9
  import sys
10
+ from importlib.metadata import version as pkg_version
10
11
  from pathlib import Path
11
12
 
12
13
  import typer
@@ -23,8 +24,23 @@ app = typer.Typer(
23
24
  console = Console()
24
25
 
25
26
 
27
+ def _version_callback(value: bool) -> None:
28
+ if value:
29
+ console.print(f"hyperloop {pkg_version('hyperloop')}")
30
+ raise typer.Exit()
31
+
32
+
26
33
  @app.callback()
27
- def main() -> None:
34
+ def main(
35
+ version: bool = typer.Option(
36
+ False,
37
+ "--version",
38
+ "-V",
39
+ help="Show version and exit.",
40
+ callback=_version_callback,
41
+ is_eager=True,
42
+ ),
43
+ ) -> None:
28
44
  """AI agent orchestrator for composable process pipelines."""
29
45
 
30
46
 
@@ -50,6 +66,37 @@ def _config_table(cfg: Config) -> Table:
50
66
  return table
51
67
 
52
68
 
69
+ def _make_composer(state: object) -> object | None:
70
+ """Construct a PromptComposer using the base/ directory.
71
+
72
+ Tries two locations:
73
+ 1. Development: relative to this file (src/hyperloop/../../base)
74
+ 2. Installed package: via importlib.resources
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]
84
+
85
+ # Installed package: try importlib.resources
86
+ try:
87
+ import importlib.resources as pkg_resources
88
+
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
95
+
96
+ console.print("[dim]Warning: base/ directory not found — prompts will be empty.[/dim]")
97
+ return None
98
+
99
+
53
100
  @app.command()
54
101
  def run(
55
102
  path: Path = typer.Option(
@@ -127,6 +174,7 @@ def run(
127
174
  # 5. Construct runtime and state store, run loop
128
175
  from hyperloop.adapters.git_state import GitStateStore
129
176
  from hyperloop.adapters.local import LocalRuntime
177
+ from hyperloop.adapters.serial import SubprocessSerialRunner
130
178
  from hyperloop.domain.model import ActionStep, LoopStep, Process, RoleStep
131
179
  from hyperloop.loop import Orchestrator
132
180
 
@@ -147,6 +195,36 @@ def run(
147
195
 
148
196
  state = GitStateStore(repo_path, specs_dir=cfg.specs_dir)
149
197
  runtime = LocalRuntime(repo_path=str(repo_path))
198
+ serial_runner = SubprocessSerialRunner(repo_path=str(repo_path))
199
+
200
+ # Resolve base/ directory for agent prompt definitions
201
+ composer = _make_composer(state)
202
+
203
+ def _on_cycle(summary: dict[str, object]) -> None:
204
+ """Print a rich status line after each orchestrator cycle."""
205
+ cycle = summary.get("cycle", "?")
206
+ tasks = summary.get("tasks", {})
207
+ workers = summary.get("workers", 0)
208
+ halt = summary.get("halt_reason")
209
+
210
+ if isinstance(tasks, dict):
211
+ total = tasks.get("total", 0)
212
+ done = tasks.get("complete", 0)
213
+ in_prog = tasks.get("in_progress", 0)
214
+ failed = tasks.get("failed", 0)
215
+ task_str = (
216
+ f"[dim]{total} total[/dim] "
217
+ f"[green]{done} done[/green] "
218
+ f"[yellow]{in_prog} active[/yellow] "
219
+ f"[red]{failed} failed[/red]"
220
+ )
221
+ else:
222
+ task_str = "[dim]unknown[/dim]"
223
+
224
+ status = f"[bold]cycle {cycle}[/bold] tasks: {task_str} workers: {workers}"
225
+ if halt:
226
+ status += f" [bold]{halt}[/bold]"
227
+ console.print(status)
150
228
 
151
229
  orchestrator = Orchestrator(
152
230
  state=state,
@@ -154,6 +232,10 @@ def run(
154
232
  process=default_process,
155
233
  max_workers=cfg.max_workers,
156
234
  max_rounds=cfg.max_rounds,
235
+ composer=composer,
236
+ serial_runner=serial_runner,
237
+ poll_interval=cfg.poll_interval,
238
+ on_cycle=_on_cycle,
157
239
  )
158
240
 
159
241
  # 6. Recover and run
@@ -1,13 +1,14 @@
1
1
  """Orchestrator loop — wires decide, pipeline, state store, and runtime.
2
2
 
3
3
  Runs the serial section from the spec: reap finished workers, check for halt,
4
- run stubs for process-improver/intake, poll gates, merge PRs, decide what to
5
- spawn, update state, spawn workers, and check convergence.
4
+ run process-improver/intake, poll gates, merge PRs, decide what to spawn,
5
+ update state, spawn workers, and check convergence.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
10
  import logging
11
+ import time
11
12
  from typing import TYPE_CHECKING
12
13
 
13
14
  from hyperloop.domain.decide import decide
@@ -37,10 +38,13 @@ from hyperloop.domain.pipeline import (
37
38
  )
38
39
 
39
40
  if TYPE_CHECKING:
41
+ from collections.abc import Callable
42
+
40
43
  from hyperloop.compose import PromptComposer
41
44
  from hyperloop.domain.model import PipelineStep, Process, Task, WorkerResult
42
45
  from hyperloop.ports.pr import PRPort
43
46
  from hyperloop.ports.runtime import Runtime
47
+ from hyperloop.ports.serial import SerialRunner
44
48
  from hyperloop.ports.state import StateStore
45
49
 
46
50
  logger = logging.getLogger(__name__)
@@ -53,8 +57,8 @@ class Orchestrator:
53
57
  Each cycle follows the spec's serial section order:
54
58
  1. Reap finished workers
55
59
  2. Halt if any task failed
56
- 3. Process-improver (stub)
57
- 4. Intake (stub)
60
+ 3. Process-improver (serial on trunk)
61
+ 4. Intake (serial on trunk)
58
62
  5. Poll gates
59
63
  6. Merge ready PRs
60
64
  7. Decide what to spawn (via decide())
@@ -71,6 +75,10 @@ class Orchestrator:
71
75
  max_rounds: int = 50,
72
76
  pr_manager: PRPort | None = None,
73
77
  composer: PromptComposer | None = None,
78
+ repo_path: str | None = None,
79
+ poll_interval: float = 30.0,
80
+ on_cycle: Callable[[dict[str, object]], None] | None = None,
81
+ serial_runner: SerialRunner | None = None,
74
82
  ) -> None:
75
83
  self._state = state
76
84
  self._runtime = runtime
@@ -79,16 +87,22 @@ class Orchestrator:
79
87
  self._max_rounds = max_rounds
80
88
  self._pr_manager = pr_manager
81
89
  self._composer = composer
90
+ self._repo_path = repo_path
91
+ self._poll_interval = poll_interval
92
+ self._on_cycle = on_cycle
93
+ self._serial_runner = serial_runner
82
94
 
83
95
  # Active worker tracking: task_id -> (handle, pipeline_position)
84
96
  self._workers: dict[str, tuple[WorkerHandle, PipelinePosition]] = {}
85
97
 
86
98
  def run_loop(self, max_cycles: int = 1000) -> str:
87
99
  """Run the orchestrator loop until halt or max_cycles. Returns halt reason."""
88
- for _ in range(max_cycles):
89
- reason = self.run_cycle()
100
+ for cycle_num in range(max_cycles):
101
+ reason = self.run_cycle(cycle_num=cycle_num + 1)
90
102
  if reason is not None:
91
103
  return reason
104
+ if self._poll_interval > 0:
105
+ time.sleep(self._poll_interval)
92
106
  return "max_cycles exhausted"
93
107
 
94
108
  def recover(self) -> None:
@@ -121,7 +135,7 @@ class Orchestrator:
121
135
  task.phase,
122
136
  )
123
137
 
124
- def run_cycle(self) -> str | None:
138
+ def run_cycle(self, cycle_num: int = 0) -> str | None:
125
139
  """Run one serial section cycle.
126
140
 
127
141
  Returns a halt reason string if the loop should stop, or None to continue.
@@ -131,6 +145,16 @@ class Orchestrator:
131
145
  # Cache world snapshot once per cycle — augmented with worker state
132
146
  world = self._build_world()
133
147
 
148
+ # ---- 0. Early exit on zero tasks -------------------------------------
149
+ if not world.tasks and not self._workers:
150
+ # Try intake first — it may create tasks from new specs
151
+ self._run_intake()
152
+ world = self._build_world()
153
+ if not world.tasks and not self._workers:
154
+ reason = "no tasks found — nothing to do"
155
+ self._notify_cycle(cycle_num, world, reason=reason)
156
+ return reason
157
+
134
158
  # ---- 1. Reap finished workers ----------------------------------------
135
159
  reaped_results: dict[str, WorkerResult] = {}
136
160
  had_failures_this_cycle = False
@@ -208,12 +232,12 @@ class Orchestrator:
208
232
  self._state.commit("orchestrator: halt")
209
233
  return halt_reason
210
234
 
211
- # ---- 3. Process-improver (stub) --------------------------------------
235
+ # ---- 3. Process-improver ------------------------------------------------
212
236
  if had_failures_this_cycle:
213
- logger.info("process-improver: stub — would run on trunk with this cycle's findings")
237
+ self._run_process_improver(reaped_results)
214
238
 
215
- # ---- 4. Intake (stub) ------------------------------------------------
216
- logger.debug("intake: stub — would run if configured and new specs exist")
239
+ # ---- 4. Intake ----------------------------------------------------------
240
+ self._run_intake()
217
241
 
218
242
  # ---- 5. Poll gates ---------------------------------------------------
219
243
  self._poll_gates(executor, to_spawn)
@@ -275,14 +299,18 @@ class Orchestrator:
275
299
  # ---- Check convergence -----------------------------------------------
276
300
  all_tasks = self._state.get_world().tasks
277
301
  if not all_tasks:
302
+ self._notify_cycle(cycle_num, world)
278
303
  return None
279
304
 
280
305
  all_complete = all(t.status == TaskStatus.COMPLETE for t in all_tasks.values())
281
306
  no_workers = len(self._workers) == 0
282
307
 
283
308
  if all_complete and no_workers:
284
- return "all tasks complete"
309
+ reason = "all tasks complete"
310
+ self._notify_cycle(cycle_num, world, reason=reason)
311
+ return reason
285
312
 
313
+ self._notify_cycle(cycle_num, world)
286
314
  return None
287
315
 
288
316
  # -----------------------------------------------------------------------
@@ -382,9 +410,13 @@ class Orchestrator:
382
410
  """Merge worker branch into base branch locally (no PR)."""
383
411
  import subprocess
384
412
 
413
+ git_cmd = ["git"]
414
+ if self._repo_path is not None:
415
+ git_cmd = ["git", "-C", self._repo_path]
416
+
385
417
  try:
386
418
  subprocess.run(
387
- ["git", "merge", branch, "--no-edit", "-m", f"merge: {task_id}"],
419
+ [*git_cmd, "merge", branch, "--no-edit", "-m", f"merge: {task_id}"],
388
420
  check=True,
389
421
  capture_output=True,
390
422
  )
@@ -393,9 +425,87 @@ class Orchestrator:
393
425
  logger.info("Local merge of %s into base branch", task_id)
394
426
  except subprocess.CalledProcessError:
395
427
  logger.warning("Local merge conflict for task %s, marking NEEDS_REBASE", task_id)
396
- subprocess.run(["git", "merge", "--abort"], capture_output=True, check=False)
428
+ subprocess.run([*git_cmd, "merge", "--abort"], capture_output=True, check=False)
397
429
  self._state.transition_task(task_id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr"))
398
430
 
431
+ # -----------------------------------------------------------------------
432
+ # Serial agents (PM intake + process-improver)
433
+ # -----------------------------------------------------------------------
434
+
435
+ def _unprocessed_specs(self) -> list[str]:
436
+ """Return spec file paths that have no corresponding task.
437
+
438
+ Scans specs/*.md (excluding subdirectories like tasks/, reviews/,
439
+ prompts/) and checks whether any existing task references each spec
440
+ via its spec_ref field.
441
+ """
442
+ all_specs = self._state.list_files("specs/*.md")
443
+ world = self._state.get_world()
444
+ covered_refs = {task.spec_ref for task in world.tasks.values()}
445
+ return [s for s in all_specs if s not in covered_refs]
446
+
447
+ def _collect_cycle_findings(self, reaped_results: dict[str, WorkerResult]) -> str:
448
+ """Collect findings from all failed results this cycle into a single string."""
449
+ sections: list[str] = []
450
+ for task_id, result in reaped_results.items():
451
+ if result.verdict in (Verdict.FAIL, Verdict.ERROR, Verdict.TIMEOUT):
452
+ sections.append(f"### {task_id}\n{result.detail}")
453
+ return "\n\n".join(sections)
454
+
455
+ def _run_intake(self) -> None:
456
+ """Run PM intake if there are unprocessed specs and a serial runner + composer."""
457
+ if self._serial_runner is None or self._composer is None:
458
+ logger.debug("intake: no serial_runner or composer — skipping")
459
+ return
460
+
461
+ unprocessed = self._unprocessed_specs()
462
+ if not unprocessed:
463
+ logger.debug("intake: no unprocessed specs — skipping")
464
+ return
465
+
466
+ logger.info("intake: %d unprocessed spec(s), running PM", len(unprocessed))
467
+
468
+ spec_list = "\n".join(f"- {s}" for s in unprocessed)
469
+ prompt = self._composer.compose(
470
+ role="pm",
471
+ task_id="intake",
472
+ spec_ref="specs/",
473
+ findings="",
474
+ )
475
+ prompt += f"\n## Specs to Process\n{spec_list}\n"
476
+
477
+ success = self._serial_runner.run("pm", prompt)
478
+ if success:
479
+ logger.info("intake: PM completed successfully")
480
+ else:
481
+ logger.warning("intake: PM agent failed")
482
+
483
+ def _run_process_improver(self, reaped_results: dict[str, WorkerResult]) -> None:
484
+ """Run process-improver with findings from failed results this cycle."""
485
+ if self._serial_runner is None or self._composer is None:
486
+ logger.info("process-improver: no serial_runner or composer — skipping")
487
+ return
488
+
489
+ findings_text = self._collect_cycle_findings(reaped_results)
490
+ if not findings_text:
491
+ logger.debug("process-improver: no failure findings this cycle — skipping")
492
+ return
493
+
494
+ logger.info("process-improver: running with this cycle's findings")
495
+
496
+ prompt = self._composer.compose(
497
+ role="process-improver",
498
+ task_id="process-improvement",
499
+ spec_ref="specs/prompts",
500
+ findings=findings_text,
501
+ )
502
+
503
+ success = self._serial_runner.run("process-improver", prompt)
504
+ if success:
505
+ logger.info("process-improver: completed successfully")
506
+ else:
507
+ logger.warning("process-improver: agent failed")
508
+
399
509
  # -----------------------------------------------------------------------
400
510
  # World building
401
511
  # -----------------------------------------------------------------------
@@ -417,6 +527,37 @@ class Orchestrator:
417
527
  epoch=base_world.epoch,
418
528
  )
419
529
 
530
+ # -----------------------------------------------------------------------
531
+ # Cycle notification
532
+ # -----------------------------------------------------------------------
533
+
534
+ def _notify_cycle(
535
+ self,
536
+ cycle_num: int,
537
+ world: World,
538
+ reason: str | None = None,
539
+ ) -> None:
540
+ """Invoke the on_cycle callback with a summary of this cycle."""
541
+ if self._on_cycle is None:
542
+ return
543
+
544
+ tasks = world.tasks
545
+ summary: dict[str, object] = {
546
+ "cycle": cycle_num,
547
+ "tasks": {
548
+ "total": len(tasks),
549
+ "not_started": sum(1 for t in tasks.values() if t.status == TaskStatus.NOT_STARTED),
550
+ "in_progress": sum(1 for t in tasks.values() if t.status == TaskStatus.IN_PROGRESS),
551
+ "complete": sum(1 for t in tasks.values() if t.status == TaskStatus.COMPLETE),
552
+ "failed": sum(1 for t in tasks.values() if t.status == TaskStatus.FAILED),
553
+ },
554
+ "workers": len(self._workers),
555
+ }
556
+ if reason is not None:
557
+ summary["halt_reason"] = reason
558
+
559
+ self._on_cycle(summary)
560
+
420
561
  # -----------------------------------------------------------------------
421
562
  # Prompt composition
422
563
  # -----------------------------------------------------------------------
@@ -0,0 +1,24 @@
1
+ """SerialRunner port — interface for running serial agents on trunk.
2
+
3
+ PM and process-improver run serially on trunk during the orchestrator's
4
+ serial section. They are not pipeline workers — they block the loop
5
+ until they complete.
6
+
7
+ Implementations: SubprocessSerialRunner (CLI subprocess),
8
+ FakeSerialRunner (in-memory for tests).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Protocol
14
+
15
+
16
+ class SerialRunner(Protocol):
17
+ """Run an agent serially on trunk. Blocks until complete."""
18
+
19
+ def run(self, role: str, prompt: str) -> bool:
20
+ """Execute a serial agent with the given prompt.
21
+
22
+ Returns True on success, False on failure.
23
+ """
24
+ ...
@@ -52,6 +52,10 @@ class StateStore(Protocol):
52
52
  """Record a last-run marker."""
53
53
  ...
54
54
 
55
+ def list_files(self, pattern: str) -> list[str]:
56
+ """List file paths matching a glob pattern relative to the repo root."""
57
+ ...
58
+
55
59
  def read_file(self, path: str) -> str | None:
56
60
  """Read a file from trunk. Returns None if the file does not exist."""
57
61
  ...
@@ -0,0 +1,47 @@
1
+ """FakeSerialRunner — in-memory implementation of the SerialRunner port.
2
+
3
+ Records invocations for test assertions. Optionally runs a callback
4
+ to simulate side effects (e.g. creating task files in the state store).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SerialRunRecord:
18
+ """Record of a serial agent invocation."""
19
+
20
+ role: str
21
+ prompt: str
22
+
23
+
24
+ class FakeSerialRunner:
25
+ """In-memory implementation of the SerialRunner port."""
26
+
27
+ def __init__(self) -> None:
28
+ self.runs: list[SerialRunRecord] = []
29
+ self._callbacks: dict[str, Callable[[str], bool]] = {}
30
+ self._default_success: bool = True
31
+
32
+ def set_callback(self, role: str, callback: Callable[[str], bool]) -> None:
33
+ """Register a callback for a role. Called with the prompt, returns success."""
34
+ self._callbacks[role] = callback
35
+
36
+ def set_default_success(self, success: bool) -> None:
37
+ """Set the default return value when no callback is registered."""
38
+ self._default_success = success
39
+
40
+ # -- SerialRunner protocol -------------------------------------------------
41
+
42
+ def run(self, role: str, prompt: str) -> bool:
43
+ """Record the invocation and optionally execute a callback."""
44
+ self.runs.append(SerialRunRecord(role=role, prompt=prompt))
45
+ if role in self._callbacks:
46
+ return self._callbacks[role](prompt)
47
+ return self._default_success