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/codex.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""GPT coding agents through the authenticated Codex CLI JSONL interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from .agent import AgentError, AgentResult
|
|
11
|
+
from .config import Config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def executable_command(binary: str) -> list[str]:
|
|
15
|
+
resolved = shutil.which(binary) or binary
|
|
16
|
+
if Path(resolved).suffix.lower() in {".cmd", ".bat", ".ps1"}:
|
|
17
|
+
# npm's Windows wrapper needs a shell. Invoke its JS entry point directly
|
|
18
|
+
# so prompts and TOML values never pass through cmd.exe quoting.
|
|
19
|
+
entry = Path(resolved).parent / "node_modules" / "@openai" / "codex" / "bin" / "codex.js"
|
|
20
|
+
node = shutil.which("node")
|
|
21
|
+
if not node or not entry.is_file():
|
|
22
|
+
raise AgentError("Set codex_bin to a native Codex executable, or install Codex with npm and Node on PATH")
|
|
23
|
+
return [node, str(entry)]
|
|
24
|
+
return [resolved]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_events(stdout: str, returncode: int, stderr: str = "", resume_session: str | None = None) -> AgentResult:
|
|
28
|
+
session = resume_session
|
|
29
|
+
final_text = None
|
|
30
|
+
complete = False
|
|
31
|
+
failures = []
|
|
32
|
+
usage = {}
|
|
33
|
+
for line in stdout.splitlines():
|
|
34
|
+
if not line.strip():
|
|
35
|
+
continue
|
|
36
|
+
try:
|
|
37
|
+
event = json.loads(line)
|
|
38
|
+
except json.JSONDecodeError as exc:
|
|
39
|
+
raise AgentError("Codex returned malformed JSONL") from exc
|
|
40
|
+
if not isinstance(event, dict):
|
|
41
|
+
raise AgentError("Codex returned a non-object event")
|
|
42
|
+
kind = event.get("type")
|
|
43
|
+
if kind == "thread.started":
|
|
44
|
+
candidate = event.get("thread_id")
|
|
45
|
+
try:
|
|
46
|
+
UUID(candidate)
|
|
47
|
+
except (ValueError, TypeError, AttributeError) as exc:
|
|
48
|
+
raise AgentError("Codex returned an invalid thread ID") from exc
|
|
49
|
+
if resume_session and candidate != resume_session:
|
|
50
|
+
raise AgentError("Codex resumed a different thread than requested")
|
|
51
|
+
session = candidate
|
|
52
|
+
elif kind == "item.completed":
|
|
53
|
+
item = event.get("item", {})
|
|
54
|
+
if isinstance(item, dict) and item.get("type") == "agent_message" and isinstance(item.get("text"), str):
|
|
55
|
+
final_text = item["text"]
|
|
56
|
+
elif kind == "turn.completed":
|
|
57
|
+
complete = True
|
|
58
|
+
reported_usage = event.get("usage") or {}
|
|
59
|
+
if not isinstance(reported_usage, dict):
|
|
60
|
+
raise AgentError("Codex returned malformed usage metadata")
|
|
61
|
+
for key, value in reported_usage.items():
|
|
62
|
+
if key.endswith("tokens") and type(value) is int and value >= 0:
|
|
63
|
+
usage[key] = usage.get(key, 0) + value
|
|
64
|
+
elif kind == "turn.failed":
|
|
65
|
+
failures.append(str(event.get("error", "Turn failed")))
|
|
66
|
+
elif kind == "error":
|
|
67
|
+
failures.append(str(event.get("message", "Codex error")))
|
|
68
|
+
raw = {"provider": "codex", "session_id": session, "usage": usage}
|
|
69
|
+
if returncode or failures or not complete or not session or not final_text:
|
|
70
|
+
raise AgentError("Codex failed or returned an incomplete turn: " + "; ".join(failures + [stderr[-2000:]]), raw)
|
|
71
|
+
return AgentResult(final_text, session, raw, returncode)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CodexClient:
|
|
75
|
+
def __init__(self, config: Config, hooks_dir: Path):
|
|
76
|
+
self.config = config
|
|
77
|
+
self.hooks_dir = hooks_dir
|
|
78
|
+
|
|
79
|
+
def probe(self, cwd: Path) -> AgentResult:
|
|
80
|
+
return self.run("Reply with the single word OK and nothing else.", cwd=cwd, role="analysis", max_turns=1)
|
|
81
|
+
|
|
82
|
+
def run(self, prompt: str, *, cwd: Path, role: str, max_turns: int,
|
|
83
|
+
resume_session: str | None = None, env: dict[str, str] | None = None,
|
|
84
|
+
budget_usd: float | None = None) -> AgentResult:
|
|
85
|
+
if role not in {"analysis", "read_only", "implementer"}:
|
|
86
|
+
raise ValueError(f"Unknown agent role: {role}")
|
|
87
|
+
if budget_usd is not None:
|
|
88
|
+
raise AgentError("Codex does not support dollar caps; use call limits and agent_timeout_seconds")
|
|
89
|
+
if resume_session:
|
|
90
|
+
try:
|
|
91
|
+
UUID(resume_session)
|
|
92
|
+
except (ValueError, TypeError, AttributeError) as exc:
|
|
93
|
+
raise AgentError("Invalid Codex resume session ID") from exc
|
|
94
|
+
sandbox = "workspace-write" if role == "implementer" else "read-only"
|
|
95
|
+
cmd = executable_command(self.config.codex_bin) + ["exec"]
|
|
96
|
+
if resume_session:
|
|
97
|
+
cmd.append("resume")
|
|
98
|
+
cmd += ["--json", "--ignore-user-config", "--model", self.config.model,
|
|
99
|
+
"-c", f"model_reasoning_effort={json.dumps(self.config.effort)}",
|
|
100
|
+
"-c", 'approval_policy="never"',
|
|
101
|
+
"-c", 'model_provider="openai"',
|
|
102
|
+
"-c", f"sandbox_mode={json.dumps(sandbox)}",
|
|
103
|
+
"-c", "sandbox_workspace_write.network_access=false"]
|
|
104
|
+
if resume_session:
|
|
105
|
+
cmd.append(resume_session)
|
|
106
|
+
cmd.append("-")
|
|
107
|
+
# max_turns is a Claude-specific control. Codex's bounded call uses the
|
|
108
|
+
# process timeout; no undocumented turn-limit flag is invented here.
|
|
109
|
+
instructions = ("Work only on the assigned task. Do not commit, push, create PRs, publish, deploy, "
|
|
110
|
+
"or modify BACKLOG.md or .manager/. The supervisor owns Git commits.\n\n")
|
|
111
|
+
try:
|
|
112
|
+
proc = subprocess.run(cmd, cwd=str(cwd), input=instructions + prompt,
|
|
113
|
+
text=True, encoding="utf-8", errors="replace", capture_output=True,
|
|
114
|
+
timeout=self.config.agent_timeout_seconds, env=env)
|
|
115
|
+
except FileNotFoundError as exc:
|
|
116
|
+
raise AgentError(f"Codex CLI not found: {self.config.codex_bin}. Install Codex and run codex login.") from exc
|
|
117
|
+
except subprocess.TimeoutExpired as exc:
|
|
118
|
+
raise AgentError(f"Codex timed out after {self.config.agent_timeout_seconds}s") from exc
|
|
119
|
+
return parse_events(proc.stdout, proc.returncode, proc.stderr, resume_session)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Opt-in unattended comparisons. Preparation never starts providers or graders."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import platform
|
|
6
|
+
import random
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .backlog import render_backlog
|
|
14
|
+
from .config import Config
|
|
15
|
+
from .contracts import validate_contract, unanswered
|
|
16
|
+
from .gitops import git, remote_block_env, ensure_local_exclude
|
|
17
|
+
from .models import BacklogTask
|
|
18
|
+
from .orchestrator import Orchestrator
|
|
19
|
+
from .preflight import baseline, execute_checks
|
|
20
|
+
from .providers import create_client
|
|
21
|
+
from .state import atomic_write_json
|
|
22
|
+
from .usage import UsageLedger, summarize
|
|
23
|
+
from .verification import run_checks
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_manifest(raw: dict, manifest: Path) -> dict:
|
|
27
|
+
if not isinstance(raw, dict):
|
|
28
|
+
raise ValueError("Comparison manifest must be an object")
|
|
29
|
+
if not isinstance(raw.get("repo"), str) or not isinstance(raw.get("commit"), str):
|
|
30
|
+
raise ValueError("Comparison needs repo and commit strings")
|
|
31
|
+
repo = (manifest.parent / raw["repo"]).resolve()
|
|
32
|
+
commit = git(repo, "rev-parse", "--verify", str(raw["commit"]) + "^{commit}").stdout
|
|
33
|
+
cfg = Config(**raw.get("config", {}))
|
|
34
|
+
tasks = raw.get("tasks", [])
|
|
35
|
+
if not isinstance(tasks, list) or not tasks:
|
|
36
|
+
raise ValueError("Comparison needs at least one task")
|
|
37
|
+
seen = set()
|
|
38
|
+
for task in tasks:
|
|
39
|
+
if not isinstance(task, dict):
|
|
40
|
+
raise ValueError("Each task must be an object")
|
|
41
|
+
tid = task.get("id", "")
|
|
42
|
+
if not isinstance(tid, str) or not tid or any(c not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" for c in tid) or tid in seen:
|
|
43
|
+
raise ValueError("Task IDs must be unique path-safe identifiers")
|
|
44
|
+
seen.add(tid)
|
|
45
|
+
if not isinstance(task.get("title"), str) or not task["title"].strip():
|
|
46
|
+
raise ValueError("Each task needs a title")
|
|
47
|
+
for key in ("acceptance_criteria", "grader_commands"):
|
|
48
|
+
values = task.get(key)
|
|
49
|
+
if not isinstance(values, list) or not values or any(not isinstance(v, str) or not v.strip() for v in values):
|
|
50
|
+
raise ValueError(f"Each task needs nonempty {key}")
|
|
51
|
+
contract = validate_contract(task.get("contract", {}), len(task["acceptance_criteria"]))
|
|
52
|
+
if unanswered(contract):
|
|
53
|
+
raise ValueError("Resolve all contract questions before comparing implementations")
|
|
54
|
+
return {"repo": str(repo), "commit": commit, "config": asdict(cfg), "tasks": tasks}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def compare(manifest: Path, output: Path, *, live=False, repetitions=1) -> dict:
|
|
58
|
+
if repetitions < 1:
|
|
59
|
+
raise ValueError("repetitions must be positive")
|
|
60
|
+
spec = validate_manifest(json.loads(manifest.read_text(encoding="utf-8")), manifest)
|
|
61
|
+
output.mkdir(parents=True, exist_ok=False)
|
|
62
|
+
schedule = [(task["id"], repetition, arm) for task in spec["tasks"]
|
|
63
|
+
for repetition in range(repetitions) for arm in ("direct", "harbor")]
|
|
64
|
+
random.Random(0).shuffle(schedule)
|
|
65
|
+
atomic_write_json(output / "manifest.json", spec)
|
|
66
|
+
atomic_write_json(output / "schedule.json", schedule)
|
|
67
|
+
if not live:
|
|
68
|
+
return {"status": "prepared", "output": str(output), "trials": len(schedule),
|
|
69
|
+
"model_calls": 0, "note": "Use --live with a new output directory to execute. Graders are operator-trusted shell commands."}
|
|
70
|
+
cfg = Config(**spec["config"])
|
|
71
|
+
results = []
|
|
72
|
+
source = Path(spec["repo"])
|
|
73
|
+
binary = cfg.codex_bin if cfg.provider == "codex" else cfg.claude_bin
|
|
74
|
+
from .codex import executable_command
|
|
75
|
+
try:
|
|
76
|
+
command = executable_command(binary) if cfg.provider == "codex" else [shutil.which(binary) or binary]
|
|
77
|
+
version_result = subprocess.run(command + ["--version"], capture_output=True, text=True, timeout=15)
|
|
78
|
+
provider_version = version_result.stdout.strip() if version_result.returncode == 0 else "unavailable"
|
|
79
|
+
except (OSError, RuntimeError, subprocess.TimeoutExpired):
|
|
80
|
+
provider_version = "unavailable"
|
|
81
|
+
for task_id, repetition, arm in schedule:
|
|
82
|
+
task_spec = next(t for t in spec["tasks"] if t["id"] == task_id)
|
|
83
|
+
trial = output / f"{task_id}-{repetition}-{arm}"
|
|
84
|
+
trial.mkdir()
|
|
85
|
+
repo = trial / "repo"
|
|
86
|
+
row = {"task": task_id, "repetition": repetition, "arm": arm,
|
|
87
|
+
"provider": cfg.provider, "model": cfg.model, "base_commit": spec["commit"],
|
|
88
|
+
"platform": platform.platform(), "python": platform.python_version(),
|
|
89
|
+
"provider_cli_version": provider_version,
|
|
90
|
+
"human_active_minutes": None, "human_outcome": None,
|
|
91
|
+
"accepted": False, "error": None}
|
|
92
|
+
started = time.monotonic()
|
|
93
|
+
try:
|
|
94
|
+
git(source, "clone", "--no-local", "--no-hardlinks", str(source), str(repo), timeout=300)
|
|
95
|
+
git(repo, "checkout", "-b", "harbor-comparison", spec["commit"])
|
|
96
|
+
for key in ("user.name", "user.email"):
|
|
97
|
+
value = git(source, "config", "--get", key).stdout
|
|
98
|
+
git(repo, "config", key, value)
|
|
99
|
+
task = BacklogTask(task_id, task_spec["title"], acceptance_criteria=task_spec["acceptance_criteria"])
|
|
100
|
+
task.metadata["contract"] = task_spec.get("contract", {})
|
|
101
|
+
render_backlog(repo / cfg.backlog_file, {task_id: task})
|
|
102
|
+
git(repo, "add", "--", cfg.backlog_file)
|
|
103
|
+
git(repo, "commit", "--allow-empty", "-m", "Prepare comparison task")
|
|
104
|
+
ensure_local_exclude(repo, cfg.manager_dir)
|
|
105
|
+
atomic_write_json(repo / cfg.manager_dir / "contracts.json", {task_id: task.metadata["contract"]})
|
|
106
|
+
row["baseline"] = baseline(repo, cfg, spec["commit"])
|
|
107
|
+
env = remote_block_env(trial / "hooks")
|
|
108
|
+
if row["baseline"]["status"] == "ENVIRONMENT_BLOCKED":
|
|
109
|
+
raise RuntimeError("Baseline environment blocked")
|
|
110
|
+
if cfg.require_verification and row["baseline"]["status"] != "PASS":
|
|
111
|
+
raise RuntimeError("Required baseline verification did not pass")
|
|
112
|
+
agent_started = time.monotonic()
|
|
113
|
+
if arm == "harbor":
|
|
114
|
+
manager = Orchestrator.start(repo, cfg)
|
|
115
|
+
manager.run()
|
|
116
|
+
candidate = Path(manager.state.integration_worktree)
|
|
117
|
+
row["manager_stop_reason"] = manager.state.stop_reason
|
|
118
|
+
row["usage"] = summarize(manager.usage.read())
|
|
119
|
+
else:
|
|
120
|
+
environment = execute_checks(cfg, repo, env)
|
|
121
|
+
if environment["status"] == "ENVIRONMENT_BLOCKED":
|
|
122
|
+
raise RuntimeError("Direct environment blocked")
|
|
123
|
+
client = create_client(cfg, trial / "hooks")
|
|
124
|
+
ledger = UsageLedger(trial / "usage.json", cfg)
|
|
125
|
+
call_id, cap = ledger.reserve("implementer", task_id)
|
|
126
|
+
# A single direct invocation contains the provider's normal tool loop.
|
|
127
|
+
# It receives the task and approved checks, not the independent grader.
|
|
128
|
+
prompt = "Implement this task, inspect the repository, run the approved checks, and repair failures. Do not push or publish.\n" + json.dumps({
|
|
129
|
+
"title": task.title, "acceptance_criteria": task.acceptance_criteria,
|
|
130
|
+
"contract": task.metadata["contract"], "verification_commands": cfg.verification_commands}, indent=2)
|
|
131
|
+
kwargs = {"cwd": repo, "role": "implementer", "max_turns": cfg.max_turns_implementer, "env": env}
|
|
132
|
+
if cfg.provider == "claude" and cap is not None:
|
|
133
|
+
kwargs["budget_usd"] = cap
|
|
134
|
+
try:
|
|
135
|
+
response = client.run(prompt, **kwargs)
|
|
136
|
+
ledger.finish(call_id, response.raw)
|
|
137
|
+
(trial / "direct-response.txt").write_text(response.text, encoding="utf-8")
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
ledger.finish(call_id, getattr(exc, "raw", None), str(exc))
|
|
140
|
+
raise
|
|
141
|
+
row["usage"] = summarize(ledger.read())
|
|
142
|
+
candidate = repo
|
|
143
|
+
row["agent_elapsed_seconds"] = round(time.monotonic() - agent_started, 3)
|
|
144
|
+
row["independent_checks"] = run_checks(task_spec["grader_commands"], candidate,
|
|
145
|
+
timeout=cfg.verification_timeout_seconds, limit=cfg.evidence_max_chars, env=env)
|
|
146
|
+
row["accepted"] = (len(row["independent_checks"]) == len(task_spec["grader_commands"])
|
|
147
|
+
and all(c["exit_code"] == 0 and not c["timed_out"] for c in row["independent_checks"]))
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
row["error"] = str(exc)
|
|
150
|
+
row["elapsed_seconds"] = round(time.monotonic() - started, 3)
|
|
151
|
+
# Persist every failed or successful trial, not just the winners.
|
|
152
|
+
for ledger_path in [trial / "usage.json", *repo.glob(f"{cfg.manager_dir}/runs/*/usage.json")]:
|
|
153
|
+
if "usage" not in row and ledger_path.exists():
|
|
154
|
+
row["usage"] = summarize(json.loads(ledger_path.read_text(encoding="utf-8")))
|
|
155
|
+
atomic_write_json(trial / "result.json", row)
|
|
156
|
+
results.append(row)
|
|
157
|
+
atomic_write_json(output / "results.json", results)
|
|
158
|
+
summary = {arm: {"attempts": sum(r["arm"] == arm for r in results),
|
|
159
|
+
"independently_passing": sum(r["arm"] == arm and r["accepted"] for r in results),
|
|
160
|
+
"errors": sum(r["arm"] == arm and r["error"] is not None for r in results)} for arm in ("direct", "harbor")}
|
|
161
|
+
atomic_write_json(output / "summary.json", summary)
|
|
162
|
+
return summary
|
opus_manager/config.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Config:
|
|
11
|
+
provider: str = "claude"
|
|
12
|
+
model: str = "opus"
|
|
13
|
+
effort: str = "medium"
|
|
14
|
+
claude_bin: str = "claude"
|
|
15
|
+
codex_bin: str = "codex"
|
|
16
|
+
max_active_planners: int = 4
|
|
17
|
+
max_fix_attempts_before_restart: int = 2
|
|
18
|
+
max_fresh_implementer_generations: int = 3
|
|
19
|
+
max_plan_reworks: int = 2
|
|
20
|
+
max_code_review_fix_attempts: int = 2
|
|
21
|
+
qa_passes_per_code_review: int = 3
|
|
22
|
+
agent_timeout_seconds: int = 3600
|
|
23
|
+
max_turns_manager: int = 12
|
|
24
|
+
max_turns_planner: int = 30
|
|
25
|
+
max_turns_implementer: int = 80
|
|
26
|
+
max_turns_qa: int = 40
|
|
27
|
+
max_turns_reviewer: int = 50
|
|
28
|
+
max_turns_diagnosis: int = 50
|
|
29
|
+
max_manager_cycles: int = 1000
|
|
30
|
+
scheduler: str = "deterministic"
|
|
31
|
+
final_code_review: bool = False
|
|
32
|
+
max_agent_calls: int | None = None
|
|
33
|
+
max_task_agent_calls: int | None = None
|
|
34
|
+
max_run_cost_usd: float | None = None
|
|
35
|
+
max_call_cost_usd: float | None = None
|
|
36
|
+
verification_timeout_seconds: int = 300
|
|
37
|
+
evidence_max_chars: int = 12000
|
|
38
|
+
verification_commands: list[str] = field(default_factory=list)
|
|
39
|
+
setup_commands: list[str] = field(default_factory=list)
|
|
40
|
+
required_executables: list[str] = field(default_factory=list)
|
|
41
|
+
required_env: list[str] = field(default_factory=list)
|
|
42
|
+
verification_artifacts: list[str] = field(default_factory=list)
|
|
43
|
+
require_verification: bool = False
|
|
44
|
+
workflow: str = "standard"
|
|
45
|
+
repeated_failure_limit: int = 4
|
|
46
|
+
protected_paths: list[str] = field(default_factory=lambda: [
|
|
47
|
+
".git", ".manager", ".env", ".env.*", "**/.env", "**/.env.*",
|
|
48
|
+
"*.pem", "*.key", "**/*.pem", "**/*.key", "secrets.*", "**/secrets.*"
|
|
49
|
+
])
|
|
50
|
+
allow_dirty_repo: bool = False
|
|
51
|
+
keep_task_worktrees: bool = True
|
|
52
|
+
manager_dir: str = ".manager"
|
|
53
|
+
backlog_file: str = "BACKLOG.md"
|
|
54
|
+
integration_branch_prefix: str = "manager/integration"
|
|
55
|
+
task_branch_prefix: str = "manager/task"
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
if self.workflow not in {"standard", "adaptive"}:
|
|
59
|
+
raise ValueError("workflow must be standard or adaptive")
|
|
60
|
+
if type(self.require_verification) is not bool:
|
|
61
|
+
raise ValueError("require_verification must be a boolean")
|
|
62
|
+
if type(self.repeated_failure_limit) is not int or self.repeated_failure_limit < 2:
|
|
63
|
+
raise ValueError("repeated_failure_limit must be at least 2")
|
|
64
|
+
if self.provider not in {"claude", "codex"}:
|
|
65
|
+
raise ValueError("provider must be claude or codex")
|
|
66
|
+
if not isinstance(self.model, str) or not self.model.strip():
|
|
67
|
+
raise ValueError("model must be a nonempty model identifier")
|
|
68
|
+
if self.provider == "codex":
|
|
69
|
+
if not self.model.startswith("gpt-"):
|
|
70
|
+
raise ValueError("Set model to a GPT model available in your Codex account when provider=codex")
|
|
71
|
+
if self.max_run_cost_usd is not None or self.max_call_cost_usd is not None:
|
|
72
|
+
raise ValueError("Codex dollar budgets are unsupported; set max_run_cost_usd and max_call_cost_usd to null and use call limits")
|
|
73
|
+
nonnegative = {"max_fix_attempts_before_restart", "max_plan_reworks", "max_code_review_fix_attempts"}
|
|
74
|
+
for name, value in asdict(self).items():
|
|
75
|
+
if name.startswith("max_turns_") or name in nonnegative | {
|
|
76
|
+
"max_active_planners", "max_fresh_implementer_generations", "qa_passes_per_code_review",
|
|
77
|
+
"agent_timeout_seconds", "max_manager_cycles", "verification_timeout_seconds", "evidence_max_chars",
|
|
78
|
+
}:
|
|
79
|
+
if type(value) is not int or value < (0 if name in nonnegative else 1):
|
|
80
|
+
raise ValueError(f"Invalid {name}: {value!r}")
|
|
81
|
+
if self.max_active_planners > 4:
|
|
82
|
+
raise ValueError("max_active_planners cannot exceed 4")
|
|
83
|
+
if self.scheduler not in {"deterministic", "model"}:
|
|
84
|
+
raise ValueError("scheduler must be deterministic or model")
|
|
85
|
+
if self.max_agent_calls is not None and (type(self.max_agent_calls) is not int or self.max_agent_calls < 1):
|
|
86
|
+
raise ValueError("max_agent_calls must be a positive integer")
|
|
87
|
+
if self.max_task_agent_calls is not None and (type(self.max_task_agent_calls) is not int or self.max_task_agent_calls < 1):
|
|
88
|
+
raise ValueError("max_task_agent_calls must be a positive integer")
|
|
89
|
+
for name in ("max_run_cost_usd", "max_call_cost_usd"):
|
|
90
|
+
value = getattr(self, name)
|
|
91
|
+
if value is not None and (type(value) not in {int, float} or not math.isfinite(value) or value <= 0):
|
|
92
|
+
raise ValueError(f"{name} must be a positive finite number")
|
|
93
|
+
for name in ("verification_commands", "setup_commands", "required_executables", "required_env", "verification_artifacts", "protected_paths"):
|
|
94
|
+
value = getattr(self, name)
|
|
95
|
+
if not isinstance(value, list) or any(not isinstance(x, str) or not x.strip() for x in value):
|
|
96
|
+
raise ValueError(f"{name} must be a list of nonempty strings")
|
|
97
|
+
for value in self.verification_artifacts:
|
|
98
|
+
path = Path(value)
|
|
99
|
+
if path.is_absolute() or ".." in path.parts:
|
|
100
|
+
raise ValueError("verification_artifacts must be paths inside the verification worktree")
|
|
101
|
+
for name in ("manager_dir", "backlog_file"):
|
|
102
|
+
value = Path(getattr(self, name))
|
|
103
|
+
if value.is_absolute() or ".." in value.parts or str(value) == ".":
|
|
104
|
+
raise ValueError(f"{name} must be a relative path inside the repository")
|
|
105
|
+
allowed_effort = {"low", "medium", "high", "xhigh", "max"} if self.provider == "claude" else {"low", "medium", "high", "xhigh", "max", "ultra"}
|
|
106
|
+
self.effort = self.effort.lower()
|
|
107
|
+
if self.effort not in allowed_effort:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"Invalid {'Claude' if self.provider == 'claude' else 'Codex'} effort {self.effort!r}; expected one of: "
|
|
110
|
+
+ ", ".join(sorted(allowed_effort))
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def save(self, path: Path) -> None:
|
|
114
|
+
path.write_text(json.dumps(asdict(self), indent=2) + "\n", encoding="utf-8")
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def load(cls, path: Path) -> "Config":
|
|
118
|
+
if not path.exists():
|
|
119
|
+
cfg = cls()
|
|
120
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
cfg.save(path)
|
|
122
|
+
return cfg
|
|
123
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
124
|
+
known = {f.name for f in __import__("dataclasses").fields(cls)}
|
|
125
|
+
cfg = cls(**{k: v for k, v in raw.items() if k in known})
|
|
126
|
+
missing = known.difference(raw)
|
|
127
|
+
if missing:
|
|
128
|
+
# Migrate older config files when new settings gain defaults. Preserve
|
|
129
|
+
# unknown keys so forward-compatible or locally managed settings are
|
|
130
|
+
# not destroyed by an older manager version.
|
|
131
|
+
migrated = dict(raw)
|
|
132
|
+
migrated.update(asdict(cfg))
|
|
133
|
+
path.write_text(json.dumps(migrated, indent=2) + "\n", encoding="utf-8")
|
|
134
|
+
return cfg
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Operator-owned task intent and checks. Agents cannot authorize new shell commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_contract(value: dict, criteria_count: int | None = None) -> dict:
|
|
9
|
+
allowed = {"risk", "out_of_scope", "constraints", "allowed_paths", "questions", "answers", "acceptance_checks", "reproduction_commands"}
|
|
10
|
+
if not isinstance(value, dict) or set(value) - allowed:
|
|
11
|
+
raise ValueError("Unknown contract fields")
|
|
12
|
+
if value.get("risk", "normal") not in {"low", "normal", "high"}:
|
|
13
|
+
raise ValueError("Contract risk must be low, normal, or high")
|
|
14
|
+
for key in ("out_of_scope", "constraints", "allowed_paths", "reproduction_commands"):
|
|
15
|
+
items = value.get(key, [])
|
|
16
|
+
if not isinstance(items, list) or any(not isinstance(x, str) or not x.strip() for x in items):
|
|
17
|
+
raise ValueError(f"Contract {key} must be a list of nonempty strings")
|
|
18
|
+
for key in ("questions", "answers"):
|
|
19
|
+
items = value.get(key, {})
|
|
20
|
+
if not isinstance(items, dict) or any(not isinstance(k, str) or not k or not isinstance(v, str) or not v.strip() for k, v in items.items()):
|
|
21
|
+
raise ValueError(f"Contract {key} must map IDs to nonempty text")
|
|
22
|
+
if set(value.get("answers", {})) - set(value.get("questions", {})):
|
|
23
|
+
raise ValueError("Answers must refer to known question IDs")
|
|
24
|
+
checks = value.get("acceptance_checks", {})
|
|
25
|
+
if not isinstance(checks, dict):
|
|
26
|
+
raise ValueError("acceptance_checks must map AC IDs to command lists")
|
|
27
|
+
for key, commands in checks.items():
|
|
28
|
+
if not isinstance(key, str) or not key.startswith("AC-") or not key[3:].isdigit() or int(key[3:]) < 1:
|
|
29
|
+
raise ValueError("Invalid acceptance ID")
|
|
30
|
+
if criteria_count is not None and int(key[3:]) > criteria_count:
|
|
31
|
+
raise ValueError(f"Unknown acceptance ID: {key}")
|
|
32
|
+
if not isinstance(commands, list) or not commands or any(not isinstance(c, str) or not c.strip() for c in commands):
|
|
33
|
+
raise ValueError("Acceptance commands must be nonempty string lists")
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_contracts(repo: Path, manager_dir: str) -> dict:
|
|
38
|
+
path = repo / manager_dir / "contracts.json"
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return {}
|
|
41
|
+
contracts = json.loads(path.read_text(encoding="utf-8"))
|
|
42
|
+
if not isinstance(contracts, dict):
|
|
43
|
+
raise ValueError("contracts.json must map task IDs to contracts")
|
|
44
|
+
return {key: validate_contract(value) for key, value in contracts.items()}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def unanswered(contract: dict) -> dict:
|
|
48
|
+
return {key: question for key, question in contract.get("questions", {}).items()
|
|
49
|
+
if not contract.get("answers", {}).get(key)}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fast_path(task, cfg) -> bool:
|
|
53
|
+
contract = task.metadata.get("contract", {})
|
|
54
|
+
expected = {f"AC-{i+1}" for i in range(len(task.acceptance_criteria))}
|
|
55
|
+
return bool(cfg.workflow == "adaptive" and contract.get("risk") == "low"
|
|
56
|
+
and contract.get("allowed_paths") and expected
|
|
57
|
+
and expected == set(contract.get("acceptance_checks", {}))
|
|
58
|
+
and not unanswered(contract))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Launch existing CLI commands without a shell or a second terminal."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def launch_run(repo: Path, runs: Path, run_id: str | None = None):
|
|
12
|
+
command = [sys.executable, "-m", "task_harbor", "--repo", str(repo)]
|
|
13
|
+
command += ["resume", "--run-id", run_id] if run_id else ["manage"]
|
|
14
|
+
logs = runs.parent / "launch-logs"
|
|
15
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
name = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
|
|
17
|
+
log = logs / f"{name}.log"
|
|
18
|
+
options = {"creationflags": subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt" else {"start_new_session": True}
|
|
19
|
+
with log.open("wb") as output:
|
|
20
|
+
process = subprocess.Popen(command, cwd=repo, stdin=subprocess.DEVNULL,
|
|
21
|
+
stdout=output, stderr=subprocess.STDOUT, **options)
|
|
22
|
+
return process, log
|
opus_manager/evidence.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Review inputs and human-readable evidence without model-generated claims."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fnmatch
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .gitops import git
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def change_evidence(repo: Path, base: str, tip: str, cfg, contract=None) -> dict:
|
|
13
|
+
contract = contract or {}
|
|
14
|
+
names = git(repo, "diff", "--no-renames", "--name-only", "-z", base, tip).stdout.split("\0")
|
|
15
|
+
files = []
|
|
16
|
+
for name in filter(None, names):
|
|
17
|
+
if name == cfg.backlog_file or name == cfg.manager_dir or name.startswith(cfg.manager_dir + "/"):
|
|
18
|
+
continue
|
|
19
|
+
patch = git(repo, "diff", "--no-ext-diff", "--no-renames", base, tip, "--", name, timeout=300).stdout
|
|
20
|
+
warnings = []
|
|
21
|
+
allowed = contract.get("allowed_paths", [])
|
|
22
|
+
if allowed and not any(fnmatch.fnmatchcase(name, pattern) for pattern in allowed):
|
|
23
|
+
warnings.append("outside_declared_scope")
|
|
24
|
+
if any(line.startswith("-") and not line.startswith("---") and ("assert" in line or "expect(" in line) for line in patch.splitlines()):
|
|
25
|
+
warnings.append("removed_assertion_review_required")
|
|
26
|
+
if any(line.startswith("+") and any(token in line for token in (".skip(", "@pytest.mark.skip", "@unittest.skip")) for line in patch.splitlines()):
|
|
27
|
+
warnings.append("skipped_test_review_required")
|
|
28
|
+
if name.rsplit("/", 1)[-1] in {"package.json", "package-lock.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod"}:
|
|
29
|
+
warnings.append("dependency_or_build_change")
|
|
30
|
+
files.append({"path": name, "diff": patch, "warnings": warnings,
|
|
31
|
+
"sha256": hashlib.sha256(patch.encode()).hexdigest()})
|
|
32
|
+
return {"base_commit": base, "checkpoint_commit": tip, "files": files}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def review_chunks(evidence: dict, limit: int):
|
|
36
|
+
for file in evidence["files"]:
|
|
37
|
+
patch = file["diff"]
|
|
38
|
+
count = max(1, (len(patch) + limit - 1) // limit)
|
|
39
|
+
for index in range(count):
|
|
40
|
+
yield {"path": file["path"], "part": index + 1, "parts": count,
|
|
41
|
+
"diff": patch[index * limit:(index + 1) * limit], "sha256": file["sha256"]}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def failure_signature(result: dict) -> str:
|
|
45
|
+
# Ignore timestamps, runtime, outputs and model prose; unchanged tree is also
|
|
46
|
+
# required by the caller, so a productive repair is not stopped by this signal.
|
|
47
|
+
payload = {"verdict": result.get("verdict"),
|
|
48
|
+
"checks": [(c.get("command"), c.get("exit_code"), c.get("timed_out")) for c in result.get("harness_checks", [])],
|
|
49
|
+
"acceptance": [(c.get("criterion_id"), c.get("status")) for c in result.get("acceptance", [])]}
|
|
50
|
+
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
|