relay-workflow 0.1.0__py3-none-any.whl

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.
relay/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Relay — cross-agent (Claude Code + Codex) planning/execution workflow."""
2
+ __version__ = "0.1.0"
relay/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow `python -m relay <subcommand>` to dispatch through the CLI."""
2
+ from relay.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
relay/cli.py ADDED
@@ -0,0 +1,55 @@
1
+ """Relay CLI dispatcher: routes `relay <step> ...` to each step's main().
2
+
3
+ Each step module (plan/project/implement/ship) keeps its own argparse parser
4
+ and `main()`. The dispatcher just slices `sys.argv` so the step sees argv as
5
+ if it had been invoked directly.
6
+ """
7
+ from __future__ import annotations
8
+ import sys
9
+ from importlib import import_module
10
+
11
+ from relay import __version__
12
+
13
+ STEPS = {
14
+ "plan": "relay.plan",
15
+ "project": "relay.project",
16
+ "implement": "relay.implement",
17
+ "ship": "relay.ship",
18
+ }
19
+
20
+ USAGE = f"""\
21
+ relay {__version__} — cross-agent planning/execution workflow
22
+
23
+ Usage:
24
+ relay <step> [options]
25
+
26
+ Steps (run in pipeline order):
27
+ plan bounded plan-only agent run → plan.json / plan.md
28
+ project idempotent GitHub epic + milestones + issues
29
+ implement headless agent implements one issue → PR (Closes #N)
30
+ ship merge mergeable PRs → bubble up issue/milestone state
31
+
32
+ Run `relay <step> --help` for step-specific options.
33
+
34
+ See https://github.com/applied-artificial-intelligence/relay for the design doc.
35
+ """
36
+
37
+
38
+ def main() -> None:
39
+ if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
40
+ print(USAGE)
41
+ sys.exit(0)
42
+ if sys.argv[1] in ("-V", "--version"):
43
+ print(f"relay {__version__}")
44
+ sys.exit(0)
45
+ step = sys.argv[1]
46
+ if step not in STEPS:
47
+ print(f"relay: unknown step {step!r}.\n", file=sys.stderr)
48
+ print(USAGE, file=sys.stderr)
49
+ sys.exit(2)
50
+ sys.argv = [f"relay {step}"] + sys.argv[2:]
51
+ import_module(STEPS[step]).main()
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
relay/implement.py ADDED
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ relay_implement.py — the Relay `implement` step (prototype).
4
+
5
+ Consumes a projected plan.json (from relay_project.py) and drives a **headless
6
+ coding agent** to implement ONE issue at a time on its own branch, then opens a
7
+ PR that closes the GitHub issue. This is the step that lets Relay execute its own
8
+ backlog (self-hosting) instead of a human hand-building each change.
9
+
10
+ Model (workflow-design.md):
11
+ IMPLEMENT → one issue at a time → branch + PR → GitHub issue (Closes #N)
12
+
13
+ Each issue in plan.json already carries (from the PROJECT step):
14
+ number — GitHub issue #
15
+ branch — relay/m<n>-<mslug>/<islug>
16
+ closes — "Closes #N" (the PR-body contract)
17
+
18
+ Flow per issue:
19
+ 1. select target issue (default: first pending; or --issue <n|title>)
20
+ 2. resolve target repo working tree (--repo-dir, else git toplevel of the unit)
21
+ + base branch (--base, else origin default)
22
+ 3. create/checkout the issue branch off base (idempotent: reuse if present)
23
+ 4. assemble an implementation brief (objective + milestone + issue + spec.md)
24
+ 5. run the host's headless agent in the working tree (Claude: claude -p …)
25
+ 6. verify commits landed on the branch (else: report no-op, don't open a PR)
26
+ 7. push branch + open PR with the `Closes #N` body (idempotent: reuse PR)
27
+ 8. write pr number/state back into plan.json
28
+
29
+ Local is the source of truth; GitHub is the projection. **Dry-run by default** —
30
+ prints the target, branch, assembled brief, and the commands it WOULD run. Pass
31
+ --execute to mutate the repo / spend an agent run / open a PR.
32
+ """
33
+ from __future__ import annotations
34
+ import argparse, json, shutil, subprocess, sys, textwrap
35
+ from pathlib import Path
36
+
37
+ HOST_DEFAULT = "auto"
38
+
39
+
40
+ def sh(cmd: list[str], cwd: str | None = None, check: bool = True) -> str:
41
+ res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
42
+ if check and res.returncode != 0:
43
+ sys.exit(f"error: {' '.join(cmd[:3])}… failed: {res.stderr.strip()}")
44
+ return res.stdout.strip()
45
+
46
+
47
+ def git(args: list[str], cwd: str, check: bool = True) -> str:
48
+ return sh(["git", "-C", cwd, *args], check=check)
49
+
50
+
51
+ # --- repo / base resolution -------------------------------------------------
52
+
53
+ def repo_toplevel(start: Path) -> str:
54
+ res = subprocess.run(["git", "-C", str(start), "rev-parse", "--show-toplevel"],
55
+ capture_output=True, text=True)
56
+ if res.returncode != 0:
57
+ sys.exit(f"error: {start} is not inside a git repo (use --repo-dir)")
58
+ return res.stdout.strip()
59
+
60
+
61
+ def default_base(repo_dir: str) -> str:
62
+ # origin/HEAD -> e.g. "origin/main"; fall back to current branch then "main".
63
+ ref = git(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
64
+ repo_dir, check=False)
65
+ if ref:
66
+ return ref.rsplit("/", 1)[-1]
67
+ cur = git(["branch", "--show-current"], repo_dir, check=False)
68
+ return cur or "main"
69
+
70
+
71
+ def has_remote(repo_dir: str) -> bool:
72
+ return bool(git(["remote"], repo_dir, check=False))
73
+
74
+
75
+ # --- issue selection --------------------------------------------------------
76
+
77
+ def iter_issues(plan: dict):
78
+ """Yield (milestone_index, milestone, issue) in plan order."""
79
+ for mi, m in enumerate(plan.get("milestones", []), 1):
80
+ for issue in m.get("issues", []):
81
+ yield mi, m, issue
82
+
83
+
84
+ # states that mean "don't pick this again by default"
85
+ DONE_STATES = {"open", "implemented", "implemented-local", "merged", "done", "no-op"}
86
+
87
+
88
+ def is_pending(issue: dict) -> bool:
89
+ # an issue with a recorded PR is in flight, not pending.
90
+ return not issue.get("pr") and issue.get("state") not in DONE_STATES
91
+
92
+
93
+ def select_issue(plan: dict, selector: str | None):
94
+ """Return (mi, milestone, issue) for the target, or exit with a message."""
95
+ candidates = list(iter_issues(plan))
96
+ if not candidates:
97
+ sys.exit("error: plan.json has no milestones/issues — run relay_plan.py")
98
+ if selector is None:
99
+ for mi, m, issue in candidates:
100
+ if is_pending(issue):
101
+ return mi, m, issue
102
+ sys.exit("nothing pending — every issue is already implemented/merged")
103
+ # explicit selector: match by number then by (sub)string title.
104
+ for mi, m, issue in candidates:
105
+ if selector.lstrip("#").isdigit() and str(issue.get("number")) == selector.lstrip("#"):
106
+ return mi, m, issue
107
+ for mi, m, issue in candidates:
108
+ if selector.lower() in issue.get("title", "").lower():
109
+ return mi, m, issue
110
+ sys.exit(f"error: no issue matched {selector!r}")
111
+
112
+
113
+ # --- implementation brief (the agent's instructions) ------------------------
114
+
115
+ def build_brief(plan: dict, mi: int, milestone: dict, issue: dict, spec: str) -> str:
116
+ parts = [
117
+ f"# Implement one issue from the Relay plan\n",
118
+ f"## Objective (whole project)\n{plan.get('objective', '(none)')}\n",
119
+ f"## Milestone M{mi}: {milestone['title']}\n"
120
+ f"{milestone.get('rationale', '').strip()}\n",
121
+ f"## YOUR ISSUE (#{issue.get('number', '?')}): {issue['title']}\n"
122
+ f"{issue.get('body', '').strip() or '(no body)'}\n",
123
+ ]
124
+ if spec:
125
+ parts.append("## Durable spec (the contract — do not contradict it)\n"
126
+ f"{spec.strip()}\n")
127
+ parts.append(textwrap.dedent("""\
128
+ ## Rules (mechanical, non-negotiable)
129
+ - Implement ONLY this issue's scope. Do not start other issues or refactor
130
+ unrelated code. Out-of-scope changes will be rejected in review.
131
+ - Match the surrounding code's style, naming, and test conventions.
132
+ - If the repo has tests, add/extend tests for this change and make them pass.
133
+ - Commit your work with focused, conventional-commit messages
134
+ (`feat:`/`fix:`/`test:`/`docs:`). The runner pushes and opens the PR; you
135
+ only need to leave committed changes on the current branch.
136
+ - If the issue is already satisfied by existing code, make no commits and say so.
137
+ """))
138
+ return "\n".join(parts)
139
+
140
+
141
+ # --- host resolution --------------------------------------------------------
142
+
143
+ def resolve_host(requested: str) -> str:
144
+ """Map --host to a concrete agent. An explicit 'claude'/'codex' is honoured
145
+ as-is; 'auto' mirrors `relay plan`'s precedence — claude on PATH first, then
146
+ codex, else exit naming both binaries."""
147
+ if requested in ("claude", "codex"):
148
+ return requested
149
+ if shutil.which("claude"):
150
+ return "claude"
151
+ if shutil.which("codex"):
152
+ return "codex"
153
+ sys.exit("error: --host auto, but neither `claude` nor `codex` found on PATH")
154
+
155
+
156
+ # --- host runner ------------------------------------------------------------
157
+
158
+ def run_claude(brief: str, repo_dir: str, perm_mode: str, model: str | None) -> dict:
159
+ """Run a headless Claude Code session in repo_dir. Returns parsed JSON result."""
160
+ cmd = ["claude", "-p", brief, "--output-format", "json",
161
+ "--permission-mode", perm_mode, "--add-dir", repo_dir]
162
+ if model:
163
+ cmd += ["--model", model]
164
+ res = subprocess.run(cmd, cwd=repo_dir, capture_output=True, text=True)
165
+ if res.returncode != 0:
166
+ sys.exit(f"error: claude headless run failed (exit {res.returncode}): "
167
+ f"{res.stderr.strip()[:500]}")
168
+ try:
169
+ return json.loads(res.stdout)
170
+ except json.JSONDecodeError:
171
+ return {"result": res.stdout.strip(), "raw": True}
172
+
173
+
174
+ def run_codex(brief: str, repo_dir: str, perm_mode: str, model: str | None) -> dict:
175
+ """Run a headless Codex session in repo_dir.
176
+
177
+ IMPLEMENT must write the working tree and commit, so the sandbox is
178
+ `workspace-write` (read-only is correct for PLAN, wrong here). The
179
+ permission mapping mirrors the Claude host: `bypassPermissions` is the
180
+ autonomous mode, so it also passes
181
+ `--dangerously-bypass-approvals-and-sandbox` to skip approval prompts;
182
+ `acceptEdits`/`default` keep Codex's approvals on.
183
+
184
+ Codex `exec` has no `--output-format json`, so success is git-verified by
185
+ the caller (commit count on the branch), not parsed from stdout. The
186
+ returned dict matches run_claude's shape only for the human-facing `result`
187
+ hint shown on a no-op."""
188
+ cmd = ["codex", "exec", "--sandbox", "workspace-write",
189
+ "--skip-git-repo-check", "--cd", repo_dir]
190
+ if perm_mode == "bypassPermissions":
191
+ cmd.append("--dangerously-bypass-approvals-and-sandbox")
192
+ if model:
193
+ cmd += ["--model", model]
194
+ cmd.append(brief)
195
+ res = subprocess.run(cmd, cwd=repo_dir, capture_output=True, text=True)
196
+ if res.returncode != 0:
197
+ sys.exit(f"error: codex headless run failed (exit {res.returncode}): "
198
+ f"{res.stderr.strip()[:500]}")
199
+ return {"result": res.stdout.strip(), "raw": True}
200
+
201
+
202
+ # --- PR projection ----------------------------------------------------------
203
+
204
+ def existing_pr(repo_dir: str, branch: str) -> int | None:
205
+ # Only an *open* PR should make open_pr() skip creating a fresh one. A
206
+ # previously closed/merged PR on the same branch (close-and-redo) would
207
+ # otherwise return a stale number, leaving plan.json pointing at a dead PR
208
+ # that SHIP rejects with "PR closed without merging".
209
+ out = sh(["gh", "pr", "list", "--head", branch, "--state", "open",
210
+ "--json", "number", "-q", ".[0].number"], cwd=repo_dir, check=False)
211
+ return int(out) if out.strip().isdigit() else None
212
+
213
+
214
+ def open_pr(repo_dir: str, base: str, branch: str, title: str, body: str) -> int:
215
+ existing = existing_pr(repo_dir, branch)
216
+ if existing:
217
+ return existing
218
+ out = sh(["gh", "pr", "create", "--base", base, "--head", branch,
219
+ "--title", title, "--body", body], cwd=repo_dir)
220
+ return int(out.rstrip("/").rsplit("/", 1)[-1])
221
+
222
+
223
+ def persist_state(plan: dict, plan_file: Path, repo_dir: str, base: str,
224
+ msg: str, log: list[str]) -> None:
225
+ """Record plan.json state on the BASE branch (cross-issue state must survive
226
+ between runs and not leave the tree dirty for the next issue). Returns the repo
227
+ to base; commits + best-effort pushes the metadata change."""
228
+ git(["checkout", base], repo_dir, check=False)
229
+ plan_file.write_text(json.dumps(plan, indent=2))
230
+ if git(["status", "--porcelain", str(plan_file)], repo_dir, check=False):
231
+ git(["add", str(plan_file)], repo_dir, check=False)
232
+ git(["-c", "commit.gpgsign=false", "commit", "-m", msg], repo_dir, check=False)
233
+ if has_remote(repo_dir):
234
+ # base may be protected; don't fail the run if the push is rejected.
235
+ pushed = subprocess.run(["git", "-C", repo_dir, "push", "origin", base],
236
+ capture_output=True, text=True)
237
+ log.append("state committed to " + base +
238
+ (" + pushed" if pushed.returncode == 0 else " (push rejected — commit local)"))
239
+ else:
240
+ log.append("state committed to " + base + " (local-only)")
241
+
242
+
243
+ # --- main -------------------------------------------------------------------
244
+
245
+ def main() -> None:
246
+ ap = argparse.ArgumentParser(description="Relay implement step (prototype)")
247
+ ap.add_argument("unit", help="work unit dir containing plan.json")
248
+ ap.add_argument("--issue", help="target issue by #number or title substring "
249
+ "(default: first pending issue)")
250
+ ap.add_argument("--repo-dir", help="repo working tree to implement in "
251
+ "(default: git toplevel of the unit dir)")
252
+ ap.add_argument("--base", help="base branch (default: origin default / current)")
253
+ ap.add_argument("--host", default=HOST_DEFAULT, choices=["claude", "codex", "auto"],
254
+ help="headless agent host. 'auto' (default) detects claude on "
255
+ "PATH first, then codex (same precedence as `relay plan`).")
256
+ ap.add_argument("--permission-mode", default="acceptEdits",
257
+ help="permission mode (acceptEdits|bypassPermissions|default). "
258
+ "Autonomous runs that must run tests/commits usually need "
259
+ "bypassPermissions — for the codex host this maps to "
260
+ "--dangerously-bypass-approvals-and-sandbox.")
261
+ ap.add_argument("--model", help="model alias/id for the headless run")
262
+ ap.add_argument("--redo", action="store_true",
263
+ help="re-run even if the issue already has an open PR recorded")
264
+ ap.add_argument("--execute", action="store_true",
265
+ help="actually branch + run the agent + open a PR (default: dry-run)")
266
+ args = ap.parse_args()
267
+
268
+ unit = Path(args.unit).resolve()
269
+ plan_file = unit / "plan.json"
270
+ if not plan_file.exists():
271
+ sys.exit(f"error: {plan_file} not found — run relay_plan.py first")
272
+ plan = json.loads(plan_file.read_text())
273
+
274
+ repo_dir = args.repo_dir or repo_toplevel(unit)
275
+ base = args.base or default_base(repo_dir)
276
+ spec = ""
277
+ spec_file = unit / "spec.md"
278
+ if spec_file.exists():
279
+ spec = spec_file.read_text()
280
+
281
+ host = resolve_host(args.host)
282
+ mi, milestone, issue = select_issue(plan, args.issue)
283
+ branch = issue.get("branch")
284
+ if not branch:
285
+ sys.exit("error: issue has no branch — run relay_project.py --execute first "
286
+ "(branch/closes wiring lives in the PROJECT step)")
287
+ closes = issue.get("closes", "")
288
+ brief = build_brief(plan, mi, milestone, issue, spec)
289
+ pr_title = issue["title"]
290
+ pr_body = f"{issue.get('body', '').strip()}\n\n{closes}".strip()
291
+
292
+ log = [
293
+ f"repo: {repo_dir}",
294
+ f"base: {base} branch: {branch}",
295
+ f"issue: #{issue.get('number', '?')} {issue['title']!r} (M{mi}: {milestone['title']})",
296
+ f"host: {host} perm-mode: {args.permission_mode} "
297
+ f"mode: {'EXECUTE' if args.execute else 'dry-run'}",
298
+ ]
299
+
300
+ if not args.execute:
301
+ print("\n".join(log), file=sys.stderr)
302
+ print("\n--- would create/checkout branch off base, then run: ---", file=sys.stderr)
303
+ print(f" git checkout -b {branch} {base} (reuse if exists)", file=sys.stderr)
304
+ if host == "codex":
305
+ bypass = (" --dangerously-bypass-approvals-and-sandbox"
306
+ if args.permission_mode == "bypassPermissions" else "")
307
+ print(f" codex exec --sandbox workspace-write --skip-git-repo-check "
308
+ f"--cd {repo_dir}{bypass} <brief>", file=sys.stderr)
309
+ else:
310
+ print(f" claude -p <brief> --output-format json "
311
+ f"--permission-mode {args.permission_mode} --add-dir {repo_dir}", file=sys.stderr)
312
+ print(f" git push -u origin {branch}", file=sys.stderr)
313
+ print(f" gh pr create --base {base} --head {branch} "
314
+ f"--title {pr_title!r} --body <body+{closes!r}>", file=sys.stderr)
315
+ print("\n--- assembled brief ---\n", file=sys.stderr)
316
+ print(brief, file=sys.stderr)
317
+ print("\n(dry-run) re-run with --execute to branch, run the agent, and open a PR.",
318
+ file=sys.stderr)
319
+ return
320
+
321
+ # --- EXECUTE ---
322
+ if issue.get("pr") and not args.redo:
323
+ sys.exit(f"issue #{issue.get('number', '?')} already has PR #{issue['pr']} "
324
+ "(state in flight). Use --redo to re-run the agent on it.")
325
+ # only tracked modifications block us; untracked files (build caches, lockfiles
326
+ # an agent may emit) don't affect branching and won't be committed by the runner.
327
+ if git(["status", "--porcelain", "--untracked-files=no"], repo_dir):
328
+ sys.exit("error: tracked files modified — commit/stash before --execute")
329
+ git(["checkout", base], repo_dir) # always plan/branch from a known base
330
+ # idempotent branch: reuse if it already exists, else create off base.
331
+ branches = git(["branch", "--list", branch], repo_dir)
332
+ git(["checkout", branch] if branches else ["checkout", "-b", branch, base], repo_dir)
333
+ log.append(f"on branch {branch} ({'reused' if branches else 'created'} off {base})")
334
+
335
+ head_before = git(["rev-parse", "HEAD"], repo_dir)
336
+ runner = run_codex if host == "codex" else run_claude
337
+ result = runner(brief, repo_dir, args.permission_mode, args.model)
338
+ head_after = git(["rev-parse", "HEAD"], repo_dir)
339
+
340
+ if head_after == head_before:
341
+ issue["state"] = "no-op"
342
+ git(["checkout", base], repo_dir, check=False)
343
+ if not branches: # delete the empty branch we just created
344
+ git(["branch", "-D", branch], repo_dir, check=False)
345
+ persist_state(plan, plan_file, repo_dir, base,
346
+ f"chore(relay): issue #{issue.get('number', '?')} no-op", log)
347
+ print("\n".join(log), file=sys.stderr)
348
+ sys.exit("agent left no commits — nothing to PR. Recorded state=no-op. "
349
+ f"Agent said: {str(result.get('result', ''))[:300]}")
350
+ n_commits = git(["rev-list", "--count", f"{head_before}..{head_after}"], repo_dir)
351
+ log.append(f"agent landed {n_commits} commit(s)")
352
+
353
+ if not has_remote(repo_dir):
354
+ issue["state"] = "implemented-local"
355
+ persist_state(plan, plan_file, repo_dir, base,
356
+ f"chore(relay): issue #{issue.get('number', '?')} implemented-local", log)
357
+ print("\n".join(log), file=sys.stderr)
358
+ print(f"no git remote — branch {branch} left local (state=implemented-local). "
359
+ "Add a remote and re-run to open a PR.", file=sys.stderr)
360
+ return
361
+
362
+ git(["push", "-u", "origin", branch], repo_dir)
363
+ pr = open_pr(repo_dir, base, branch, pr_title, pr_body)
364
+ issue["pr"] = pr
365
+ issue["state"] = "open"
366
+ log.append(f"PR #{pr} open ({closes})")
367
+ persist_state(plan, plan_file, repo_dir, base,
368
+ f"chore(relay): record PR #{pr} for issue #{issue.get('number', '?')}", log)
369
+ print("\n".join(log), file=sys.stderr)
370
+ print(f"\n✓ implemented issue #{issue.get('number', '?')} → PR #{pr}", file=sys.stderr)
371
+
372
+
373
+ if __name__ == "__main__":
374
+ main()
relay/plan.py ADDED
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ relay_plan.py — the Relay `plan` step (prototype).
4
+
5
+ Turns an aligned spec into a structured, milestone/issue plan WITHOUT letting the
6
+ coding agent run off into implementation. Host-neutral: drives Claude or Codex
7
+ through its mechanically-bounded "plan-but-don't-build" primitive.
8
+
9
+ Claude: claude -p --permission-mode plan (ExitPlanMode is terminal)
10
+ Codex: codex exec --sandbox read-only (read-only sandbox; can't write)
11
+
12
+ Flow (matches relay-workflow-design.md):
13
+ 1. read .workspace/work/<unit>/spec.md (the WHAT/WHY, from `align`)
14
+ 2. plan bounded subprocess produces a decomposition (milestones -> issues)
15
+ 3. write .workspace/work/<unit>/plan.json + plan.md (local source of truth)
16
+ 4. project stub: emit the `gh` commands to mirror plan onto GitHub
17
+ (milestones + issues + Closes-# links). --project to execute.
18
+
19
+ Empirical basis: relay-planmode-probe.md (live trials, 2026-05-27).
20
+ This is a PROTOTYPE: single-pass, no auth handling beyond what the host CLIs do,
21
+ gh projection is dry-run by default.
22
+ """
23
+ from __future__ import annotations
24
+ import argparse, json, os, shutil, subprocess, sys, time
25
+ from pathlib import Path
26
+
27
+ # --- the schema we want the plan to conform to (Codex enforces it; Claude is asked) ---
28
+ PLAN_SCHEMA = {
29
+ "type": "object",
30
+ "additionalProperties": False,
31
+ "required": ["objective", "milestones"],
32
+ "properties": {
33
+ "objective": {"type": "string"},
34
+ "milestones": {
35
+ "type": "array",
36
+ "items": {
37
+ "type": "object",
38
+ "additionalProperties": False,
39
+ "required": ["title", "rationale", "issues"],
40
+ "properties": {
41
+ "title": {"type": "string"},
42
+ "rationale": {"type": "string"},
43
+ "issues": {
44
+ "type": "array",
45
+ "items": {
46
+ "type": "object",
47
+ "additionalProperties": False,
48
+ "required": ["title", "body", "labels"],
49
+ "properties": {
50
+ "title": {"type": "string"},
51
+ "body": {"type": "string"},
52
+ "labels": {"type": "array", "items": {"type": "string"}},
53
+ },
54
+ },
55
+ },
56
+ },
57
+ },
58
+ },
59
+ },
60
+ }
61
+
62
+ PROMPT = """You are producing an IMPLEMENTATION PLAN ONLY. Do not write or modify any files.
63
+
64
+ Read the specification below and explore the repository as needed. Decompose the work
65
+ into a small number of milestones (delivery chunks), each broken into 5-10 concrete,
66
+ independently-shippable issues. Each issue should be a clear work contract: a title and
67
+ a short body describing scope and acceptance. Prefer the smallest safe slices.
68
+
69
+ Return ONLY a JSON object matching this shape:
70
+ {schema}
71
+
72
+ === SPECIFICATION (spec.md) ===
73
+ {spec}
74
+ """
75
+
76
+
77
+ def detect_host() -> str:
78
+ if shutil.which("claude"):
79
+ return "claude"
80
+ if shutil.which("codex"):
81
+ return "codex"
82
+ sys.exit("error: neither `claude` nor `codex` found on PATH")
83
+
84
+
85
+ def run_plan_codex(prompt: str, outdir: Path, timeout: int) -> dict:
86
+ """codex exec --sandbox read-only with an enforced output schema."""
87
+ schema_file = outdir / "plan.schema.json"
88
+ schema_file.write_text(json.dumps(PLAN_SCHEMA, indent=2))
89
+ last = outdir / "plan.codex.json"
90
+ cmd = [
91
+ "codex", "exec", "--sandbox", "read-only", "--skip-git-repo-check",
92
+ "--output-schema", str(schema_file), "-o", str(last), prompt,
93
+ ]
94
+ print(f"[codex] {' '.join(cmd[:6])} … (read-only, bounded)", file=sys.stderr)
95
+ subprocess.run(cmd, timeout=timeout, check=True)
96
+ return json.loads(last.read_text())
97
+
98
+
99
+ def run_plan_claude(prompt: str, outdir: Path, timeout: int) -> dict:
100
+ """claude -p --permission-mode plan (ExitPlanMode is terminal -> can't implement).
101
+
102
+ Claude can't enforce a schema, so we ask for JSON and parse the newest plan file
103
+ as a fallback. We snapshot ~/.claude/plans before/after to find the new file.
104
+ """
105
+ plans = Path.home() / ".claude" / "plans"
106
+ before = {p: p.stat().st_mtime for p in plans.glob("*.md")} if plans.exists() else {}
107
+ cmd = [
108
+ "claude", "-p", prompt, "--permission-mode", "plan",
109
+ "--setting-sources", "user,project,local", "--output-format", "json",
110
+ ]
111
+ print("[claude] claude -p --permission-mode plan … (ExitPlanMode terminal)", file=sys.stderr)
112
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=True)
113
+ # Prefer the structured result payload; fall back to newest plan file.
114
+ text = res.stdout
115
+ try:
116
+ payload = json.loads(text)
117
+ text = payload.get("result", text)
118
+ except json.JSONDecodeError:
119
+ pass
120
+ plan = _extract_json(text)
121
+ if plan is None and plans.exists():
122
+ new = sorted(
123
+ [p for p in plans.glob("*.md") if before.get(p) != p.stat().st_mtime],
124
+ key=lambda p: p.stat().st_mtime,
125
+ )
126
+ if new:
127
+ plan = _extract_json(new[-1].read_text())
128
+ (outdir / "plan.claude.raw.md").write_text(new[-1].read_text())
129
+ if plan is None:
130
+ sys.exit("error: could not extract a JSON plan from Claude output (see plan.claude.raw.md)")
131
+ return plan
132
+
133
+
134
+ def _extract_json(text: str) -> dict | None:
135
+ """Pull the first balanced {...} block that parses as our plan."""
136
+ start = text.find("{")
137
+ while start != -1:
138
+ depth = 0
139
+ for i in range(start, len(text)):
140
+ depth += (text[i] == "{") - (text[i] == "}")
141
+ if depth == 0:
142
+ try:
143
+ obj = json.loads(text[start : i + 1])
144
+ if isinstance(obj, dict) and "milestones" in obj:
145
+ return obj
146
+ except json.JSONDecodeError:
147
+ pass
148
+ break
149
+ start = text.find("{", start + 1)
150
+ return None
151
+
152
+
153
+ def write_plan_md(plan: dict, path: Path) -> None:
154
+ lines = [f"# Plan: {plan.get('objective', '(no objective)')}", ""]
155
+ for mi, m in enumerate(plan.get("milestones", []), 1):
156
+ lines.append(f"## M{mi}. {m['title']}")
157
+ if m.get("rationale"):
158
+ lines.append(f"_{m['rationale']}_")
159
+ lines.append("")
160
+ for ii, issue in enumerate(m.get("issues", []), 1):
161
+ labels = " ".join(f"`{l}`" for l in issue.get("labels", []))
162
+ lines.append(f"- [ ] **{issue['title']}** {labels}".rstrip())
163
+ if issue.get("body"):
164
+ lines.append(f" {issue['body']}")
165
+ lines.append("")
166
+ path.write_text("\n".join(lines))
167
+
168
+
169
+ def gh_projection(plan: dict, execute: bool) -> list[str]:
170
+ """Emit (or run) the `gh` commands that mirror the plan onto GitHub."""
171
+ cmds: list[str] = []
172
+ for m in plan.get("milestones", []):
173
+ cmds.append(
174
+ f'gh api repos/:owner/:repo/milestones -f title={_q(m["title"])} '
175
+ f'-f description={_q(m.get("rationale", ""))}'
176
+ )
177
+ for issue in m.get("issues", []):
178
+ labels = ",".join(issue.get("labels", []))
179
+ label_arg = f" --label {_q(labels)}" if labels else ""
180
+ cmds.append(
181
+ f'gh issue create --title {_q(issue["title"])} '
182
+ f'--body {_q(issue.get("body", ""))} '
183
+ f'--milestone {_q(m["title"])}{label_arg}'
184
+ )
185
+ if execute:
186
+ if not shutil.which("gh"):
187
+ sys.exit("error: --project given but `gh` not on PATH")
188
+ for c in cmds:
189
+ print(f"$ {c}", file=sys.stderr)
190
+ subprocess.run(c, shell=True, check=False)
191
+ return cmds
192
+
193
+
194
+ def _q(s: str) -> str:
195
+ return "'" + str(s).replace("'", "'\\''") + "'"
196
+
197
+
198
+ def main() -> None:
199
+ ap = argparse.ArgumentParser(description="Relay plan step (prototype)")
200
+ ap.add_argument("unit", help="path to work unit dir containing spec.md")
201
+ ap.add_argument("--host", choices=["claude", "codex", "auto"], default="auto")
202
+ ap.add_argument("--timeout", type=int, default=300)
203
+ ap.add_argument("--project", action="store_true",
204
+ help="actually create GitHub milestones/issues via gh (default: dry-run)")
205
+ args = ap.parse_args()
206
+
207
+ unit = Path(args.unit).resolve()
208
+ spec_file = unit / "spec.md"
209
+ if not spec_file.exists():
210
+ sys.exit(f"error: {spec_file} not found — run `align` first to produce the spec")
211
+
212
+ host = detect_host() if args.host == "auto" else args.host
213
+ prompt = PROMPT.format(schema=json.dumps(PLAN_SCHEMA), spec=spec_file.read_text())
214
+
215
+ t0 = time.time()
216
+ plan = (run_plan_codex if host == "codex" else run_plan_claude)(prompt, unit, args.timeout)
217
+ dt = time.time() - t0
218
+
219
+ (unit / "plan.json").write_text(json.dumps(plan, indent=2))
220
+ write_plan_md(plan, unit / "plan.md")
221
+ n_m = len(plan.get("milestones", []))
222
+ n_i = sum(len(m.get("issues", [])) for m in plan.get("milestones", []))
223
+ print(f"✓ planned via {host} in {dt:.0f}s → {n_m} milestones, {n_i} issues", file=sys.stderr)
224
+ print(f" wrote {unit/'plan.json'} and {unit/'plan.md'}", file=sys.stderr)
225
+
226
+ cmds = gh_projection(plan, execute=args.project)
227
+ (unit / "gh_projection.sh").write_text("#!/bin/bash\nset -e\n" + "\n".join(cmds) + "\n")
228
+ if args.project:
229
+ print(f"✓ projected {n_i} issues onto GitHub", file=sys.stderr)
230
+ else:
231
+ print(f" (dry-run) gh commands written to {unit/'gh_projection.sh'} — "
232
+ f"run with --project to execute", file=sys.stderr)
233
+
234
+
235
+ if __name__ == "__main__":
236
+ main()