rein-aharness/rein_aharness/cli.py

739 lines
24 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:
source = getattr(args, "source", "issue-core") or "issue-core"
if source in {"ops-run", "ops_run", "ops"}:
from rein_aharness.claim_loop import process_one, poll_peek
from rein_aharness.ops_run_client import OpsRunError
try:
if args.no_claim:
rows = poll_peek()
print(
json.dumps(
{"source": "ops-run", "queue": "empty" if not rows else "open", "items": rows},
indent=2,
)
)
return 0
result = process_one(
dry_run=bool(getattr(args, "dry_run", False)),
report_to_hub=False if getattr(args, "no_hub", False) else True,
)
except OpsRunError as exc:
print(f"ops-run error: {exc}", file=sys.stderr)
return 2
print(
json.dumps(
{
"source": "ops-run",
"claimed": result.claimed,
"empty": result.empty,
"run_id": result.run_id,
"approach": result.approach,
"ok": result.ok,
"ops_state": result.ops_state,
"reason": result.reason,
"detail": result.detail,
},
indent=2,
)
)
if result.empty:
return 0
return 0 if result.ok else 1
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({"source": "issue-core", "queue": "empty"}, indent=2))
return 0
issue, spec = result
print(
json.dumps(
{
"source": "issue-core",
"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_claim_loop(args: argparse.Namespace) -> int:
import logging
from rein_aharness.claim_loop import run_claim_loop
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return run_claim_loop(
once=bool(args.once),
interval_seconds=args.interval,
report_to_hub=not args.no_hub,
commit=not args.no_commit,
dry_run=bool(args.dry_run),
max_iterations=args.max_iterations,
)
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 getattr(args, "from_ops_run", False):
from rein_aharness.claim_loop import process_one
from rein_aharness.ops_run_client import OpsRunError
try:
result = process_one(
report_to_hub=not args.no_hub,
commit=not getattr(args, "no_commit", False),
)
except OpsRunError as exc:
print(f"ops-run error: {exc}", file=sys.stderr)
return 2
print(
json.dumps(
{
"source": "ops-run",
"ok": bool(result.ok) if result.claimed else True,
"empty": result.empty,
"run_id": result.run_id,
"approach": result.approach,
"ops_state": result.ops_state,
"reason": result.reason,
"detail": result.detail,
},
indent=2,
)
)
if result.empty:
return 0
return 0 if result.ok else 1
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, "source": "issue-core", "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, --from-ops-run, 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
def _print_event(event: dict) -> None:
# Tagged + single-line so a consumer reading stdout line-by-line
# (e.g. glas-harness's ReinAharness) can tell an event line apart
# from the pretty-printed final result block below.
print(json.dumps({"stream_event": event}), flush=True)
result = run_task(
spec,
report_to_hub=not args.no_hub,
write_metrics=not args.no_metrics,
emit_tool_events=args.stream_tool_events,
on_tool_event=_print_event if args.stream_tool_events else None,
model=args.model,
tool_profile_override=args.tool_profile,
budget_tokens_override=args.budget_tokens,
)
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,
"source": "issue-core" if issue_id else "task-file",
"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,
"model": result.model,
"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: ops_run (primary), issue-core (legacy), or task file",
)
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-ops-run",
action="store_true",
help="Claim one activity-core ops_run, select approach, execute, complete/fail",
)
run_src.add_argument(
"--from-issue-core",
action="store_true",
help="Legacy: 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-commit",
action="store_true",
help="With --from-ops-run: approaches that support it skip git commit",
)
run.add_argument(
"--no-metrics",
action="store_true",
help="Skip writing .kaizen/metrics in the target repo",
)
run.add_argument(
"--stream-tool-events",
action="store_true",
help=(
"Run claude with --output-format stream-json --include-hook-events "
"and print each tool_use/tool_result/hook event as its own JSON "
"line while running (real-time audit, not external tool dispatch "
"-- HARNESS-WP-0002-T03)"
),
)
run.add_argument(
"--model",
default=None,
help="Explicit Claude Code model identifier supplied by the execution profile",
)
run.add_argument(
"--tool-profile",
default=None,
help="Override the instance tool profile for this explicitly governed run",
)
run.add_argument(
"--budget-tokens",
type=int,
default=None,
help="Override the instance token budget for this explicitly governed run",
)
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",
)
weekly = sub.add_parser(
"brief-weekly",
help="Write weekly founder-review prep via llm-connect (no coding agent)",
)
weekly.add_argument("--target-repo", required=True)
weekly.add_argument(
"--date",
default=None,
help="Review date YYYY-MM-DD (default: today Europe/Berlin)",
)
weekly.add_argument(
"--force",
action="store_true",
help="Overwrite if today's weekly review already exists",
)
weekly.add_argument("--no-hub", action="store_true", help="Skip hub reporting")
weekly.add_argument(
"--no-commit",
action="store_true",
help="Write review file but do not git commit",
)
fi_brief = sub.add_parser(
"fi-research-brief",
help=(
"Freedom Intelligence research brief via llm-connect "
"(activity-core consumer; posts fi_daily_brief)"
),
)
fi_brief.add_argument(
"--target-repo",
required=True,
help="freedom-intelligence checkout path",
)
fi_brief.add_argument(
"--date",
default=None,
help="Brief date YYYY-MM-DD (default: today Europe/Berlin)",
)
fi_brief.add_argument(
"--force",
action="store_true",
help="Overwrite if today's brief already exists",
)
fi_brief.add_argument("--no-hub", action="store_true", help="Skip hub reporting")
fi_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 task (default source=ops-run; issue-core is legacy)",
)
poll.add_argument(
"--source",
choices=["ops-run", "issue-core"],
default="ops-run",
help="ops-run = activity-core claim queue (primary); issue-core = legacy tickets",
)
poll.add_argument(
"--no-claim",
action="store_true",
help="List/map only; do not claim",
)
poll.add_argument(
"--dry-run",
action="store_true",
help="With ops-run: claim then fail+reopen without executing",
)
poll.add_argument("--no-hub", action="store_true", help="Skip hub on execute")
claim_loop = sub.add_parser(
"claim-loop",
help="Continuously claim ops_runs, select approach, execute, complete/fail",
)
claim_loop.add_argument(
"--once",
action="store_true",
help="Process at most one claim cycle then exit",
)
claim_loop.add_argument(
"--interval",
type=float,
default=None,
help="Seconds between empty-queue polls (default env AGENT_HARNESS_CLAIM_INTERVAL or 30)",
)
claim_loop.add_argument(
"--max-iterations",
type=int,
default=None,
help="Stop after N cycles (tests / bounded runs)",
)
claim_loop.add_argument("--dry-run", action="store_true")
claim_loop.add_argument("--no-hub", action="store_true")
claim_loop.add_argument("--no-commit", action="store_true")
claim_loop.add_argument("-v", "--verbose", action="store_true")
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 == "claim-loop":
return _cmd_claim_loop(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 == "brief-weekly":
from datetime import date as date_cls
from rein_aharness.brief_weekly import run_brief_weekly
day = date_cls.fromisoformat(args.date) if args.date else None
result = run_brief_weekly(
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": result.ok,
"date": result.date,
"path": result.path,
"wrote": result.wrote,
"committed": result.committed,
"skipped_existing": result.skipped_existing,
"milestone_moved": result.milestone_moved,
"risk005_state": result.risk005_state,
"head_after": result.head_after,
"reason": result.reason,
"model_meta": result.model_meta,
},
indent=2,
)
)
return 0 if result.ok else 1
if args.command == "fi-research-brief":
from datetime import date as date_cls
from rein_aharness.fi_research_brief import run_fi_research_brief
day = None
if args.date:
day = date_cls.fromisoformat(args.date)
fi_result = run_fi_research_brief(
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": fi_result.ok,
"date": fi_result.date,
"path": fi_result.path,
"wrote": fi_result.wrote,
"committed": fi_result.committed,
"skipped_existing": fi_result.skipped_existing,
"collection_candidates": fi_result.collection_candidates,
"head_after": fi_result.head_after,
"reason": fi_result.reason,
"model_meta": fi_result.model_meta,
},
indent=2,
)
)
return 0 if fi_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())