diff --git a/README.md b/README.md index 5ce0ba4..3c14789 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,15 @@ agentic-harness check agentic-harness report ``` +To use a free-form managed workflow (kept separate from the verified `do` path): + +```bash +cd /path/to/your/project +agentic-harness external-do "prepare release notes" --mode cloud +agentic-harness external-status +agentic-harness external-watch +``` + `--check` is the independent completion gate. A worker saying “done” cannot replace it. The durable report is written to `.agentic-harness/runs/{goal-id}/report.md`. diff --git a/agentic_harness/cli.py b/agentic_harness/cli.py index b38ac18..1249d3e 100644 --- a/agentic_harness/cli.py +++ b/agentic_harness/cli.py @@ -35,6 +35,7 @@ from agentic_harness.core.local_goal_bridge import ( CommandResult, DOC_ROOT_ENV, + EXECUTION_PROFILES, LocalGoalBridge, Mode3AGoalOptions, format_command_result, @@ -125,6 +126,45 @@ def build_parser() -> argparse.ArgumentParser: help="Independent command to run before accepting done; repeat for multiple checks.", ) easy_do.add_argument("--json", action="store_true", help="Print final goal JSON.") + external_do = sub.add_parser( + "external-do", + help="Start a free-form goal through the optional managed external backend", + ) + external_do.add_argument("objective") + external_do.add_argument( + "--mode", + default="local", + help="External mode: local, guided, cloud, or experimental. Default: local.", + ) + external_do.add_argument("--safe-area", action="append", default=[]) + external_do.add_argument( + "--check", + "--verify", + dest="check", + action="append", + default=[], + metavar="COMMAND", + help="Verification command to carry with the goal; repeat for multiple checks.", + ) + external_do.add_argument("--doc-root", default=None, help=DOC_ROOT_HELP) + external_do.add_argument( + "--execution-profile", + choices=sorted(EXECUTION_PROFILES), + default="automatic", + help="Optional managed model profile; default lets the backend choose safely.", + ) + external_do.add_argument( + "--supervision", + choices=("none", "glm-5.2"), + default="none", + help="Optional advisory reviewer for a local Mode 1 goal.", + ) + external_do.add_argument( + "--monitor", + action="store_true", + help="Run one external monitor pass after a successful start.", + ) + external_do.add_argument("--json", action="store_true", help="Print a safe JSON receipt.") work = sub.add_parser("work", help="Interactive no-jargon mode picker") work.add_argument("--doc-root", default=None, help=DOC_ROOT_HELP) gui = sub.add_parser("gui", help="Open the local browser app") @@ -181,6 +221,18 @@ def build_parser() -> argparse.ArgumentParser: ) mode3a_monitor.add_argument("--doc-root", default=None, help=DOC_ROOT_HELP) mode3a_monitor.add_argument("--json", action="store_true") + external_status = sub.add_parser( + "external-status", + help="Show status from the optional managed external backend", + ) + external_status.add_argument("--doc-root", default=None, help=DOC_ROOT_HELP) + external_status.add_argument("--json", action="store_true") + external_watch = sub.add_parser( + "external-watch", + help="Run one monitor pass on the optional managed external backend", + ) + external_watch.add_argument("--doc-root", default=None, help=DOC_ROOT_HELP) + external_watch.add_argument("--json", action="store_true") sub.add_parser("agents", help="Show supported backend tools found on PATH") create_demo_cmd = sub.add_parser("create-demo", help="Create a runnable example project") create_demo_cmd.add_argument("demo", choices=demo_names()) @@ -407,6 +459,8 @@ def main(argv: list[str] | None = None) -> int: return run_server_from_args(args) if args.command == "do": return run_easy_do_command(args, project_dir) + if args.command == "external-do": + return run_external_goal_command(args) if args.command == "check": return run_easy_check_command(args, project_dir) if args.command == "watch": @@ -417,6 +471,10 @@ def main(argv: list[str] | None = None) -> int: return run_mode3a_status(args) if args.command == "mode3a-monitor": return run_mode3a_monitor(args) + if args.command == "external-status": + return run_external_status_command(args) + if args.command == "external-watch": + return run_external_watch_command(args) if args.command == "create-demo": try: demo_path = create_demo(args.demo, args.path, force=args.force) @@ -1097,6 +1155,104 @@ def run_mode3a_monitor(args: argparse.Namespace) -> int: return result.returncode +def _safe_external_payload(mode_key: str, result: CommandResult) -> dict[str, object]: + return { + "ok": result.returncode == 0, + "mode": mode_key, + "returncode": result.returncode, + "output": format_command_result(result), + } + + +def run_external_goal_command(args: argparse.Namespace) -> int: + objective = args.objective.strip() + if not objective: + print("No goal entered. Nothing started.") + return 2 + + bridge = LocalGoalBridge(doc_root=resolve_doc_root(args.doc_root)) + if not bridge.available(): + payload = { + "ok": False, + "error": "local-goal backend not found or not executable", + "path": str(bridge.local_goal), + "next": local_goal_setup_hint(), + } + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(payload["error"]) + print(f"Expected: {payload['path']}") + print(f"Next: {payload['next']}") + return 2 + + try: + mode = human_mode_by_key(args.mode) + result = bridge.start_human_goal( + mode_key=mode.key, + objective=objective, + safe_areas=tuple(args.safe_area), + checks=tuple(args.check), + execution_profile=args.execution_profile, + supervision=args.supervision, + ) + except ValueError as exc: + payload = {"ok": False, "error": str(exc)} + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f"Could not start external goal: {exc}") + return 2 + + monitor_result: CommandResult | None = None + if args.monitor and result.returncode == 0: + monitor_result = bridge.monitor(json_output=args.json) + + if args.json: + payload = _safe_external_payload(mode.key, result) + if monitor_result is not None: + payload["monitor"] = _safe_external_payload(mode.key, monitor_result) + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + if result.returncode == 0: + print("Started external goal.") + print(f"Mode: {mode.key} — {mode.title}") + print(_friendly_queue_summary(result.stdout)) + print("Check it: agentic-harness external-status") + print("Move it forward: agentic-harness external-watch") + if monitor_result is not None: + print(format_command_result(monitor_result)) + else: + print("The managed external backend did not start the goal.") + print(format_command_result(result)) + + if monitor_result is not None: + return monitor_result.returncode + return result.returncode + + +def run_external_status_command(args: argparse.Namespace) -> int: + bridge = LocalGoalBridge(doc_root=resolve_doc_root(args.doc_root)) + if not bridge.available(): + print(f"local-goal backend not found or not executable: {bridge.local_goal}") + print(f"Next: {local_goal_setup_hint()}") + return 2 + result = bridge.status(json_output=args.json) + print(format_command_result(result)) + return result.returncode + + +def run_external_watch_command(args: argparse.Namespace) -> int: + bridge = LocalGoalBridge(doc_root=resolve_doc_root(args.doc_root)) + if not bridge.available(): + print(f"local-goal backend not found or not executable: {bridge.local_goal}") + print(f"Next: {local_goal_setup_hint()}") + return 2 + result = bridge.monitor(json_output=args.json) + print(format_command_result(result)) + return result.returncode + + def run_recipe( project_dir: Path, recipe: Recipe, diff --git a/tests/test_external_goal_cli.py b/tests/test_external_goal_cli.py new file mode 100644 index 0000000..642d887 --- /dev/null +++ b/tests/test_external_goal_cli.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from agentic_harness.core.local_goal_bridge import CommandResult +import agentic_harness.cli as cli + + +class FakeLocalGoalBridge: + def __init__(self, *, doc_root: Path | None = None) -> None: + self.doc_root = doc_root + self.local_goal = Path("/tmp/fake-local-goal") + self.status_calls = 0 + self.monitor_calls = 0 + self.start_kwargs: dict[str, object] | None = None + + def available(self) -> bool: + return True + + def start_human_goal(self, **kwargs: object) -> CommandResult: + self.start_kwargs = kwargs + return CommandResult( + ("local-goal", "start"), + 0, + "queued_id=goal-123\nrun_dir=/tmp/queue\n", + "", + ) + + def status(self, *, json_output: bool = False) -> CommandResult: # noqa: ARG002 + self.status_calls += 1 + return CommandResult( + ("local-goal", "status"), + 0, + "mode=local\nqueued_id=goal-123\n", + "", + ) + + def monitor(self, *, json_output: bool = False) -> CommandResult: # noqa: ARG002 + self.monitor_calls += 1 + return CommandResult(("local-goal", "monitor"), 0, "monitor ok\n", "") + + +class MissingLocalGoalBridge(FakeLocalGoalBridge): + def available(self) -> bool: + return False + + +def test_external_do_command_starts_managed_goal_and_outputs_receipt(monkeypatch, tmp_path, capsys) -> None: + captured: dict[str, FakeLocalGoalBridge] = {} + + def bridge_factory(*_args: object, **_kwargs: object) -> FakeLocalGoalBridge: + bridge = FakeLocalGoalBridge() + captured["bridge"] = bridge + return bridge + + monkeypatch.setattr(cli, "LocalGoalBridge", bridge_factory) + + rc = cli.main( + [ + "--project-dir", + str(tmp_path), + "external-do", + "prepare release notes", + "--mode", + "local", + "--safe-area", + "docs", + "--check", + "python -m pytest", + "--execution-profile", + "automatic", + "--supervision", + "none", + "--monitor", + "--json", + ] + ) + + assert rc == 0 + output = capsys.readouterr().out + payload = json.loads(output) + assert payload["ok"] is True + assert payload["mode"] == "local" + assert payload["returncode"] == 0 + assert payload["monitor"]["returncode"] == 0 + + bridge = captured["bridge"] + assert bridge.start_kwargs == { + "mode_key": "local", + "objective": "prepare release notes", + "safe_areas": ("docs",), + "checks": ("python -m pytest",), + "execution_profile": "automatic", + "supervision": "none", + } + + +def test_external_do_command_fails_when_backend_missing(monkeypatch, tmp_path, capsys) -> None: + monkeypatch.setattr(cli, "LocalGoalBridge", MissingLocalGoalBridge) + + rc = cli.main( + [ + "--project-dir", + str(tmp_path), + "external-do", + "prepare release notes", + "--json", + ] + ) + + assert rc == 2 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + assert payload["error"] == "local-goal backend not found or not executable" + + +def test_external_status_and_watch_commands(monkeypatch, tmp_path) -> None: + bridge = FakeLocalGoalBridge() + monkeypatch.setattr(cli, "LocalGoalBridge", lambda *args, **kwargs: bridge) + + rc_status = cli.main(["--project-dir", str(tmp_path), "external-status"]) + assert rc_status == 0 + assert bridge.status_calls == 1 + + rc_watch = cli.main(["--project-dir", str(tmp_path), "external-watch", "--json"]) + assert rc_watch == 0 + assert bridge.monitor_calls == 1