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).
438 lines
14 KiB
Python
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())
|