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/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
@@ -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()