task-harbor 0.7.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.
@@ -0,0 +1,3 @@
1
+ """Task Harbor: local Claude and GPT engineering supervisor."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
opus_manager/agent.py ADDED
@@ -0,0 +1,17 @@
1
+ """Provider-neutral result and error types."""
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+
6
+ class AgentError(RuntimeError):
7
+ def __init__(self, message: str, raw: dict | None = None):
8
+ super().__init__(message)
9
+ self.raw = raw
10
+
11
+
12
+ @dataclass
13
+ class AgentResult:
14
+ text: str
15
+ session_id: str | None
16
+ raw: dict[str, Any]
17
+ returncode: int
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from .models import BacklogTask, Priority, TaskStatus
7
+
8
+ TASK_RE = re.compile(r"^##\s+([A-Za-z0-9_.-]+)\s*[—-]\s*(.+?)\s*$")
9
+ FIELD_RE = re.compile(r"^-\s+\*\*(.+?):\*\*\s*(.*?)\s*$")
10
+
11
+
12
+ def _split_csv(value: str) -> list[str]:
13
+ value = value.strip()
14
+ if not value or value in {"—", "-", "none", "None", "[]"}:
15
+ return []
16
+ return [x.strip() for x in value.split(",") if x.strip()]
17
+
18
+
19
+ def parse_backlog(path: Path) -> dict[str, BacklogTask]:
20
+ if not path.exists():
21
+ raise FileNotFoundError(f"Backlog not found: {path}")
22
+ lines = path.read_text(encoding="utf-8").splitlines()
23
+ tasks: dict[str, BacklogTask] = {}
24
+ i = 0
25
+ while i < len(lines):
26
+ m = TASK_RE.match(lines[i])
27
+ if not m:
28
+ i += 1
29
+ continue
30
+ task_id, title = m.group(1), m.group(2)
31
+ if task_id in tasks:
32
+ raise ValueError(f"Duplicate backlog task ID: {task_id}")
33
+ fields: dict[str, str] = {}
34
+ criteria: list[str] = []
35
+ i += 1
36
+ in_criteria = False
37
+ while i < len(lines) and not TASK_RE.match(lines[i]):
38
+ line = lines[i]
39
+ fm = FIELD_RE.match(line)
40
+ if fm:
41
+ key = fm.group(1).strip().lower().replace(" ", "_")
42
+ val = fm.group(2).strip()
43
+ fields[key] = val
44
+ in_criteria = key == "acceptance_criteria"
45
+ elif in_criteria:
46
+ cm = re.match(r"^\s{2,}-\s+(.*)$", line)
47
+ if cm:
48
+ criteria.append(cm.group(1).strip())
49
+ elif line.strip():
50
+ in_criteria = False
51
+ i += 1
52
+ try:
53
+ priority = Priority(fields.get("priority", "core").lower())
54
+ except ValueError as e:
55
+ raise ValueError(f"{task_id}: invalid priority {fields.get('priority')!r}") from e
56
+ try:
57
+ status = TaskStatus(fields.get("status", "UNPLANNED").upper())
58
+ except ValueError as e:
59
+ raise ValueError(f"{task_id}: invalid status {fields.get('status')!r}") from e
60
+ owner = fields.get("owner") or None
61
+ if owner in {"—", "-", "none", "None"}:
62
+ owner = None
63
+ blocked_reason = fields.get("blocked_reason") or None
64
+ if blocked_reason in {"—", "-", "none", "None"}:
65
+ blocked_reason = None
66
+ tasks[task_id] = BacklogTask(
67
+ task_id=task_id,
68
+ title=title,
69
+ priority=priority,
70
+ status=status,
71
+ owner=owner,
72
+ dependencies=_split_csv(fields.get("dependencies", "")),
73
+ acceptance_criteria=criteria,
74
+ next_action=fields.get("next_action", ""),
75
+ blocked_reason=blocked_reason,
76
+ plan_path=(fields.get("plan") or None),
77
+ )
78
+ if not tasks:
79
+ raise ValueError(f"No tasks found in {path}. Use the documented BACKLOG.md format.")
80
+ return tasks
81
+
82
+
83
+ def render_backlog(path: Path, tasks: dict[str, BacklogTask]) -> None:
84
+ parts = [
85
+ "# Backlog",
86
+ "",
87
+ "> Managed by Task Harbor. Edit task intent/priority/dependencies/acceptance criteria when needed; runtime fields are synchronized by the manager.",
88
+ "",
89
+ ]
90
+ for t in tasks.values():
91
+ parts.extend([
92
+ f"## {t.task_id} — {t.title}",
93
+ f"- **Priority:** {t.priority.value}",
94
+ f"- **Status:** {t.status.value}",
95
+ f"- **Owner:** {t.owner or '—'}",
96
+ f"- **Dependencies:** {', '.join(t.dependencies) if t.dependencies else '—'}",
97
+ f"- **Blocked reason:** {t.blocked_reason or '—'}",
98
+ f"- **Plan:** {t.plan_path or '—'}",
99
+ "- **Acceptance criteria:**",
100
+ ])
101
+ if t.acceptance_criteria:
102
+ parts.extend([f" - {c}" for c in t.acceptance_criteria])
103
+ else:
104
+ parts.append(" - Define acceptance criteria")
105
+ parts.extend([
106
+ f"- **Next action:** {t.next_action or '—'}",
107
+ "",
108
+ ])
109
+ path.write_text("\n".join(parts).rstrip() + "\n", encoding="utf-8")
110
+
111
+
112
+ def write_template(path: Path) -> None:
113
+ path.write_text(
114
+ """# Backlog
115
+
116
+ ## TASK-001 — Replace this with real work
117
+ - **Priority:** core
118
+ - **Status:** UNPLANNED
119
+ - **Owner:** —
120
+ - **Dependencies:** —
121
+ - **Blocked reason:** —
122
+ - **Plan:** —
123
+ - **Acceptance criteria:**
124
+ - Define an observable success condition
125
+ - **Next action:** Plan the task
126
+ """,
127
+ encoding="utf-8",
128
+ )
opus_manager/claude.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .config import Config
10
+
11
+
12
+ # Backward-compatible imports for existing integrations.
13
+ from .agent import AgentError as ClaudeError, AgentResult as ClaudeResult
14
+
15
+
16
+ class ClaudeClient:
17
+ def __init__(self, config: Config, hooks_dir: Path):
18
+ self.config = config
19
+ self.hooks_dir = hooks_dir
20
+
21
+ def probe(self, cwd: Path) -> ClaudeResult:
22
+ return self.run(
23
+ "Reply with the single word OK and nothing else.",
24
+ cwd=cwd,
25
+ role="analysis",
26
+ max_turns=1,
27
+ )
28
+
29
+ def run(
30
+ self,
31
+ prompt: str,
32
+ *,
33
+ cwd: Path,
34
+ role: str,
35
+ max_turns: int,
36
+ resume_session: str | None = None,
37
+ env: dict[str, str] | None = None,
38
+ budget_usd: float | None = None,
39
+ ) -> ClaudeResult:
40
+ cmd = [
41
+ self.config.claude_bin,
42
+ "-p",
43
+ "--output-format", "json",
44
+ "--model", self.config.model,
45
+ "--effort", self.config.effort,
46
+ "--max-turns", str(max_turns),
47
+ ]
48
+ if resume_session:
49
+ cmd.extend(["--resume", resume_session])
50
+ if budget_usd is not None:
51
+ cmd.extend(["--max-budget-usd", str(budget_usd)])
52
+
53
+ # Read-only roles get inspection/test tools but no direct file editors.
54
+ # They run in disposable or manager-owned worktrees when shell access is required.
55
+ if role == "analysis":
56
+ # Claude plan permissions constrain analysis; this is not an OS sandbox.
57
+ cmd.extend(["--permission-mode", "plan"])
58
+ elif role == "read_only":
59
+ # Used only in disposable worktrees: shell access is useful for tests/repro,
60
+ # while editor tools remain unavailable and all mutations are discarded.
61
+ cmd.extend([
62
+ "--allowedTools", "Read", "Glob", "Grep", "Bash",
63
+ "--disallowedTools", "Edit", "Write", "NotebookEdit",
64
+ "Bash(git commit:*)", "Bash(git push:*)", "Bash(gh pr create:*)", "Bash(gh repo:*)",
65
+ ])
66
+ elif role == "implementer":
67
+ cmd.extend([
68
+ "--permission-mode", "acceptEdits",
69
+ "--allowedTools", "Read", "Glob", "Grep", "Edit", "Write", "NotebookEdit", "Bash",
70
+ "--disallowedTools", "Bash(git commit:*)", "Bash(git push:*)", "Bash(gh pr create:*)", "Bash(gh repo:*)",
71
+ "Bash(npm publish:*)", "Bash(pnpm publish:*)", "Bash(yarn publish:*)",
72
+ ])
73
+ else:
74
+ raise ValueError(f"Unknown Claude role: {role}")
75
+
76
+ try:
77
+ proc = subprocess.run(
78
+ cmd,
79
+ cwd=str(cwd),
80
+ input=prompt,
81
+ text=True,
82
+ encoding="utf-8",
83
+ errors="replace",
84
+ capture_output=True,
85
+ timeout=self.config.agent_timeout_seconds,
86
+ env=env,
87
+ )
88
+ except FileNotFoundError as e:
89
+ raise ClaudeError(f"Claude CLI not found: {self.config.claude_bin}") from e
90
+ except subprocess.TimeoutExpired as e:
91
+ raise ClaudeError(f"Claude timed out after {self.config.agent_timeout_seconds}s") from e
92
+
93
+ stdout = proc.stdout.strip()
94
+ stderr = proc.stderr.strip()
95
+ if not stdout:
96
+ raise ClaudeError(f"Claude produced no output (exit {proc.returncode}): {stderr}")
97
+ try:
98
+ raw = json.loads(stdout)
99
+ except json.JSONDecodeError as e:
100
+ raise ClaudeError(f"Claude returned non-JSON wrapper output: {stdout[:1000]}\n{stderr}") from e
101
+
102
+ text = raw.get("result")
103
+ if not isinstance(text, str):
104
+ # Be tolerant of evolving wrapper shapes while retaining raw data.
105
+ text = raw.get("message") if isinstance(raw.get("message"), str) else stdout
106
+ if proc.returncode != 0 or raw.get("is_error") is True:
107
+ raise ClaudeError(f"Claude failed (exit {proc.returncode}): {text}\n{stderr}", raw)
108
+ return ClaudeResult(
109
+ text=text,
110
+ session_id=raw.get("session_id"),
111
+ raw=raw,
112
+ returncode=proc.returncode,
113
+ )
114
+
115
+
116
+ def extract_json(text: str) -> dict[str, Any]:
117
+ """Extract a single JSON object from an agent response without trusting prose around it."""
118
+ text = text.strip()
119
+ if text.startswith("```"):
120
+ text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I)
121
+ text = re.sub(r"\s*```$", "", text)
122
+ try:
123
+ obj = json.loads(text)
124
+ if isinstance(obj, dict):
125
+ return obj
126
+ except json.JSONDecodeError:
127
+ pass
128
+ decoder = json.JSONDecoder()
129
+ for idx, ch in enumerate(text):
130
+ if ch != "{":
131
+ continue
132
+ try:
133
+ obj, _ = decoder.raw_decode(text[idx:])
134
+ if isinstance(obj, dict):
135
+ return obj
136
+ except json.JSONDecodeError:
137
+ continue
138
+ raise ClaudeError(f"Expected a JSON object from Claude, got: {text[:1500]}")
opus_manager/cli.py ADDED
@@ -0,0 +1,305 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .backlog import parse_backlog, write_template
9
+ from .config import Config
10
+ from .gitops import ensure_repo, git
11
+ from .orchestrator import ManagerError, Orchestrator, validate_tasks
12
+ from .state import load_state, atomic_write_json
13
+ from .reporting import run_report, write_html
14
+ from .models import TaskStatus
15
+
16
+
17
+ def _repo(value: str) -> Path:
18
+ p = Path(value).expanduser().resolve()
19
+ if not p.exists():
20
+ raise argparse.ArgumentTypeError(f"Path does not exist: {p}")
21
+ return p
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ p = argparse.ArgumentParser(
26
+ prog="task-harbor",
27
+ description="Run a coding backlog with Claude or GPT. With no command, open the dashboard.",
28
+ formatter_class=argparse.RawDescriptionHelpFormatter,
29
+ epilog="""Quick start (inside your Git repository):
30
+ task-harbor init Create config and sample backlog
31
+ # Edit BACKLOG.md and .manager/config.json
32
+ task-harbor validate Check tasks without model calls
33
+ task-harbor doctor Check agent connectivity (uses a model call)
34
+ task-harbor manage Start work (uses model calls)
35
+ task-harbor Monitor from another terminal
36
+
37
+ Help: task-harbor guide | task-harbor COMMAND --help
38
+ Dashboard keys: F1 help, Tab next pane, arrows select, r refresh, q quit.
39
+ Python fallback: python -m task_harbor
40
+ """,
41
+ )
42
+ p.add_argument("--version", action="version", version="%(prog)s 0.7.0")
43
+ p.add_argument("--repo", type=_repo, default=Path.cwd(), help="Target Git repository (default: current directory)")
44
+ sub = p.add_subparsers(dest="command")
45
+ p.set_defaults(command="tui", run_id=None)
46
+ sub.add_parser("guide", help="Show setup, usage, and troubleshooting instructions")
47
+ tui = sub.add_parser("tui", help="Open the live terminal dashboard")
48
+ tui.add_argument("--run-id", help="Select a saved run")
49
+
50
+ sub.add_parser("init", help="Create .manager/config.json and a BACKLOG.md template if absent")
51
+ sub.add_parser("doctor", help="Validate Git, backlog, configuration, and agent CLI connectivity")
52
+ sub.add_parser("validate", help="Validate backlog structure and dependency graph without running agents")
53
+ check = sub.add_parser("check", help="Read-only readiness audit; no model calls or project commands")
54
+ check.add_argument("--json", action="store_true", help="Print machine-readable readiness results")
55
+ runs = sub.add_parser("runs", help="List saved run snapshots without calling agents")
56
+ runs.add_argument("--json", action="store_true", help="Print machine-readable run summaries")
57
+ runs.add_argument("--status", choices=["unfinished", "paused", "complete"], help="Filter saved run status")
58
+
59
+ manage = sub.add_parser("manage", help="Start a new autonomous manager run")
60
+ manage.add_argument("--backlog", type=Path, default=None, help="Backlog path (default: config backlog_file)")
61
+
62
+ resume = sub.add_parser("resume", help="Resume an existing manager run")
63
+ resume.add_argument("--run-id", required=True)
64
+
65
+ status = sub.add_parser("status", help="Show persisted state for a run")
66
+ status.add_argument("--run-id", required=True)
67
+
68
+ cleanup = sub.add_parser("cleanup", help="Remove linked worktrees for a finished/abandoned run; branches are retained")
69
+ cleanup.add_argument("--run-id", required=True)
70
+ for name in ("stop", "report", "retry"):
71
+ command = sub.add_parser(name, help={"stop": "Stop at the next agent boundary", "report": "Offline JSON or HTML run report", "retry": "Explicitly retry a blocked task"}[name])
72
+ command.add_argument("--run-id", required=True)
73
+ if name == "report":
74
+ command.add_argument("--html", type=Path, help="Write an offline HTML dashboard")
75
+ if name == "retry":
76
+ command.add_argument("--task", required=True)
77
+ preflight = sub.add_parser("preflight", help="Suggest checks, or execute the approved recipe in a disposable worktree")
78
+ preflight.add_argument("--run-checks", action="store_true", help="Execute configured shell commands; no model calls")
79
+ contract = sub.add_parser("contract", help="Import an operator-owned task agreement for future runs")
80
+ contract.add_argument("--task", required=True)
81
+ contract.add_argument("--file", type=Path, required=True)
82
+ questions = sub.add_parser("questions", help="List unanswered questions from a saved run")
83
+ questions.add_argument("--run-id", required=True)
84
+ answer = sub.add_parser("answer", help="Record an answer; resume explicitly to continue")
85
+ answer.add_argument("--run-id", required=True)
86
+ answer.add_argument("--task", required=True)
87
+ answer.add_argument("--question", required=True)
88
+ answer.add_argument("--text", required=True)
89
+ review = sub.add_parser("review", help="Show acceptance evidence, review flags, and full task diffs without model calls")
90
+ review.add_argument("--run-id", required=True)
91
+ review.add_argument("--task")
92
+ review.add_argument("--html", type=Path)
93
+ feedback = sub.add_parser("feedback", help="Record human review outcome and active review minutes")
94
+ feedback.add_argument("--run-id", required=True)
95
+ feedback.add_argument("--task", required=True)
96
+ feedback.add_argument("--outcome", choices=["accepted", "rejected", "needs_changes"], required=True)
97
+ feedback.add_argument("--minutes", type=float, required=True)
98
+ feedback.add_argument("--note", default="")
99
+ compare = sub.add_parser("compare", help="Prepare or run an opt-in direct-agent versus Task Harbor comparison")
100
+ compare.add_argument("--manifest", type=Path, required=True)
101
+ compare.add_argument("--output", type=Path, required=True)
102
+ compare.add_argument("--live", action="store_true", help="Consumes model usage in both arms")
103
+ compare.add_argument("--repetitions", type=int, default=1)
104
+ return p
105
+
106
+
107
+ def load_config(repo: Path) -> Config:
108
+ path = repo / ".manager" / "config.json"
109
+ return Config.load(path)
110
+
111
+
112
+ def cmd_init(repo: Path) -> int:
113
+ ensure_repo(repo)
114
+ cfg = load_config(repo)
115
+ backlog = repo / cfg.backlog_file
116
+ if not backlog.exists():
117
+ write_template(backlog)
118
+ print(f"Created {backlog}")
119
+ else:
120
+ print(f"Kept existing {backlog}")
121
+ print(f"Config: {repo / cfg.manager_dir / 'config.json'}")
122
+ print("Next: edit BACKLOG.md, then run `task-harbor validate`, then `task-harbor doctor`.")
123
+ return 0
124
+
125
+
126
+ def cmd_validate(repo: Path, cfg: Config) -> int:
127
+ ensure_repo(repo)
128
+ tasks = parse_backlog(repo / cfg.backlog_file)
129
+ validate_tasks(tasks)
130
+ blocked = []
131
+ for t in tasks.values():
132
+ missing = [d for d in t.dependencies if tasks[d].status.value != "COMPLETE"]
133
+ if missing:
134
+ blocked.append((t.task_id, missing))
135
+ print(f"Valid backlog: {len(tasks)} task(s)")
136
+ if blocked:
137
+ print("Currently dependency-blocked:")
138
+ for tid, deps in blocked:
139
+ print(f" {tid}: {', '.join(deps)}")
140
+ return 0
141
+
142
+
143
+ def cmd_doctor(repo: Path, cfg: Config) -> int:
144
+ cmd_validate(repo, cfg)
145
+ # A transient throwaway run object is unnecessary; invoke Claude through a small temporary run context.
146
+ from .providers import create_client
147
+ hooks = repo / cfg.manager_dir / "doctor-hooks"
148
+ client = create_client(cfg, hooks)
149
+ result = client.probe(repo)
150
+ if result.text.strip().upper() != "OK":
151
+ raise ManagerError(f"Agent probe failed: {result.text}")
152
+ print(f"{cfg.provider} CLI: OK (model={cfg.model}, effort={cfg.effort})")
153
+ print(f"Git branch: {git(repo, 'branch', '--show-current').stdout}")
154
+ print("Doctor checks passed.")
155
+ return 0
156
+
157
+
158
+ def cmd_status(repo: Path, cfg: Config, run_id: str) -> int:
159
+ state_file = repo / cfg.manager_dir / "runs" / run_id / "state.json"
160
+ if not state_file.exists():
161
+ raise ManagerError(f"Run not found: {run_id}")
162
+ state = load_state(state_file)
163
+ print(f"Run: {state.run_id}")
164
+ print(f"Integration branch: {state.integration_branch}")
165
+ print(f"QA passes since review: {state.qa_passes_since_review}")
166
+ print(f"Paused: {state.paused}")
167
+ if state.stop_reason:
168
+ print(f"Stop reason: {state.stop_reason}")
169
+ print("Usage: " + json.dumps(run_report(state_file.parent)["usage"]))
170
+ print("Tasks:")
171
+ for t in state.tasks.values():
172
+ blocked = f" | blocked: {t.blocked_reason}" if t.blocked_reason else ""
173
+ print(f" {t.task_id:16} {t.status.value:12} {t.priority.value:14}{blocked}")
174
+ return 0
175
+
176
+
177
+ def cmd_cleanup(repo: Path, cfg: Config, run_id: str) -> int:
178
+ state_file = repo / cfg.manager_dir / "runs" / run_id / "state.json"
179
+ if not state_file.exists():
180
+ raise ManagerError(f"Run not found: {run_id}")
181
+ manager = Orchestrator.resume(repo, cfg, run_id)
182
+ manager._acquire_run_lock()
183
+ try:
184
+ state = load_state(state_file)
185
+ from .gitops import worktree_remove
186
+ root = manager.paths.worktrees_root.resolve()
187
+ candidates = [Path(t.worktree_path) for t in state.tasks.values() if t.worktree_path]
188
+ candidates.append(Path(state.integration_worktree))
189
+ for path in candidates:
190
+ if not path.resolve().is_relative_to(root):
191
+ raise ManagerError(f"Refusing cleanup outside run worktree root: {path}")
192
+ for path in candidates:
193
+ worktree_remove(repo, path, force=True)
194
+ finally:
195
+ manager._release_run_lock()
196
+ print("Linked worktrees removed. Local branches and run artifacts were retained.")
197
+ return 0
198
+
199
+
200
+ def main(argv: list[str] | None = None) -> int:
201
+ args = build_parser().parse_args(argv)
202
+ repo = args.repo.resolve()
203
+ try:
204
+ if args.command == "guide":
205
+ from .help import GUIDE
206
+ print(GUIDE)
207
+ return 0
208
+ if args.command == "init":
209
+ return cmd_init(repo)
210
+ if getattr(args, "run_id", None) is not None and (not args.run_id or any(c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" for c in args.run_id)):
211
+ raise ValueError("Invalid run ID")
212
+ if args.command == "tui":
213
+ try:
214
+ import textual # Check the optional extra before importing its dependencies.
215
+ from .tui import HarborApp
216
+ except ModuleNotFoundError as exc:
217
+ if exc.name != "textual":
218
+ raise
219
+ raise ManagerError('Install the dashboard first: python -m pip install ".[ui]" (from the Task Harbor checkout)') from exc
220
+ HarborApp(repo, args.run_id).run()
221
+ return 0
222
+ if args.command == "compare":
223
+ from .comparison import compare
224
+ result = compare(args.manifest.resolve(), args.output.resolve(), live=args.live, repetitions=args.repetitions)
225
+ print(json.dumps(result, indent=2))
226
+ return 0
227
+ if args.command in {"check", "runs"}:
228
+ from .offline import run_offline
229
+ return run_offline(args, repo)
230
+ cfg = load_config(repo)
231
+ if args.command in {"preflight", "contract", "questions", "answer", "review", "feedback"}:
232
+ from .operator_commands import run_operator_command
233
+ return run_operator_command(args, repo, cfg)
234
+ if args.command in {"report", "stop", "retry"}:
235
+ run_dir = repo / cfg.manager_dir / "runs" / args.run_id
236
+ if not (run_dir / "state.json").exists():
237
+ raise ManagerError(f"Run not found: {args.run_id}")
238
+ if args.command == "report":
239
+ report = run_report(run_dir)
240
+ if args.html:
241
+ write_html(report, args.html)
242
+ print(f"Report: {args.html.resolve()}")
243
+ else:
244
+ print(json.dumps(report, indent=2))
245
+ return 0
246
+ if args.command == "stop":
247
+ atomic_write_json(run_dir / "stop-request.json", {"requested": True})
248
+ print("Stop requested. In-flight calls finish; no new calls will start.")
249
+ return 0
250
+ manager = Orchestrator.resume(repo, cfg, args.run_id)
251
+ manager._acquire_run_lock()
252
+ try:
253
+ manager.state = load_state(run_dir / "state.json")
254
+ task = manager.state.tasks.get(args.task)
255
+ if task is None or not task.blocked:
256
+ raise ManagerError("retry requires an existing blocked task")
257
+ manager._recover_interrupted_tasks()
258
+ needs_review = task.metadata.get("blocked_kind") == "plan_rework_limit"
259
+ task.blocked_reason = None
260
+ task.metadata.pop("blocked_kind", None)
261
+ task.metadata.pop("failure_signature", None)
262
+ task.metadata.pop("identical_failures", None)
263
+ task.qa_failures = 0
264
+ task.generation = 0
265
+ task.plan_reworks = 0
266
+ task.implementer_session_id = None
267
+ task.status = (TaskStatus.PLAN_REVIEW if needs_review else TaskStatus.READY) if task.plan_path else TaskStatus.UNPLANNED
268
+ task.metadata["resume_mode"] = "implementation"
269
+ manager._event("OPERATOR_RETRY", task_id=task.task_id)
270
+ manager._sync_backlog()
271
+ finally:
272
+ manager._release_run_lock()
273
+ print("Task retry recorded. Resume the run to continue; usage budgets remain cumulative.")
274
+ return 0
275
+ if args.command == "validate":
276
+ return cmd_validate(repo, cfg)
277
+ if args.command == "doctor":
278
+ return cmd_doctor(repo, cfg)
279
+ if args.command == "manage":
280
+ backlog = args.backlog
281
+ if backlog is not None and not backlog.is_absolute():
282
+ backlog = repo / backlog
283
+ manager = Orchestrator.start(repo, cfg, backlog)
284
+ print(f"Run ID: {manager.state.run_id}")
285
+ print(f"Integration branch: {manager.state.integration_branch}")
286
+ manager.run()
287
+ print(f"Stopped: {manager.state.stop_reason}")
288
+ return 0 if not manager.state.paused else 2
289
+ if args.command == "resume":
290
+ manager = Orchestrator.resume(repo, cfg, args.run_id)
291
+ manager.run()
292
+ print(f"Stopped: {manager.state.stop_reason}")
293
+ return 0 if not manager.state.paused else 2
294
+ if args.command == "status":
295
+ return cmd_status(repo, cfg, args.run_id)
296
+ if args.command == "cleanup":
297
+ return cmd_cleanup(repo, cfg, args.run_id)
298
+ raise ManagerError(f"Unknown command: {args.command}")
299
+ except (ManagerError, OSError, ValueError, RuntimeError) as e:
300
+ print(f"ERROR: {e}", file=sys.stderr)
301
+ return 2
302
+
303
+
304
+ if __name__ == "__main__":
305
+ raise SystemExit(main())