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/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)
|
opus_manager/offline.py
ADDED
|
@@ -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
|