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/gitops.py ADDED
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Iterable
10
+
11
+
12
+ class GitError(RuntimeError):
13
+ pass
14
+
15
+
16
+ @dataclass
17
+ class CmdResult:
18
+ code: int
19
+ stdout: str
20
+ stderr: str
21
+
22
+
23
+ def run(cmd: list[str], cwd: Path, *, check: bool = True, timeout: int = 120, env: dict[str, str] | None = None) -> CmdResult:
24
+ proc = subprocess.run(
25
+ cmd,
26
+ cwd=str(cwd),
27
+ text=True,
28
+ capture_output=True,
29
+ timeout=timeout,
30
+ env=env,
31
+ )
32
+ result = CmdResult(proc.returncode, proc.stdout.rstrip(), proc.stderr.rstrip())
33
+ if check and proc.returncode != 0:
34
+ raise GitError(f"Command failed ({proc.returncode}): {' '.join(cmd)}\n{result.stderr or result.stdout}")
35
+ return result
36
+
37
+
38
+ def git(repo: Path, *args: str, check: bool = True, timeout: int = 120) -> CmdResult:
39
+ return run(["git", *args], repo, check=check, timeout=timeout)
40
+
41
+
42
+ def ensure_repo(repo: Path) -> None:
43
+ if git(repo, "rev-parse", "--is-inside-work-tree", check=False).stdout != "true":
44
+ raise GitError(f"Not a Git worktree: {repo}")
45
+
46
+
47
+ def current_branch(repo: Path) -> str:
48
+ branch = git(repo, "branch", "--show-current").stdout
49
+ if not branch:
50
+ raise GitError("Detached HEAD is not supported for manager initialization. Check out a base branch first.")
51
+ return branch
52
+
53
+
54
+ def head(repo: Path) -> str:
55
+ return git(repo, "rev-parse", "HEAD").stdout
56
+
57
+
58
+ def status_paths(repo: Path) -> list[str]:
59
+ out = git(repo, "status", "--porcelain=v1", "--untracked-files=all").stdout
60
+ paths: list[str] = []
61
+ for line in out.splitlines():
62
+ if len(line) < 4:
63
+ continue
64
+ raw = line[3:]
65
+ if " -> " in raw:
66
+ raw = raw.split(" -> ", 1)[1]
67
+ paths.append(raw.strip('"'))
68
+ return paths
69
+
70
+
71
+ def meaningful_dirty(repo: Path, manager_dir: str, backlog_file: str | None = None) -> list[str]:
72
+ prefix = manager_dir.rstrip("/") + "/"
73
+ ignored = {manager_dir}
74
+ if backlog_file:
75
+ ignored.add(backlog_file.replace("\\", "/"))
76
+ return [
77
+ p for p in status_paths(repo)
78
+ if p.replace("\\", "/") not in ignored and not p.replace("\\", "/").startswith(prefix)
79
+ ]
80
+
81
+
82
+ def ensure_local_exclude(repo: Path, manager_dir: str) -> None:
83
+ git_dir = Path(git(repo, "rev-parse", "--git-path", "info/exclude").stdout)
84
+ if not git_dir.is_absolute():
85
+ git_dir = repo / git_dir
86
+ git_dir.parent.mkdir(parents=True, exist_ok=True)
87
+ existing = git_dir.read_text(encoding="utf-8") if git_dir.exists() else ""
88
+ entry = f"/{manager_dir.strip('/')}/"
89
+ if entry not in existing.splitlines():
90
+ with git_dir.open("a", encoding="utf-8") as f:
91
+ if existing and not existing.endswith("\n"):
92
+ f.write("\n")
93
+ f.write(entry + "\n")
94
+
95
+
96
+ def branch_exists(repo: Path, branch: str) -> bool:
97
+ return git(repo, "show-ref", "--verify", f"refs/heads/{branch}", check=False).code == 0
98
+
99
+
100
+ def worktree_add(repo: Path, path: Path, branch: str, start_point: str, *, create_branch: bool = True) -> None:
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ if path.exists():
103
+ if any(path.iterdir()):
104
+ raise GitError(f"Worktree path already exists and is not empty: {path}")
105
+ path.rmdir()
106
+ if create_branch:
107
+ git(repo, "worktree", "add", "-b", branch, str(path), start_point, timeout=300)
108
+ else:
109
+ git(repo, "worktree", "add", str(path), branch, timeout=300)
110
+
111
+
112
+ def detached_worktree_add(repo: Path, path: Path, commit: str) -> None:
113
+ path.parent.mkdir(parents=True, exist_ok=True)
114
+ if path.exists():
115
+ shutil.rmtree(path)
116
+ git(repo, "worktree", "add", "--detach", str(path), commit, timeout=300)
117
+
118
+
119
+ def worktree_remove(repo: Path, path: Path, *, force: bool = True) -> None:
120
+ if not path.exists():
121
+ return
122
+ args = ["worktree", "remove"]
123
+ if force:
124
+ args.append("--force")
125
+ args.append(str(path))
126
+ git(repo, *args, check=False, timeout=300)
127
+ if path.exists():
128
+ shutil.rmtree(path, ignore_errors=True)
129
+
130
+
131
+ def changed_paths(repo: Path) -> list[str]:
132
+ return status_paths(repo)
133
+
134
+
135
+ def matches_protected(path: str, protected: Iterable[str], backlog_file: str) -> bool:
136
+ normalized = path.replace("\\", "/")
137
+ if normalized == backlog_file.replace("\\", "/"):
138
+ return True
139
+ for pat in protected:
140
+ p = pat.replace("\\", "/")
141
+ if fnmatch.fnmatch(normalized, p) or normalized == p.rstrip("/") or normalized.startswith(p.rstrip("/") + "/"):
142
+ return True
143
+ return False
144
+
145
+
146
+ def restore_paths(repo: Path, paths: Iterable[str]) -> None:
147
+ tracked: list[str] = []
148
+ untracked: list[str] = []
149
+ for p in paths:
150
+ if git(repo, "ls-files", "--error-unmatch", "--", p, check=False).code == 0:
151
+ tracked.append(p)
152
+ else:
153
+ untracked.append(p)
154
+ if tracked:
155
+ git(repo, "restore", "--staged", "--worktree", "--", *tracked, check=False)
156
+ for p in untracked:
157
+ target = repo / p
158
+ if target.is_dir():
159
+ shutil.rmtree(target, ignore_errors=True)
160
+ elif target.exists():
161
+ target.unlink(missing_ok=True)
162
+
163
+
164
+ def checkpoint(repo: Path, message: str) -> str:
165
+ if not changed_paths(repo):
166
+ return head(repo)
167
+ git(repo, "add", "-A")
168
+ # Local checkpoint commits are infrastructure, not final delivery commits.
169
+ result = git(repo, "commit", "-m", message, check=False, timeout=300)
170
+ if result.code != 0:
171
+ text = (result.stderr + "\n" + result.stdout).lower()
172
+ if "nothing to commit" not in text:
173
+ raise GitError(f"Could not create local checkpoint commit: {result.stderr or result.stdout}")
174
+ return head(repo)
175
+
176
+
177
+ def merge_branch(integration_repo: Path, branch: str, task_id: str) -> tuple[bool, str]:
178
+ result = git(
179
+ integration_repo,
180
+ "merge", "--no-ff", "--no-edit", branch,
181
+ check=False,
182
+ timeout=300,
183
+ )
184
+ if result.code == 0:
185
+ return True, head(integration_repo)
186
+ conflict = git(integration_repo, "diff", "--name-only", "--diff-filter=U", check=False).stdout
187
+ git(integration_repo, "merge", "--abort", check=False)
188
+ return False, conflict or result.stderr or result.stdout
189
+
190
+
191
+ def diff_range(repo: Path, base: str, tip: str = "HEAD") -> str:
192
+ return git(repo, "diff", "--stat", f"{base}..{tip}", check=False).stdout + "\n\n" + git(
193
+ repo, "diff", "--find-renames", f"{base}..{tip}", check=False, timeout=300
194
+ ).stdout
195
+
196
+
197
+ def repo_snapshot(repo: Path) -> str:
198
+ branch = git(repo, "branch", "--show-current", check=False).stdout or "DETACHED"
199
+ h = head(repo)
200
+ status = git(repo, "status", "--short", check=False).stdout or "clean"
201
+ recent = git(repo, "log", "-5", "--oneline", check=False).stdout
202
+ return f"branch: {branch}\nhead: {h}\nstatus:\n{status}\nrecent commits:\n{recent}"
203
+
204
+
205
+ def remote_block_env(hooks_dir: Path) -> dict[str, str]:
206
+ hooks_dir.mkdir(parents=True, exist_ok=True)
207
+ hook = hooks_dir / "pre-push"
208
+ hook.write_text("#!/bin/sh\necho 'Remote git push blocked by Task Harbor' >&2\nexit 1\n", encoding="utf-8")
209
+ try:
210
+ hook.chmod(0o755)
211
+ except OSError:
212
+ pass
213
+ env = os.environ.copy()
214
+ # Environment-scoped Git config: inherited by git processes spawned by Claude.
215
+ env["GIT_CONFIG_COUNT"] = "1"
216
+ env["GIT_CONFIG_KEY_0"] = "core.hooksPath"
217
+ env["GIT_CONFIG_VALUE_0"] = str(hooks_dir)
218
+ return env
opus_manager/help.py ADDED
@@ -0,0 +1,59 @@
1
+ GUIDE = """TASK HARBOR — GETTING STARTED
2
+
3
+ 1. Open a terminal in your Git repository (with at least one commit).
4
+ task-harbor init
5
+ 2. Edit BACKLOG.md: give each task an ID and acceptance criteria.
6
+ 3. Edit .manager/config.json. Choose provider claude or codex and a model
7
+ available to your account. Sign in with that provider's CLI first.
8
+ For Codex, use a gpt-* model and leave both dollar caps null.
9
+ 4. Set verification_commands to your test/build commands. Set max_agent_calls
10
+ to limit usage; by default calls are unlimited.
11
+ 5. Check and run:
12
+ task-harbor check (read-only readiness; no model calls or project commands)
13
+ task-harbor preflight (suggests checks; no model calls)
14
+ task-harbor preflight --run-checks
15
+ task-harbor validate
16
+ task-harbor doctor (makes a model call)
17
+ task-harbor manage (starts paid/subscription agent work)
18
+ 6. Open another terminal in the same repository:
19
+ task-harbor
20
+
21
+ MONITOR AND CONTROL
22
+ List saved runs with task-harbor runs (or runs --status paused).
23
+ Use check --json and runs --json for machine-readable results.
24
+ Saved run status is a snapshot, not proof that a process is running.
25
+ The dashboard refreshes every two seconds. Viewing does not call models.
26
+ Set up creates config/backlog; Validate checks them without model calls.
27
+ New run and Resume show settings before you confirm agent work.
28
+ Stop pauses after in-flight calls. Launch output: .manager/launch-logs/.
29
+ Tab / Shift+Tab: change pane. Arrows: select task or run. F1: help. q: exit.
30
+ Closing the dashboard does not stop a run. Use:
31
+ task-harbor stop --run-id RUN_ID
32
+ task-harbor resume --run-id RUN_ID
33
+ Stop waits for in-flight calls to finish. Resume keeps existing usage counts.
34
+ Review results on manager/integration/RUN_ID. Nothing is automatically pushed.
35
+
36
+ EVIDENCE AND QUESTIONS
37
+ Press E in the dashboard to inspect a task, P for suggested checks.
38
+ task-harbor review --run-id RUN_ID --html review.html
39
+ task-harbor questions --run-id RUN_ID
40
+ task-harbor answer --run-id RUN_ID --task TASK_ID --question Q-1 --text "Your answer"
41
+ task-harbor resume --run-id RUN_ID
42
+ Record your review outcome separately from model QA:
43
+ task-harbor feedback --run-id RUN_ID --task TASK_ID --outcome accepted --minutes 5
44
+ Compare against a direct agent with an operator-written task manifest:
45
+ task-harbor compare --manifest comparison.json --output prepared
46
+ Preparation calls no models. Add --live with a new output folder to run both arms.
47
+
48
+ TROUBLESHOOTING
49
+ Command not found? Use python -m task_harbor with the same arguments.
50
+ No runs? Use Set up, edit the files, Validate, then New run.
51
+ A run is one execution of a backlog; the picker only appears when runs exist.
52
+ Different directory? Put --repo "path/to/app" BEFORE the command.
53
+ Unknown cost? The provider did not report it; it is not a zero-cost call.
54
+ Blocked task? Inspect task details and saved logs, fix the cause, then use
55
+ retry --run-id RUN_ID --task TASK_ID followed by resume --run-id RUN_ID.
56
+
57
+ Run task-harbor COMMAND --help for command options.
58
+ Documentation: https://github.com/rhit-nayeems/task-harbor
59
+ """
opus_manager/models.py ADDED
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from enum import StrEnum
5
+ from typing import Any
6
+
7
+
8
+ class TaskStatus(StrEnum):
9
+ UNPLANNED = "UNPLANNED"
10
+ PLANNING = "PLANNING"
11
+ PLAN_REVIEW = "PLAN_REVIEW"
12
+ READY = "READY"
13
+ IMPLEMENTING = "IMPLEMENTING"
14
+ QA = "QA"
15
+ QA_FAIL = "QA_FAIL"
16
+ QA_PASSED = "QA_PASSED"
17
+ CODE_REVIEW = "CODE_REVIEW"
18
+ COMPLETE = "COMPLETE"
19
+
20
+
21
+ class Priority(StrEnum):
22
+ BLOCKER = "blocker"
23
+ CRITICAL_BUG = "critical_bug"
24
+ HIGH_RISK = "high_risk"
25
+ CORE = "core"
26
+ LOW_RISK = "low_risk"
27
+ POLISH = "polish"
28
+
29
+
30
+ PRIORITY_ORDER = {
31
+ Priority.BLOCKER: 0,
32
+ Priority.CRITICAL_BUG: 1,
33
+ Priority.HIGH_RISK: 2,
34
+ Priority.CORE: 3,
35
+ Priority.LOW_RISK: 4,
36
+ Priority.POLISH: 5,
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class BacklogTask:
42
+ task_id: str
43
+ title: str
44
+ priority: Priority = Priority.CORE
45
+ status: TaskStatus = TaskStatus.UNPLANNED
46
+ owner: str | None = None
47
+ dependencies: list[str] = field(default_factory=list)
48
+ acceptance_criteria: list[str] = field(default_factory=list)
49
+ next_action: str = ""
50
+ blocked_reason: str | None = None
51
+ plan_path: str | None = None
52
+ plan_base_commit: str | None = None
53
+ plan_reworks: int = 0
54
+ implementer_session_id: str | None = None
55
+ worktree_path: str | None = None
56
+ branch_name: str | None = None
57
+ checkpoint_commit: str | None = None
58
+ last_qa_path: str | None = None
59
+ qa_failures: int = 0
60
+ generation: int = 0
61
+ last_error: str | None = None
62
+ completed_commit: str | None = None
63
+ metadata: dict[str, Any] = field(default_factory=dict)
64
+
65
+ @property
66
+ def blocked(self) -> bool:
67
+ return bool(self.blocked_reason)
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ d = asdict(self)
71
+ d["priority"] = self.priority.value
72
+ d["status"] = self.status.value
73
+ return d
74
+
75
+ @classmethod
76
+ def from_dict(cls, d: dict[str, Any]) -> "BacklogTask":
77
+ x = dict(d)
78
+ x["priority"] = Priority(x.get("priority", Priority.CORE))
79
+ x["status"] = TaskStatus(x.get("status", TaskStatus.UNPLANNED))
80
+ return cls(**x)
81
+
82
+
83
+ @dataclass
84
+ class ManagerState:
85
+ run_id: str
86
+ repo: str
87
+ integration_branch: str
88
+ integration_worktree: str
89
+ base_branch: str
90
+ base_commit: str
91
+ tasks: dict[str, BacklogTask] = field(default_factory=dict)
92
+ qa_passes_since_review: int = 0
93
+ last_review_commit: str | None = None
94
+ active_planners: list[str] = field(default_factory=list)
95
+ event_seq: int = 0
96
+ paused: bool = False
97
+ stop_reason: str | None = None
98
+ schema_version: int = 3
99
+ provider: str = "claude"
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ d = asdict(self)
103
+ d["tasks"] = {k: v.to_dict() for k, v in self.tasks.items()}
104
+ return d
105
+
106
+ @classmethod
107
+ def from_dict(cls, d: dict[str, Any]) -> "ManagerState":
108
+ x = dict(d)
109
+ x["tasks"] = {k: BacklogTask.from_dict(v) for k, v in x.get("tasks", {}).items()}
110
+ return cls(**x)
@@ -0,0 +1,142 @@
1
+ """Read-only local readiness and saved-run discovery."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import shutil
7
+ from dataclasses import fields
8
+ from pathlib import Path
9
+
10
+ from .backlog import parse_backlog
11
+ from .config import Config
12
+ from .contracts import load_contracts, unanswered, validate_contract
13
+ from .gitops import git
14
+ from .models import TaskStatus
15
+ from .orchestrator import ManagerError, validate_tasks
16
+ from .state import load_state
17
+
18
+
19
+ def read_config(repo: Path) -> Config:
20
+ path = repo / ".manager" / "config.json"
21
+ if not path.exists():
22
+ return Config()
23
+ raw = json.loads(path.read_text(encoding="utf-8"))
24
+ if not isinstance(raw, dict):
25
+ raise ValueError("Configuration must be a JSON object")
26
+ names = {f.name for f in fields(Config)}
27
+ return Config(**{k: v for k, v in raw.items() if k in names})
28
+
29
+
30
+ def readiness(repo: Path) -> dict:
31
+ checks = []
32
+
33
+ def add(name, status, detail):
34
+ checks.append({"name": name, "status": status, "detail": detail})
35
+
36
+ try:
37
+ cfg = read_config(repo)
38
+ except (ValueError, TypeError, OSError) as exc:
39
+ return {"ready": False, "checks": [{"name": "Configuration", "status": "error",
40
+ "detail": str(exc)}], "model_calls": 0}
41
+ configured = (repo / ".manager" / "config.json").is_file()
42
+ add("Configuration", "pass" if configured else "warning",
43
+ "Configuration loaded." if configured else "Run task-harbor init to create a configuration.")
44
+ if not shutil.which("git"):
45
+ add("Git", "error", "Install Git and put it on PATH.")
46
+ elif git(repo, "rev-parse", "--verify", "HEAD", check=False).code:
47
+ add("Git", "error", "Use a Git repository with at least one commit.")
48
+ else:
49
+ add("Git", "pass", "Repository has a committed starting point.")
50
+ dirty = bool(git(repo, "status", "--porcelain", check=False).stdout.strip())
51
+ add("Working tree", "warning" if dirty and cfg.allow_dirty_repo else "error" if dirty else "pass",
52
+ "Uncommitted changes exist; commit or stash them before a run." if dirty else "Working tree is clean.")
53
+ identity = all(git(repo, "config", key, check=False).stdout.strip()
54
+ for key in ("user.name", "user.email"))
55
+ add("Git author", "pass" if identity else "error",
56
+ "Git author is configured." if identity else "Configure git user.name and user.email.")
57
+ binary = cfg.codex_bin if cfg.provider == "codex" else cfg.claude_bin
58
+ add("Agent executable", "pass" if shutil.which(binary) else "error",
59
+ f"{cfg.provider}: executable found; authentication not tested." if shutil.which(binary)
60
+ else f"Install {cfg.provider} or correct its configured executable.")
61
+ for name in cfg.required_executables:
62
+ add(f"Executable: {name}", "pass" if shutil.which(name) else "error",
63
+ "Found on PATH." if shutil.which(name) else "Required executable is missing.")
64
+ for name in cfg.required_env:
65
+ add(f"Environment: {name}", "pass" if os.environ.get(name) else "error",
66
+ "Present (value hidden)." if os.environ.get(name) else "Required variable is missing.")
67
+ add("Verification", "pass" if cfg.verification_commands else "error" if cfg.require_verification else "warning",
68
+ f"{len(cfg.verification_commands)} configured command(s); not executed."
69
+ if cfg.verification_commands else "No checks configured. Add verification_commands before relying on QA.")
70
+ add("Verification policy", "pass" if cfg.require_verification else "warning",
71
+ "Checks are required." if cfg.require_verification else "require_verification is false; missing checks do not stop runs.")
72
+ add("Usage limit", "pass" if cfg.max_agent_calls else "warning",
73
+ f"Run limited to {cfg.max_agent_calls} agent calls." if cfg.max_agent_calls else "No run-wide call limit is configured.")
74
+ try:
75
+ tasks = parse_backlog(repo / cfg.backlog_file)
76
+ validate_tasks(tasks)
77
+ add("Backlog", "pass" if tasks else "error",
78
+ f"{len(tasks)} task(s) validated." if tasks else "Backlog contains no tasks.")
79
+ for task in tasks.values():
80
+ if not task.acceptance_criteria:
81
+ add(f"Acceptance: {task.task_id}", "error", "Add acceptance criteria before starting this task.")
82
+ contracts = load_contracts(repo, cfg.manager_dir)
83
+ for task_id, contract in contracts.items():
84
+ if task_id not in tasks:
85
+ add(f"Agreement: {task_id}", "warning", "No matching backlog task.")
86
+ continue
87
+ validate_contract(contract, len(tasks[task_id].acceptance_criteria))
88
+ pending = unanswered(contract)
89
+ if pending:
90
+ add(f"Agreement: {task_id}", "warning",
91
+ f"{len(pending)} unanswered question(s); this task will wait for clarification.")
92
+ except (ValueError, TypeError, OSError, ManagerError) as exc:
93
+ add("Backlog / agreements", "error", str(exc))
94
+ return {"ready": not any(c["status"] == "error" for c in checks), "checks": checks,
95
+ "model_calls": 0, "note": "Readiness only: authentication and command outcomes were not tested."}
96
+
97
+
98
+ def saved_runs(repo: Path, status: str | None = None) -> dict:
99
+ cfg = read_config(repo)
100
+ root = repo / cfg.manager_dir / "runs"
101
+ runs, errors = [], []
102
+ for directory in sorted(root.iterdir(), reverse=True) if root.exists() else []:
103
+ if not directory.is_dir() or directory.is_symlink():
104
+ continue
105
+ try:
106
+ state = load_state(directory / "state.json")
107
+ tasks = list(state.tasks.values())
108
+ complete = sum(t.status == TaskStatus.COMPLETE for t in tasks)
109
+ kind = "complete" if tasks and complete == len(tasks) else "paused" if state.paused else "unfinished"
110
+ item = {"id": directory.name, "status": kind, "provider": state.provider,
111
+ "completed": complete, "total": len(tasks),
112
+ "blocked": sum(t.blocked for t in tasks), "stop_reason": state.stop_reason,
113
+ "branch": state.integration_branch}
114
+ if status is None or status == kind:
115
+ runs.append(item)
116
+ except (ValueError, TypeError, KeyError, AttributeError, OSError) as exc:
117
+ errors.append({"id": directory.name, "error": str(exc)})
118
+ return {"runs": runs, "errors": errors,
119
+ "note": "Saved snapshots; unfinished does not mean a process is currently running."}
120
+
121
+
122
+ def run_offline(args, repo: Path) -> int:
123
+ result = readiness(repo) if args.command == "check" else saved_runs(repo, args.status)
124
+ if args.json:
125
+ print(json.dumps(result, indent=2))
126
+ elif args.command == "check":
127
+ for item in result["checks"]:
128
+ print(f"{item['status'].upper():7} {item['name']}: {item['detail']}")
129
+ print("Ready for a run." if result["ready"] else "Resolve the errors above before starting.")
130
+ print("No model calls or project commands executed. Authentication is not tested.")
131
+ else:
132
+ for run in result["runs"]:
133
+ print(f"{run['id']} {run['status']} {run['completed']}/{run['total']} complete "
134
+ f"{run['blocked']} blocked {run['provider']}")
135
+ if run["stop_reason"]:
136
+ print(f" {run['stop_reason']}")
137
+ if not result["runs"]:
138
+ print("No saved runs match. Start one with task-harbor manage.")
139
+ for error in result["errors"]:
140
+ print(f"Unreadable run {error['id']}: {error['error']}")
141
+ print(result["note"])
142
+ return 2 if (args.command == "check" and not result["ready"]) or result.get("errors") else 0
@@ -0,0 +1,86 @@
1
+ """Local operator actions; no model calls."""
2
+ import json
3
+ import math
4
+ from datetime import datetime, timezone
5
+
6
+ from .backlog import parse_backlog
7
+ from .contracts import load_contracts, validate_contract, unanswered
8
+ from .gitops import head, ensure_repo
9
+ from .orchestrator import Orchestrator
10
+ from .preflight import baseline, suggestions
11
+ from .reporting import run_report, write_html, review_text
12
+ from .state import atomic_write_json, load_state
13
+
14
+
15
+ def run_operator_command(args, repo, cfg):
16
+ root = repo / cfg.manager_dir
17
+ if args.command == "preflight":
18
+ ensure_repo(repo)
19
+ if not args.run_checks:
20
+ print(json.dumps({"suggestions_not_executed": suggestions(repo), "approved_commands": cfg.verification_commands,
21
+ "setup_commands": cfg.setup_commands,
22
+ "next": "Edit .manager/config.json, then preflight --run-checks"}, indent=2))
23
+ return 0
24
+ result = baseline(repo, cfg, head(repo))
25
+ atomic_write_json(root / "preflight.json", result)
26
+ print(json.dumps(result, indent=2))
27
+ return 0 if result["status"] == "PASS" else 2
28
+ if args.command == "contract":
29
+ tasks = parse_backlog(repo / cfg.backlog_file)
30
+ if args.task not in tasks:
31
+ raise ValueError("Task not found in backlog")
32
+ contract = validate_contract(json.loads(args.file.read_text(encoding="utf-8")), len(tasks[args.task].acceptance_criteria))
33
+ contracts = load_contracts(repo, cfg.manager_dir)
34
+ contracts[args.task] = contract
35
+ atomic_write_json(root / "contracts.json", contracts)
36
+ print("Agreement saved for future runs. Existing runs keep their saved agreement.")
37
+ return 0
38
+ run_dir = root / "runs" / args.run_id
39
+ if args.command == "review":
40
+ report = run_report(run_dir)
41
+ if args.html:
42
+ if args.task:
43
+ report["tasks"] = [t for t in report["tasks"] if t["id"] == args.task]
44
+ if not report["tasks"]:
45
+ raise ValueError("Task not found")
46
+ write_html(report, args.html)
47
+ print(f"Review: {args.html.resolve()}")
48
+ else:
49
+ print(review_text(report, args.task))
50
+ return 0
51
+ if args.command == "questions":
52
+ state = load_state(run_dir / "state.json")
53
+ print(json.dumps({t.task_id: unanswered(t.metadata.get("contract", {})) for t in state.tasks.values()
54
+ if unanswered(t.metadata.get("contract", {}))}, indent=2))
55
+ return 0
56
+ manager = Orchestrator.resume(repo, cfg, args.run_id)
57
+ manager._acquire_run_lock()
58
+ try:
59
+ manager.state = load_state(run_dir / "state.json")
60
+ task = manager.state.tasks.get(args.task)
61
+ if task is None:
62
+ raise ValueError("Task not found")
63
+ if args.command == "answer":
64
+ contract = task.metadata.get("contract", {})
65
+ if args.question not in contract.get("questions", {}) or not args.text.strip():
66
+ raise ValueError("Supply a known question ID and a nonempty answer")
67
+ contract.setdefault("answers", {})[args.question] = args.text
68
+ if not unanswered(contract) and task.metadata.get("blocked_kind") == "clarification":
69
+ task.blocked_reason = None
70
+ task.metadata.pop("blocked_kind", None)
71
+ manager._event("OPERATOR_ANSWER", task_id=args.task, question_id=args.question)
72
+ manager._sync_backlog()
73
+ print("Answer saved. Resume the run to continue.")
74
+ else:
75
+ if not math.isfinite(args.minutes) or args.minutes < 0:
76
+ raise ValueError("Review minutes must be finite and nonnegative")
77
+ path = run_dir / "feedback.json"
78
+ entries = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
79
+ entries.append({"task": args.task, "outcome": args.outcome, "active_review_minutes": args.minutes,
80
+ "note": args.note, "checkpoint": task.checkpoint_commit,
81
+ "time": datetime.now(timezone.utc).isoformat()})
82
+ atomic_write_json(path, entries)
83
+ print("Review feedback saved; task status was not changed.")
84
+ finally:
85
+ manager._release_run_lock()
86
+ return 0