rein-aharness/agent_harness/cli.py
tegwick f770825975 feat: brief-daily via llm-connect (structured daily brief)
Generate OperatingRhythm-format briefs with OpenRouter-backed llm-connect
JSON + deterministic markdown write/commit. Server path for Binky daily
rhythm without host Claude (BINKY-WP-0006-T05).
2026-07-22 00:18:52 +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 agent_harness.manifest import (
ManifestError,
load_manifest_for_repo,
manifest_path,
validate_manifest,
)
from agent_harness.profiles import list_profiles
from agent_harness.runner import run_task
from agent_harness.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 agent_harness.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 agent_harness.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="agent-harness")
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 agent_harness.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 agent_harness.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 agent_harness.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 agent_harness.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())