harness-agent2 1.1.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.
- harness/__init__.py +3 -0
- harness/__main__.py +6 -0
- harness/agents.py +75 -0
- harness/bundle.py +110 -0
- harness/ci.py +112 -0
- harness/cli.py +134 -0
- harness/contract.py +100 -0
- harness/eval.py +242 -0
- harness/evidence.py +94 -0
- harness/gitops.py +30 -0
- harness/guard.py +39 -0
- harness/policy.py +149 -0
- harness/pr.py +64 -0
- harness/runner.py +300 -0
- harness/scan.py +92 -0
- harness/server.py +344 -0
- harness/shellx.py +22 -0
- harness/verify.py +56 -0
- harness_agent2-1.1.0.dist-info/METADATA +10 -0
- harness_agent2-1.1.0.dist-info/RECORD +23 -0
- harness_agent2-1.1.0.dist-info/WHEEL +4 -0
- harness_agent2-1.1.0.dist-info/entry_points.txt +2 -0
- harness_agent2-1.1.0.dist-info/licenses/LICENSE +661 -0
harness/__init__.py
ADDED
harness/__main__.py
ADDED
harness/agents.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Agent adapters: the same contract lifecycle driven by different coding agents.
|
|
2
|
+
|
|
3
|
+
Enforcement asymmetry, stated honestly: Claude runs under the PreToolUse guard hook
|
|
4
|
+
(.claude/settings.json); Codex has no hook equivalent, so it relies on AGENTS.md rules
|
|
5
|
+
plus its workspace-write sandbox, with the scope gate in `harness verify` catching
|
|
6
|
+
violations after the fact.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from types import MappingProxyType
|
|
15
|
+
|
|
16
|
+
from harness.policy import die
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AgentSpec:
|
|
21
|
+
binary: str
|
|
22
|
+
argv: Callable[[str], list[str]]
|
|
23
|
+
enforcement_tier: str
|
|
24
|
+
description: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
AGENT_SPECS: Mapping[str, AgentSpec] = MappingProxyType({
|
|
28
|
+
"claude": AgentSpec("claude", lambda prompt: ["claude", "-p", prompt, "--permission-mode", "acceptEdits"],
|
|
29
|
+
"guard-hook", "Claude Code with the PreToolUse guard hook."),
|
|
30
|
+
"codex": AgentSpec("codex", lambda prompt: ["codex", "exec", "--sandbox", "workspace-write", prompt],
|
|
31
|
+
"sandbox+verify", "Codex with workspace-write sandbox and verify scope gate."),
|
|
32
|
+
})
|
|
33
|
+
AGENTS = tuple(AGENT_SPECS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def agent_prompt(c) -> str:
|
|
37
|
+
return (f"Goal: {c['goal']}\nAcceptance criteria:\n" + "\n".join(f"- {x}" for x in c["accept"]) +
|
|
38
|
+
"\nFirst write a short plan, then implement. Do not touch protected paths. Before finishing, run: " +
|
|
39
|
+
"; ".join(g["cmd"] for g in c["gates"].values()) + ". Do not claim completion while any fails.")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_argv(agent: str, prompt: str) -> list[str]:
|
|
43
|
+
try:
|
|
44
|
+
return AGENT_SPECS[agent].argv(prompt)
|
|
45
|
+
except KeyError:
|
|
46
|
+
die(f"unknown agent: {agent} (supported: {', '.join(AGENT_SPECS)})")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_agents(args) -> int:
|
|
50
|
+
rows = []
|
|
51
|
+
for name, spec in AGENT_SPECS.items():
|
|
52
|
+
path = shutil.which(spec.binary)
|
|
53
|
+
version = None
|
|
54
|
+
if path:
|
|
55
|
+
for flag in ("--version", "version"):
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run([path, flag], capture_output=True, text=True, timeout=5, check=False)
|
|
58
|
+
except (OSError, subprocess.SubprocessError):
|
|
59
|
+
continue
|
|
60
|
+
if result.returncode == 0:
|
|
61
|
+
lines = (result.stdout + result.stderr).strip().splitlines()
|
|
62
|
+
version = lines[0][:90] if lines else "present"
|
|
63
|
+
break
|
|
64
|
+
if version is None:
|
|
65
|
+
version = "present"
|
|
66
|
+
rows.append({"name": name, "binary": spec.binary, "present": path is not None,
|
|
67
|
+
"version": version, "enforcement_tier": spec.enforcement_tier})
|
|
68
|
+
if args.json:
|
|
69
|
+
print(json.dumps(rows))
|
|
70
|
+
else:
|
|
71
|
+
print(f"{'NAME':<12} {'PRESENT':<8} {'VERSION':<24} ENFORCEMENT TIER")
|
|
72
|
+
for row in rows:
|
|
73
|
+
print(f"{row['name']:<12} {'yes' if row['present'] else 'no':<8} "
|
|
74
|
+
f"{(row['version'] or '-'):<24} {row['enforcement_tier']}")
|
|
75
|
+
return 0
|
harness/bundle.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Team policy bundle: the shared files of .ai-engineering/ versioned in a Git repo.
|
|
2
|
+
|
|
3
|
+
Bundle = policy.json + verification.json at .ai-engineering/ in the bundle repo.
|
|
4
|
+
Everything else stays local on purpose: project.json carries machine-specific
|
|
5
|
+
paths, and tasks/evidence/audit.log are per-repo history.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from harness.policy import AE, die, find_root
|
|
14
|
+
|
|
15
|
+
BUNDLE = ("policy.json", "verification.json")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _git(cwd: Path, *a: str) -> tuple[int, str]:
|
|
19
|
+
p = subprocess.run(["git", *a], cwd=str(cwd), capture_output=True, text=True, check=False)
|
|
20
|
+
return p.returncode, (p.stdout + p.stderr).rstrip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _clone(remote: str, branch: str) -> Path:
|
|
24
|
+
tmp = Path(tempfile.mkdtemp(prefix="harness-bundle-"))
|
|
25
|
+
clone = tmp / "repo"
|
|
26
|
+
rc, out = _git(tmp, "clone", remote, "repo")
|
|
27
|
+
if rc:
|
|
28
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
29
|
+
die(f"cannot clone bundle remote {remote!r}: {out}")
|
|
30
|
+
if _git(clone, "rev-parse", "--verify", f"origin/{branch}")[0] == 0:
|
|
31
|
+
_git(clone, "checkout", "-B", branch, f"origin/{branch}")
|
|
32
|
+
else:
|
|
33
|
+
# non-empty repo without the branch: new branch off HEAD; empty repo: unborn HEAD
|
|
34
|
+
_git(clone, "checkout", "-B", branch)
|
|
35
|
+
return clone
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _need_local(r: Path) -> None:
|
|
39
|
+
miss = [n for n in BUNDLE if not (r / AE / n).exists()]
|
|
40
|
+
if miss:
|
|
41
|
+
die(f"missing {', '.join(miss)}; run `harness init` first")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _remote_bundle(clone: Path) -> dict[str, str]:
|
|
45
|
+
out = {}
|
|
46
|
+
for n in BUNDLE:
|
|
47
|
+
p = clone / AE / n
|
|
48
|
+
if p.exists():
|
|
49
|
+
out[n] = p.read_text()
|
|
50
|
+
else:
|
|
51
|
+
die(f"bundle remote has no {AE}/{n}; run `harness policy push` from a member repo first")
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _push(r: Path, clone: Path, a) -> int:
|
|
56
|
+
_need_local(r)
|
|
57
|
+
dst = clone / AE
|
|
58
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
for n in BUNDLE:
|
|
60
|
+
shutil.copy2(r / AE / n, dst / n)
|
|
61
|
+
_git(clone, "add", "-A")
|
|
62
|
+
rc, out = _git(clone, "commit", "-m", f"harness policy bundle: {r.name}")
|
|
63
|
+
if "nothing to commit" in out:
|
|
64
|
+
print("Bundle : unchanged (remote already has this state)")
|
|
65
|
+
return 0
|
|
66
|
+
rc, out = _git(clone, "push", "origin", f"HEAD:{a.branch}")
|
|
67
|
+
if rc:
|
|
68
|
+
die(f"push failed: {out}")
|
|
69
|
+
print(f"Pushed : {_git(clone, 'rev-parse', 'HEAD')[1]} -> {a.remote} ({a.branch})")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _status(r: Path, clone: Path, a) -> int:
|
|
74
|
+
_need_local(r)
|
|
75
|
+
remote = _remote_bundle(clone)
|
|
76
|
+
drift = [n for n in BUNDLE if remote[n] != (r / AE / n).read_text()]
|
|
77
|
+
for n in drift:
|
|
78
|
+
print(f"DRIFT : {AE}/{n} differs from {a.remote} ({a.branch})")
|
|
79
|
+
if drift:
|
|
80
|
+
return 1
|
|
81
|
+
print(f"In sync : {', '.join(BUNDLE)} match {a.remote} ({a.branch})")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _pull(r: Path, clone: Path, a) -> int:
|
|
86
|
+
remote = _remote_bundle(clone)
|
|
87
|
+
updated = []
|
|
88
|
+
for n in BUNDLE:
|
|
89
|
+
lp = r / AE / n
|
|
90
|
+
if not lp.exists() or lp.read_text() != remote[n]:
|
|
91
|
+
lp.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
lp.write_text(remote[n])
|
|
93
|
+
updated.append(n)
|
|
94
|
+
print(f"Pulled : {', '.join(updated) if updated else 'nothing (already in sync)'}")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cmd_policy(a) -> int:
|
|
99
|
+
r = find_root(a.repo)
|
|
100
|
+
if not r:
|
|
101
|
+
die("run `harness init` first")
|
|
102
|
+
clone = _clone(a.remote, a.branch)
|
|
103
|
+
try:
|
|
104
|
+
if a.polc == "push":
|
|
105
|
+
return _push(r, clone, a)
|
|
106
|
+
if a.polc == "status":
|
|
107
|
+
return _status(r, clone, a)
|
|
108
|
+
return _pull(r, clone, a)
|
|
109
|
+
finally:
|
|
110
|
+
shutil.rmtree(clone.parent, ignore_errors=True)
|
harness/ci.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""CI generator: a GitHub Actions workflow running the readiness floor and the repo's gates.
|
|
2
|
+
|
|
3
|
+
The YAML is generated from the repo's own verification.json, so CI and
|
|
4
|
+
`harness verify` execute the same commands. Re-running `harness ci` updates the
|
|
5
|
+
file in place (marker line); a hand-written workflow of the same name is only
|
|
6
|
+
touched with --force.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from harness.policy import AE, die, find_root, jload
|
|
12
|
+
from harness.scan import scan
|
|
13
|
+
|
|
14
|
+
WORKFLOW = Path(".github") / "workflows" / "harness.yml"
|
|
15
|
+
MARKER = "Generated by `harness ci`"
|
|
16
|
+
LOCKFILES = {"package-lock.json": "npm ci --no-fund --no-audit", "pnpm-lock.yaml": "pnpm install --frozen-lockfile",
|
|
17
|
+
"yarn.lock": "yarn install --frozen-lockfile"}
|
|
18
|
+
SKIP_DIRS = {"node_modules", ".venv", "venv", "dist", "build", "test-results"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _manifest_dirs(r: Path) -> list[Path]:
|
|
22
|
+
"""Dirs holding a JS package: the root plus depth-1 app dirs (web/, frontend/…)."""
|
|
23
|
+
dirs = [r] if (r / "package.json").exists() else []
|
|
24
|
+
dirs += [p.parent for p in sorted(r.glob("*/package.json")) if p.parent.name not in SKIP_DIRS]
|
|
25
|
+
return dirs
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _install_cmds(r: Path) -> list[str]:
|
|
29
|
+
cmds = []
|
|
30
|
+
if (r / "uv.lock").exists():
|
|
31
|
+
cmds += ["pip install uv", "uv sync --frozen"] # runners have pip, not uv
|
|
32
|
+
for f, c in LOCKFILES.items():
|
|
33
|
+
if (r / f).exists():
|
|
34
|
+
cmds.append(c)
|
|
35
|
+
for d in _manifest_dirs(r):
|
|
36
|
+
if d == r:
|
|
37
|
+
continue
|
|
38
|
+
for f, c in LOCKFILES.items():
|
|
39
|
+
if (d / f).exists():
|
|
40
|
+
cmds.append(f"cd {d.relative_to(r).as_posix()} && {c}")
|
|
41
|
+
if not cmds and (r / "pyproject.toml").exists():
|
|
42
|
+
cmds.append("pip install .")
|
|
43
|
+
return list(dict.fromkeys(cmds))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _harness_install(r: Path) -> str:
|
|
47
|
+
"""Where CI gets the CLI: this repo when it is harness itself, otherwise a git ref
|
|
48
|
+
(the package is not on PyPI; secrets.HARNESS_REPO points at the harness repository)."""
|
|
49
|
+
return "pip install ." if (r / "harness" / "cli.py").exists() else "pip install git+${{ secrets.HARNESS_REPO }}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _yaml(r: Path, gates: dict, fail_under: int, extra: list[str], install: str) -> str:
|
|
53
|
+
L = ["# " + MARKER + " — regenerate with `harness ci`; do not edit by hand.",
|
|
54
|
+
"name: harness",
|
|
55
|
+
"on:",
|
|
56
|
+
" pull_request:",
|
|
57
|
+
" push:",
|
|
58
|
+
" branches: [main]",
|
|
59
|
+
"permissions:",
|
|
60
|
+
" contents: read",
|
|
61
|
+
"jobs:",
|
|
62
|
+
" readiness:",
|
|
63
|
+
" runs-on: ubuntu-latest",
|
|
64
|
+
" steps:",
|
|
65
|
+
" - uses: actions/checkout@v4",
|
|
66
|
+
" - uses: actions/setup-python@v5",
|
|
67
|
+
" with:",
|
|
68
|
+
' python-version: "3.12"',
|
|
69
|
+
f' - run: "{install}"',
|
|
70
|
+
f' - run: "harness scan --fail-under {fail_under}"',
|
|
71
|
+
" gates:",
|
|
72
|
+
" runs-on: ubuntu-latest",
|
|
73
|
+
" steps:",
|
|
74
|
+
" - uses: actions/checkout@v4"]
|
|
75
|
+
if _manifest_dirs(r):
|
|
76
|
+
L += [" - uses: actions/setup-node@v4",
|
|
77
|
+
" with:",
|
|
78
|
+
' node-version: "22"']
|
|
79
|
+
if (r / "pyproject.toml").exists() or (r / "uv.lock").exists() or (r / "requirements.txt").exists():
|
|
80
|
+
L += [" - uses: actions/setup-python@v5",
|
|
81
|
+
" with:",
|
|
82
|
+
' python-version: "3.12"']
|
|
83
|
+
for c in _install_cmds(r) + extra:
|
|
84
|
+
L += [f' - run: "{c}"']
|
|
85
|
+
for name, g in gates.items():
|
|
86
|
+
L += [f" - name: gate {name}", f' run: "{g["cmd"]}"']
|
|
87
|
+
if not gates:
|
|
88
|
+
L += [" - name: no gates detected", " run: \"echo 'run harness init to add gates'\""]
|
|
89
|
+
return "\n".join(L) + "\n"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_ci(a) -> int:
|
|
93
|
+
r = find_root(a.repo)
|
|
94
|
+
if not r:
|
|
95
|
+
die("run `harness init` first")
|
|
96
|
+
v = jload(r / AE / "verification.json")
|
|
97
|
+
floor = a.fail_under
|
|
98
|
+
if floor is None:
|
|
99
|
+
measured = scan(r)["readiness"]
|
|
100
|
+
floor = min(70, measured)
|
|
101
|
+
if measured < 70:
|
|
102
|
+
print(f"Note : repo readiness is {measured}/100 — clamped floor to {floor}; "
|
|
103
|
+
"pass --fail-under explicitly to override")
|
|
104
|
+
dst = r / WORKFLOW
|
|
105
|
+
if dst.exists() and MARKER not in dst.read_text() and not a.force:
|
|
106
|
+
die(f"{WORKFLOW} is hand-written; keep it or re-run with --force")
|
|
107
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
dst.write_text(_yaml(r, v.get("gates", {}), floor, a.setup or [],
|
|
109
|
+
a.install or _harness_install(r)))
|
|
110
|
+
print(f"Wrote : {WORKFLOW} ({len(v.get('gates', {}))} gates, floor {floor}) — commit it; "
|
|
111
|
+
"GitHub Actions runs it on push/PR")
|
|
112
|
+
return 0
|
harness/cli.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Argparse surface. Exit codes: 0 ok, 1 policy/contract failure or die(), 2 reserved for guard block."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from harness import __version__
|
|
7
|
+
from harness.agents import AGENTS, cmd_agents
|
|
8
|
+
from harness.bundle import cmd_policy
|
|
9
|
+
from harness.ci import cmd_ci
|
|
10
|
+
from harness.contract import cmd_list, cmd_run, cmd_start, cmd_update
|
|
11
|
+
from harness.eval import VARIANTS, cmd_eval
|
|
12
|
+
from harness.guard import cmd_guard
|
|
13
|
+
from harness.policy import LVL, cmd_init
|
|
14
|
+
from harness.pr import cmd_pr
|
|
15
|
+
from harness.scan import cmd_scan
|
|
16
|
+
from harness.verify import cmd_verify
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv=None) -> int:
|
|
20
|
+
p = argparse.ArgumentParser(prog="harness",
|
|
21
|
+
description="Local-first control plane that professionalizes how "
|
|
22
|
+
"coding agents work on a repo: scan -> init -> task start "
|
|
23
|
+
"-> (task run) -> verify -> pr.")
|
|
24
|
+
p.add_argument("--version", action="version", version=f"harness {__version__}")
|
|
25
|
+
p.add_argument("--repo", default=".")
|
|
26
|
+
s = p.add_subparsers(dest="c", required=True)
|
|
27
|
+
|
|
28
|
+
x = s.add_parser("scan", help="detect stacks, gates, protected paths; score agent-readiness")
|
|
29
|
+
x.add_argument("--json", action="store_true")
|
|
30
|
+
x.add_argument("--fail-under", type=int, metavar="N", help="exit 1 when readiness scores below N (CI gate)")
|
|
31
|
+
x.set_defaults(f=cmd_scan)
|
|
32
|
+
|
|
33
|
+
x = s.add_parser("init", help="write .ai-engineering/, CLAUDE.md, AGENTS.md and .claude/settings.json")
|
|
34
|
+
x.add_argument("--force", action="store_true")
|
|
35
|
+
x.set_defaults(f=cmd_init)
|
|
36
|
+
|
|
37
|
+
s.add_parser("guard", help=argparse.SUPPRESS).set_defaults(f=cmd_guard)
|
|
38
|
+
|
|
39
|
+
x = s.add_parser("agents", help="show supported agent binaries and enforcement tiers")
|
|
40
|
+
x.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
41
|
+
x.set_defaults(f=cmd_agents)
|
|
42
|
+
|
|
43
|
+
t = s.add_parser("task", help="manage contracts").add_subparsers(dest="t", required=True)
|
|
44
|
+
x = t.add_parser("start")
|
|
45
|
+
x.add_argument("name")
|
|
46
|
+
x.add_argument("--goal", required=True)
|
|
47
|
+
x.add_argument("--accept", action="append")
|
|
48
|
+
x.add_argument("--allow", action="append")
|
|
49
|
+
x.add_argument("--risk", choices=list(LVL), default="medium")
|
|
50
|
+
x.set_defaults(f=cmd_start)
|
|
51
|
+
x = t.add_parser("run", help="optional headless agent run inside the worktree")
|
|
52
|
+
x.add_argument("name")
|
|
53
|
+
x.add_argument("--agent", choices=list(AGENTS), default="claude")
|
|
54
|
+
x.set_defaults(f=cmd_run)
|
|
55
|
+
x = t.add_parser("update", help="amend a contract (goal/accept/allow/risk)")
|
|
56
|
+
x.add_argument("name")
|
|
57
|
+
x.add_argument("--goal")
|
|
58
|
+
x.add_argument("--accept", action="append")
|
|
59
|
+
x.add_argument("--allow", action="append")
|
|
60
|
+
x.add_argument("--risk", choices=list(LVL))
|
|
61
|
+
x.set_defaults(f=cmd_update)
|
|
62
|
+
x = t.add_parser("list")
|
|
63
|
+
x.set_defaults(f=cmd_list)
|
|
64
|
+
|
|
65
|
+
x = s.add_parser("verify", help="run gates + scope + review, emit evidence")
|
|
66
|
+
x.add_argument("name")
|
|
67
|
+
x.set_defaults(f=cmd_verify)
|
|
68
|
+
|
|
69
|
+
x = s.add_parser("pr", help="gh-backed PR operations for task branches")
|
|
70
|
+
prs = x.add_subparsers(dest="prc", required=True)
|
|
71
|
+
y = prs.add_parser("create", help="commit, push, gh pr create (requires PASS evidence)")
|
|
72
|
+
y.add_argument("name")
|
|
73
|
+
y = prs.add_parser("view", help="show the PR opened for the task branch")
|
|
74
|
+
y.add_argument("name")
|
|
75
|
+
prs.add_parser("status", help="gh pr status")
|
|
76
|
+
prs.add_parser("list", help="gh pr list")
|
|
77
|
+
x.set_defaults(f=cmd_pr)
|
|
78
|
+
|
|
79
|
+
x = s.add_parser("policy", help="sync the team policy bundle (policy.json + verification.json) with a Git repo")
|
|
80
|
+
ps = x.add_subparsers(dest="polc", required=True)
|
|
81
|
+
for nm, hp in (("push", "publish local policy + gates to the bundle repo"),
|
|
82
|
+
("pull", "adopt the bundle as the local team standard"),
|
|
83
|
+
("status", "exit 1 when the local bundle differs from the remote")):
|
|
84
|
+
y = ps.add_parser(nm, help=hp)
|
|
85
|
+
y.add_argument("remote", help="bundle repo path or URL")
|
|
86
|
+
y.add_argument("--branch", default="main")
|
|
87
|
+
x.set_defaults(f=cmd_policy)
|
|
88
|
+
|
|
89
|
+
x = s.add_parser("ci", help="generate .github/workflows/harness.yml (readiness floor + gates)")
|
|
90
|
+
x.add_argument("--fail-under", type=int, metavar="N",
|
|
91
|
+
help="readiness floor in the workflow (default: min(70, the repo's measured score))")
|
|
92
|
+
x.add_argument("--setup", action="append", metavar="CMD",
|
|
93
|
+
help="extra step the gates need before they can run (repeatable), e.g. "
|
|
94
|
+
'"cd web && npx playwright install --with-deps chromium"')
|
|
95
|
+
x.add_argument("--install", metavar="CMD",
|
|
96
|
+
help="how CI installs the harness CLI (default: this repo when it is harness, "
|
|
97
|
+
"otherwise `pip install harness-agent`)")
|
|
98
|
+
x.add_argument("--force", action="store_true", help="overwrite a hand-written workflow")
|
|
99
|
+
x.set_defaults(f=cmd_ci)
|
|
100
|
+
|
|
101
|
+
x = s.add_parser("eval", help="P7: run the raw-vs-harnessed corpus and report metrics")
|
|
102
|
+
evs = x.add_subparsers(dest="ev", required=True)
|
|
103
|
+
evs.add_parser("list", help="show the task matrix").set_defaults(f=cmd_eval)
|
|
104
|
+
y = evs.add_parser("run", help="run evals (fake agent with --dry; provider calls otherwise)")
|
|
105
|
+
y.add_argument("--task", help="only this task id")
|
|
106
|
+
y.add_argument("--agent", choices=[*AGENTS, "all"], default="all")
|
|
107
|
+
y.add_argument("--variant", choices=[*VARIANTS, "all"], default="all")
|
|
108
|
+
y.add_argument("--dry", action="store_true", help="no provider calls: fake no-op agent, plumbing proof")
|
|
109
|
+
y.add_argument("--keep", action="store_true", help="keep throwaway workdirs for post-mortem")
|
|
110
|
+
y.set_defaults(f=cmd_eval)
|
|
111
|
+
evs.add_parser("report", help="render evals/report.html from evals/results.jsonl").set_defaults(f=cmd_eval)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
x = s.add_parser("serve", help="local API server for the dashboard (needs server extra)")
|
|
115
|
+
x.add_argument("--host", default="127.0.0.1")
|
|
116
|
+
x.add_argument("--port", type=int, default=8766)
|
|
117
|
+
x.add_argument("--max-parallel", type=int, default=2, help="concurrent agent/verify runs (plan §4.3)")
|
|
118
|
+
x.set_defaults(f=_serve)
|
|
119
|
+
|
|
120
|
+
a = p.parse_args(argv)
|
|
121
|
+
return a.f(a) or 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _serve(a) -> int:
|
|
125
|
+
try:
|
|
126
|
+
from harness.server import cmd_serve
|
|
127
|
+
except ImportError:
|
|
128
|
+
from harness.policy import die
|
|
129
|
+
die("fastapi/uvicorn missing; run `uv sync --extra server` or `uv tool install harness-agent --with fastapi --with uvicorn`")
|
|
130
|
+
return cmd_serve(a)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
sys.exit(main())
|
harness/contract.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Task contracts: start/update/list and the optional headless agent run."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from harness.policy import AE, LVL, die, find_root, jload, jsave
|
|
8
|
+
from harness.shellx import git, sh
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def tdir(r: Path) -> Path:
|
|
12
|
+
return r / AE / "tasks"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_task(a) -> tuple:
|
|
16
|
+
r = find_root(a.repo)
|
|
17
|
+
if not r:
|
|
18
|
+
die("run `harness init` first")
|
|
19
|
+
p = tdir(r) / f"{a.name}.json"
|
|
20
|
+
if not p.exists():
|
|
21
|
+
die(f"no such task: {a.name}")
|
|
22
|
+
return r, jload(p)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd_start(a) -> int:
|
|
26
|
+
r = find_root(a.repo)
|
|
27
|
+
if not r:
|
|
28
|
+
die("run `harness init` first")
|
|
29
|
+
wt = r.parent / ".harness-worktrees" / f"{r.name}-{a.name}"
|
|
30
|
+
br = f"harness/{a.name}"
|
|
31
|
+
base = git(r, "rev-parse", "HEAD")[1]
|
|
32
|
+
rc, out, _ = git(r, "worktree", "add", "-b", br, str(wt))
|
|
33
|
+
if rc:
|
|
34
|
+
die(out)
|
|
35
|
+
for p in (AE, ".claude", "CLAUDE.md", "AGENTS.md"):
|
|
36
|
+
s, d = r / p, wt / p
|
|
37
|
+
if s.exists() and not d.exists():
|
|
38
|
+
if s.is_dir():
|
|
39
|
+
shutil.copytree(s, d, ignore=shutil.ignore_patterns("tasks", "evidence", "audit.log"))
|
|
40
|
+
else:
|
|
41
|
+
shutil.copy2(s, d)
|
|
42
|
+
v = jload(r / AE / "verification.json")
|
|
43
|
+
pol = jload(r / AE / "policy.json")
|
|
44
|
+
gates = {k: g for k, g in v["gates"].items() if LVL[g["min_risk"]] <= LVL[a.risk]}
|
|
45
|
+
con = {"name": a.name, "goal": a.goal, "accept": a.accept or [], "risk": a.risk, "branch": br,
|
|
46
|
+
"worktree": str(wt), "base": base, "allowed": a.allow or ["**"], "protected": pol["protected"],
|
|
47
|
+
"gates": gates, "review": v.get("review", True) and a.risk != "low", "created": int(time.time())}
|
|
48
|
+
jsave(tdir(r) / f"{a.name}.json", con)
|
|
49
|
+
print(f"Worktree : {wt}\nBranch : {br}\nGates : {', '.join(gates) or 'none (!)'}")
|
|
50
|
+
print(f"Next : harness task run {a.name} | harness verify {a.name}")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_update(a) -> int:
|
|
55
|
+
r, c = load_task(a)
|
|
56
|
+
if a.goal:
|
|
57
|
+
c["goal"] = a.goal
|
|
58
|
+
if a.accept:
|
|
59
|
+
c["accept"] = a.accept
|
|
60
|
+
if a.allow:
|
|
61
|
+
c["allowed"] = a.allow
|
|
62
|
+
if a.risk and a.risk != c["risk"]:
|
|
63
|
+
c["risk"] = a.risk
|
|
64
|
+
v = jload(r / AE / "verification.json")
|
|
65
|
+
c["gates"] = {k: g for k, g in v["gates"].items() if LVL[g["min_risk"]] <= LVL[a.risk]}
|
|
66
|
+
c["review"] = v.get("review", True) and c["risk"] != "low"
|
|
67
|
+
jsave(tdir(r) / f"{c['name']}.json", c)
|
|
68
|
+
print(f"Updated : {c['name']}\nRisk : {c['risk']}\nGates : {', '.join(c['gates']) or 'none (!)'}")
|
|
69
|
+
print(f"Allowed : {', '.join(c['allowed'])}")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_list(a) -> int:
|
|
74
|
+
r = find_root(a.repo)
|
|
75
|
+
if not r:
|
|
76
|
+
die("run `harness init` first")
|
|
77
|
+
d = tdir(r)
|
|
78
|
+
rows = []
|
|
79
|
+
for p in sorted(d.glob("*.json")) if d.is_dir() else []:
|
|
80
|
+
c = jload(p)
|
|
81
|
+
ep = r / AE / "evidence" / f"{c['name']}.json"
|
|
82
|
+
verdict = jload(ep)["verdict"] if ep.exists() else "unverified"
|
|
83
|
+
rows.append(f"{c['name']:<20} {c['risk']:<7} {c['branch']:<28} {verdict}")
|
|
84
|
+
if not rows:
|
|
85
|
+
print("no tasks")
|
|
86
|
+
for row in rows:
|
|
87
|
+
print(row)
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_run(a) -> int:
|
|
92
|
+
from harness.agents import agent_prompt, build_argv
|
|
93
|
+
|
|
94
|
+
_, c = load_task(a)
|
|
95
|
+
if not shutil.which(a.agent):
|
|
96
|
+
die(f"`{a.agent}` CLI not found; work in the worktree manually, then `harness verify`.")
|
|
97
|
+
rc, out, _ = sh(build_argv(a.agent, agent_prompt(c)), c["worktree"], timeout=3600)
|
|
98
|
+
if out:
|
|
99
|
+
print(out)
|
|
100
|
+
return rc
|