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.
- opus_manager/__init__.py +3 -0
- opus_manager/__main__.py +3 -0
- opus_manager/agent.py +17 -0
- opus_manager/backlog.py +128 -0
- opus_manager/claude.py +138 -0
- opus_manager/cli.py +305 -0
- opus_manager/codex.py +119 -0
- opus_manager/comparison.py +162 -0
- opus_manager/config.py +134 -0
- opus_manager/contracts.py +58 -0
- opus_manager/desktop_actions.py +22 -0
- opus_manager/evidence.py +50 -0
- opus_manager/gitops.py +218 -0
- opus_manager/help.py +59 -0
- opus_manager/models.py +110 -0
- opus_manager/offline.py +142 -0
- opus_manager/operator_commands.py +86 -0
- opus_manager/orchestrator.py +1267 -0
- opus_manager/preflight.py +92 -0
- opus_manager/prompts.py +259 -0
- opus_manager/providers.py +11 -0
- opus_manager/reporting.py +82 -0
- opus_manager/state.py +37 -0
- opus_manager/tui.py +388 -0
- opus_manager/usage.py +77 -0
- opus_manager/verification.py +39 -0
- task_harbor/__init__.py +1 -0
- task_harbor/__main__.py +3 -0
- task_harbor-0.7.0.dist-info/METADATA +294 -0
- task_harbor-0.7.0.dist-info/RECORD +34 -0
- task_harbor-0.7.0.dist-info/WHEEL +5 -0
- task_harbor-0.7.0.dist-info/entry_points.txt +4 -0
- task_harbor-0.7.0.dist-info/licenses/LICENSE +21 -0
- task_harbor-0.7.0.dist-info/top_level.txt +2 -0
opus_manager/__init__.py
ADDED
opus_manager/__main__.py
ADDED
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
|
opus_manager/backlog.py
ADDED
|
@@ -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())
|