rein-aharness/rein_aharness/cli.py
tegwick f6930ad115 Rename package, CLI, and deploy artifacts to rein-aharness (HARNESS-WP-0002-T02)
agent_harness -> rein_aharness (package + all imports), CLI command
agent-harness -> rein-aharness, Docker image tag, k8s namespace/labels/
names, Makefile targets, deploy script env var/paths. In-repo identity
strings (hub event source, metrics harness field, default assignee,
argparse prog name, commit author identity) updated to match.

Historical documents left untouched on purpose: docs/adr/ADR-001-agent-harness-architecture.md,
docs/architecture.md (dated v0.1 snapshot), workplans/HARNESS-WP-0001
(completed under the old name), and the SSH host alias
"forgejo-agent-harness" (external ~/.ssh/config entry, not owned here).

Verified: 47/47 tests pass, CLI runs correctly from a fresh venv,
`make image` builds and the resulting container runs correctly.

deploy/README.md gained an explicit rename cutover checklist for what
this session cannot safely do unattended -- moving the host-side
secrets dir and checkout on railiance01, and not deleting the old k8s
namespace until the new one is confirmed working. The actual live
cutover (running that checklist against the real Railiance deployment)
is not attempted here -- real production surgery on binky-control's
live automation, needs the operator present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 14:22:18 +02:00

438 lines
14 KiB
Python

"""CLI: validate instance manifests and execute exactly one task."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from rein_aharness.manifest import (
ManifestError,
load_manifest_for_repo,
manifest_path,
validate_manifest,
)
from rein_aharness.profiles import list_profiles
from rein_aharness.runner import run_task
from rein_aharness.taskspec import TaskSpec, TaskSpecError
def _cmd_validate(args: argparse.Namespace) -> int:
target = Path(args.target).expanduser().resolve()
path = manifest_path(target)
if not path.is_file():
print(f"error: no instance manifest at {path}", file=sys.stderr)
return 2
try:
manifest = load_manifest_for_repo(target)
except ManifestError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
errors = validate_manifest(
manifest, require_harness_fields=bool(args.strict)
)
if errors:
print(f"invalid: {path}", file=sys.stderr)
for err in errors:
print(f" - {err}", file=sys.stderr)
return 1
enabled = manifest.enabled_agents()
print(f"ok: {path}")
print(f" version: {manifest.version}")
if manifest.timezone:
print(f" timezone: {manifest.timezone}")
if manifest.harness is not None:
print(f" harness major: {manifest.harness}")
print(f" agents: {len(manifest.agents)} ({len(enabled)} enabled)")
for agent in manifest.agents:
flags = []
if not agent.enabled:
flags.append("disabled")
if agent.tool_profile:
flags.append(f"profile={agent.tool_profile}")
if agent.lane:
flags.append(f"lane={agent.lane}")
if agent.budget is not None:
flags.append(f"budget={agent.budget}")
suffix = f" [{', '.join(flags)}]" if flags else ""
print(f" - {agent.name}: {agent.cadence}{suffix}")
return 0
def _cmd_profiles(_args: argparse.Namespace) -> int:
for profile in list_profiles():
print(f"{profile.name}\tlane={profile.lane}\t{profile.description}")
return 0
def _cmd_poll(args: argparse.Namespace) -> int:
from rein_aharness.intake import IntakeError, IssueCoreClient, poll_next
try:
client = IssueCoreClient()
result = poll_next(client, claim=not args.no_claim)
except IntakeError as exc:
print(f"intake error: {exc}", file=sys.stderr)
return 2
if result is None:
print(json.dumps({"queue": "empty"}, indent=2))
return 0
issue, spec = result
print(
json.dumps(
{
"issue_id": issue.issue_id,
"state": issue.state,
"title": issue.title,
"agent": spec.agent,
"target_repo": str(spec.target_repo),
"completion_event_type": spec.completion_event_type,
"labels": spec.labels,
"claimed": not args.no_claim,
},
indent=2,
)
)
return 0
def _cmd_run(args: argparse.Namespace) -> int:
from rein_aharness.intake import IntakeError, IssueCoreClient, poll_next
issue_id: str | None = None
client: IssueCoreClient | None = None
if args.from_issue_core:
try:
client = IssueCoreClient()
polled = poll_next(client, claim=True)
except IntakeError as exc:
print(f"intake error: {exc}", file=sys.stderr)
return 2
if polled is None:
print(json.dumps({"ok": True, "queue": "empty"}, indent=2))
return 0
issue, spec = polled
issue_id = issue.issue_id
else:
if not args.task_file:
print(
"error: provide --task-file or --from-issue-core",
file=sys.stderr,
)
return 2
try:
spec = TaskSpec.from_file(args.task_file)
except (TaskSpecError, OSError, json.JSONDecodeError) as exc:
print(f"invalid task spec: {exc}", file=sys.stderr)
return 2
result = run_task(
spec,
report_to_hub=not args.no_hub,
write_metrics=not args.no_metrics,
)
closed = False
close_error = ""
if client is not None and issue_id:
try:
if result.ok:
client.close(issue_id)
closed = True
else:
client.reopen(issue_id)
except IntakeError as exc:
close_error = str(exc)
print(
json.dumps(
{
"ok": result.ok,
"committed": result.committed,
"head_after": result.head_after,
"persona_source": result.persona_source,
"tool_profile": result.tool_profile,
"budget_tokens": result.budget_tokens,
"tokens_spent": result.tokens_spent,
"execution_time_s": round(result.execution_time_s, 3),
"reason": result.reason,
"issue_id": issue_id,
"issue_closed": closed,
"issue_close_error": close_error,
},
indent=2,
)
)
return 0 if result.ok else 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="rein-aharness")
sub = parser.add_subparsers(dest="command", required=True)
run = sub.add_parser(
"run",
help="Execute one task from a JSON file or next issue-core emission",
)
run_src = run.add_mutually_exclusive_group(required=True)
run_src.add_argument("--task-file", help="Local JSON task-spec (dev path)")
run_src.add_argument(
"--from-issue-core",
action="store_true",
help="Poll issue-core for one open harness-labeled task, claim, run, close",
)
run.add_argument("--no-hub", action="store_true", help="Skip hub reporting")
run.add_argument(
"--no-metrics",
action="store_true",
help="Skip writing .kaizen/metrics in the target repo",
)
scan = sub.add_parser(
"mail-scan", help="Deterministic company-mailbox scan (no LLM session)"
)
scan.add_argument("--target-repo", required=True)
scan.add_argument("--config", default="integrations/mailbox-binky-company.yml")
scan.add_argument("--out", default="mailmeta/reports")
scan.add_argument("--no-hub", action="store_true", help="Skip hub reporting")
triage = sub.add_parser(
"mail-triage",
help="Triage newest mail CSV via llm-connect (JSON + deterministic apply; no Claude)",
)
triage.add_argument("--target-repo", required=True)
triage.add_argument("--reports-dir", default="mailmeta/reports")
triage.add_argument("--mail-log", default="mailmeta/mail-log.md")
triage.add_argument(
"--max-rows",
type=int,
default=None,
help="Max CSV rows to send to the model (default env MAIL_TRIAGE_MAX_ROWS or 40)",
)
triage.add_argument("--no-hub", action="store_true", help="Skip hub reporting")
triage.add_argument(
"--no-commit",
action="store_true",
help="Apply log updates but do not git commit",
)
brief = sub.add_parser(
"brief-daily",
help="Write daily brief via llm-connect (JSON + markdown apply; no Claude)",
)
brief.add_argument("--target-repo", required=True)
brief.add_argument(
"--date",
default=None,
help="Brief date YYYY-MM-DD (default: today Europe/Berlin)",
)
brief.add_argument(
"--force",
action="store_true",
help="Overwrite if today's brief already exists",
)
brief.add_argument("--no-hub", action="store_true", help="Skip hub reporting")
brief.add_argument(
"--no-commit",
action="store_true",
help="Write brief file but do not git commit",
)
validate = sub.add_parser(
"validate",
help="Validate instance manifest (.kaizen/schedule.yml + harness fields)",
)
validate.add_argument(
"--target",
default=".",
help="Consuming repo root (default: cwd)",
)
validate.add_argument(
"--strict",
action="store_true",
help="Require lane, tool_profile, and harness pin on every enabled agent",
)
sub.add_parser("profiles", help="List named tool profiles")
smoke = sub.add_parser(
"smoke",
help="Deterministic Railiance smoke: commit SMOKE.md, optional push, hub event",
)
smoke.add_argument(
"--target-repo",
help="Existing git checkout (default: clone executor-sandbox under --work-dir)",
)
smoke.add_argument(
"--work-dir",
default="~/work/executor-sandbox",
help="Clone destination when --target-repo is omitted",
)
smoke.add_argument(
"--remote",
default="ssh://git@forgejo-agent-harness/coulomb/executor-sandbox.git",
help="Git remote for sandbox clone",
)
smoke.add_argument("--no-hub", action="store_true", help="Skip hub reporting")
smoke.add_argument("--no-push", action="store_true", help="Skip git push after commit")
poll = sub.add_parser(
"poll",
help="Peek/claim next open issue-core task labeled for the harness (no execute)",
)
poll.add_argument(
"--no-claim",
action="store_true",
help="List/map only; do not set in_progress",
)
args = parser.parse_args(argv)
if args.command == "validate":
return _cmd_validate(args)
if args.command == "profiles":
return _cmd_profiles(args)
if args.command == "poll":
return _cmd_poll(args)
if args.command == "run":
return _cmd_run(args)
if args.command == "smoke":
from rein_aharness.smoke import ensure_sandbox_clone, run_smoke
if args.target_repo:
repo = Path(args.target_repo).expanduser().resolve()
else:
try:
repo = ensure_sandbox_clone(
Path(args.work_dir).expanduser(),
remote=args.remote,
)
except Exception as exc:
print(f"smoke clone failed: {exc}", file=sys.stderr)
return 2
try:
smoke_result = run_smoke(
repo,
report_to_hub=not args.no_hub,
push=not args.no_push,
)
except Exception as exc:
print(f"smoke failed: {exc}", file=sys.stderr)
return 1
print(
json.dumps(
{
"ok": smoke_result.run.ok and (
smoke_result.pushed or args.no_push
),
"committed": smoke_result.run.committed,
"pushed": smoke_result.pushed,
"head_after": smoke_result.run.head_after,
"tool_profile": smoke_result.run.tool_profile,
"reason": smoke_result.run.reason,
"push_reason": smoke_result.push_reason,
"target_repo": str(repo),
},
indent=2,
)
)
ok = smoke_result.run.ok and (smoke_result.pushed or args.no_push)
return 0 if ok else 1
if args.command == "mail-triage":
from rein_aharness.mail_triage import run_mail_triage
triage_result = run_mail_triage(
target_repo=Path(args.target_repo).expanduser(),
reports_dir=args.reports_dir,
mail_log_rel=args.mail_log,
max_rows=args.max_rows,
report_to_hub=not args.no_hub,
commit=not args.no_commit,
)
print(
json.dumps(
{
"ok": triage_result.ok,
"report": triage_result.report,
"entries_applied": triage_result.entries_applied,
"committed": triage_result.committed,
"head_after": triage_result.head_after,
"reason": triage_result.reason,
"model_meta": triage_result.model_meta,
},
indent=2,
)
)
return 0 if triage_result.ok else 1
if args.command == "brief-daily":
from datetime import date as date_cls
from rein_aharness.brief_daily import run_brief_daily
day = None
if args.date:
day = date_cls.fromisoformat(args.date)
brief_result = run_brief_daily(
target_repo=Path(args.target_repo).expanduser(),
day=day,
force=bool(args.force),
report_to_hub=not args.no_hub,
commit=not args.no_commit,
)
print(
json.dumps(
{
"ok": brief_result.ok,
"date": brief_result.date,
"path": brief_result.path,
"wrote": brief_result.wrote,
"committed": brief_result.committed,
"skipped_existing": brief_result.skipped_existing,
"head_after": brief_result.head_after,
"reason": brief_result.reason,
"model_meta": brief_result.model_meta,
},
indent=2,
)
)
return 0 if brief_result.ok else 1
if args.command == "mail-scan":
from rein_aharness.mailscan import run_mail_scan
result = run_mail_scan(
target_repo=Path(args.target_repo).expanduser(),
config=args.config,
out_dir=args.out,
report_to_hub=not args.no_hub,
)
print(
json.dumps(
{
"ok": result.ok,
"report": result.report_path,
"new_messages": result.new_messages,
"auth_lane": result.auth_lane,
"reason": result.reason,
},
indent=2,
)
)
return 0 if result.ok else 1
print(f"unknown command: {args.command}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())