From 88b50b4c46e2c7d8b869db8b1a817902b1ceaada Mon Sep 17 00:00:00 2001 From: HAL9000 Date: Sat, 9 May 2026 09:46:54 +0000 Subject: [PATCH] fix(cli): wrap plan status --format json output in spec-required JSON envelope --- features/plan_status_json_envelope.feature | 184 ++++++++++ .../steps/plan_status_json_envelope_steps.py | 319 ++++++++++++++++++ src/cleveragents/cli/commands/plan.py | 194 ++++++++++- 3 files changed, 690 insertions(+), 7 deletions(-) create mode 100644 features/plan_status_json_envelope.feature create mode 100644 features/steps/plan_status_json_envelope_steps.py diff --git a/features/plan_status_json_envelope.feature b/features/plan_status_json_envelope.feature new file mode 100644 index 000000000..b6b23e42d --- /dev/null +++ b/features/plan_status_json_envelope.feature @@ -0,0 +1,184 @@ +Feature: plan status --format json produces spec-required JSON envelope + + The ``agents plan status`` command must produce a spec-compliant JSON + envelope on its stdout when invoked with ``--format json``. + The envelope carries the structure {command, status, exit_code, data, timing, + messages} so downstream dashboards and scripts can parse it uniformly. + + Rule: every non-RICH output from plan status must include at minimum the four + envelope fields (command == "plan status", status == "ok", exit_code == 0, + data with at least plan_id, phase, state) plus timing and messages. + + @tdd_issue + @tdd_issue_11034 + Scenario: single-plan JSON output contains the envelope header + Given a lifecycle service that returns a plan with: + | plan_id | 01HQEXAMPLE0000000000000AB | + | phase | Strategize | + | processing_state | queued | + | action_name | local/code-coverage | + When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json" + Then the output is valid JSON with: + | field | value | + | command | "plan status" | + | status | "ok" | + | exit_code | 0 | + + @tdd_issue_11034 + Scenario: single-plan JSON output data contains required fields + Given a lifecycle service that returns a plan with: + | plan_id | 01HQEXAMPLE0000000000000AB | + | phase | Execute | + | processing_state | processing | + | action_name | local/refactor-db | + When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json" + Then the output JSON "data" contains: + | field | value | + | plan_id | "01HQEXAMPLE0000000000000AB" | + | phase | "execute" | + | state | "processing" | + | action | "local/refactor-db" | + + @tdd_issue_11034 + Scenario: single-plan JSON output contains timing envelope with started time + Given a lifecycle service that returns a plan with: + | plan_id | 01HQEXAMPLE0000000000000AB | + | phase | Apply | + | processing_state | complete | + | action_name | local/docs-update | + When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json" + Then the output JSON "timing" contains a "started" field matching an ISO 8601 datetime + + @tdd_issue_11034 + Scenario: single-plan JSON output includes progress steps + Given a lifecycle service that returns a plan with: + | plan_id | 01HQEXAMPLE0000000000000AB | + | phase | Strategize | + | processing_state | queued | + | action_name | local/new-feature | + When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json" + Then the output JSON "data"."progress" is a list of 3 steps: + | step | expected_status | + | Strategize | "queued" | + | Execute | "queued" | + | Apply | "queued" | + + @tdd_issue_11034 + Scenario: completed plan shows progress status "done" + Given a lifecycle service that returns a plan with: + | Plan ID | 01HQEXAMPLE0000000000000AB | + | Phase | Apply | + | ProcessingState | applied | + | Action Name | local/release | + When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json" + Then the output JSON "data"."progress" has all three step statuses equal to "done" + + @tdd_issue_11034 + Scenario: errored plan shows progress status "error" + Given a lifecycle service that returns a plan with: + | Plan ID | 01HQEXAMPLE0000000000000AB | + | Phase | Execute | + | ProcessingState | errored | + | Action Name | local/migrate-schema | + When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json" + Then the output JSON "data"."progress" has all three step statuses equal to "error" + + @tdd_issue_11034 + Scenario: in-progress execute plan shows progress status "running" then "queued" + Given a lifecycle service that returns a plan with: + | Plan ID | 01HQEXAMPLE0000000000000AB | + | Phase | Execute | + | ProcessingState | processing | + | Action Name | local/perf-tuning | + When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json" + Then the output JSON "data"."progress" has: + | step | expected_status | + | Strategize | "done" | + | Execute | "running" | + | Apply | "queued" | + + @tdd_issue_11034 + Scenario: plan with project_name includes optional project field in data + Given a lifecycle service that returns a plan with: + | Plan ID | 01HQEXAMPLE0000000000000AB | + | Phase | Strategize | + | ProcessingState | queued | + | Action Name | local/code-coverage | + | Project Name | myproject | + When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json" + Then the output JSON "data"."project" equals "myproject" + + @tdd_issue_11034 + Scenario: plan with automation_profile includes optional automation field in data + Given a lifecycle service that returns a plan with: + | Plan ID | 01HQEXAMPLE0000000000000AB | + | Phase | Execute | + | ProcessingState | queued | + | Action Name | local/new-feature | + | AutomationProfile | full-auto | + When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json" + Then the output JSON "data"."automation" equals "full-auto" + + @tdd_issue_11034 + Scenario: plan execution duration_ms appears in timing envelope + Given a lifecycle service that returns a plan with: + | Plan ID | 01HQEXAMPLE0000000000000AB | + | Phase | Apply | + | ProcessingState | complete | + | Action Name | local/release | + When I run "agents plan status 01HQEXAMPLE0000000000000AB --format json" + Then the output JSON "timing" contains a "duration_ms" field with a positive integer value + + @tdd_issue_11034 + Scenario: YAML output for plan status includes envelope fields in format_output call + Given a lifecycle service that returns a plan with: + | Plan ID | 01HQEXAMPLE0000000000000AB | + | Phase | Strategize | + | ProcessingState | queued | + | Action Name | local/lint | + When I run "agents plan status 01HQEXAMPLE0000000000000AB --format yaml" + Then the output contains: + | field | expected_content | + | plan_id | any string | + | phase | "strategize" or the raw phase value | + | state | "queued" | + + @tdd_issue_11034 + Scenario: rich output for plan status shows lifecycle panel (unchanged) + Given a lifecycle service that returns a plan with: + | Plan ID | 01HQEXAMPLE0000000000000AB | + | Phase | Apply | + | ProcessingState | complete | + | Action Name | local/release | + When I run "agents plan status 01HQEXAMPLE0000000000000AB --format rich" + Then the output contains "Plan Status" + + @tdd_issue_11034 + Scenario: timing envelope includes started ISO datetime + Given a lifecycle service that returns a plan with phase Apply and state complete + When I run "agents plan status --format json" (any valid ULID) + Then the output JSON "timing"."started" matches the regex ``^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$`` + + @tdd_issue_11034 + Scenario: cost data section is present in plan status JSON output + Given a lifecycle service that returns a plan with estimation_result=None (no cost info) + When I run "agents plan status --format json" (any valid ULID) + Then the output JSON "data"."cost" contains: + | field | expected_value | + | tokens_used | 0 | + | cost_so_far | 0.0 | + | estimated | 0.0 | + + @tdd_issue_11034 + Scenario: execution section includes child_plans info + Given a lifecycle service that returns a plan with: + | ChildPlanIds | [id1, id2] | + | CompletedChildPlanIds | [id1] | + When I run "agents plan status --format json" + Then the output JSON "data"."execution"."child_plans" equals "1/2 complete" + + @tdd_issue_11034 + Scenario: multi-plan list (no plan ID) still works with existing behavior + Given a lifecycle service that returns three plans + When I run "agents plan status --format json" + Then the output is valid JSON and does NOT crash diff --git a/features/steps/plan_status_json_envelope_steps.py b/features/steps/plan_status_json_envelope_steps.py new file mode 100644 index 000000000..c2a27f6b1 --- /dev/null +++ b/features/steps/plan_status_json_envelope_steps.py @@ -0,0 +1,319 @@ +"""Step definitions for plan status JSON-envelope BDD tests.""" + +from __future__ import annotations + +import json as _json +import re as _re +import sys as _sys +from io import StringIO as _StringIO +from typing import Any + +from behave import given, then, when # type: ignore[import-untyped] +from contextlib import suppress as _suppress +from unittest.mock import patch as _patch + + +# --------------------------------------------------------------------------- +# Mock Plan object (minimal domain-model attributes the envelope reads) +# --------------------------------------------------------------------------- + +class _MockPlan: + id: str = "" + phase: str = "" + processing_state: str | None = None + action_name: str | None = None + project_name: str | None = None + automation_profile: Any | None = None + child_plan_ids: list[str] | None = None + completed_child_plan_ids: list[str] | None = None + + +class _MockService: + """Minimal lifecycle-service mock for plan-status envelope tests.""" + + _plans_by_id: dict[str, _MockPlan] = {} + + @classmethod + def make_plan( + cls, + *, + plan_id: str, + phase: str = "strategize", + processing_state: str = "queued", + action_name: str | None = "local/default", + project_name: str | None = None, + automation_profile: str | None = None, + ) -> _MockPlan: + plan = _MockPlan() + plan.id = plan_id + plan.phase = phase.lower()[:1].upper() + phase[1:].lower() + plan.processing_state = processing_state.lower() + plan.action_name = action_name + plan.project_name = project_name + plan.automation_profile = automation_profile + cls._plans_by_id[plan_id] = plan + return plan + + def get_plan(self, plan_id: str) -> _MockPlan: # type: ignore[override] + p = self._plans_by_id.get(plan_id) + if p is None: + raise AssertionError(f"Mock plan '{plan_id}' not found") + return p + + +# Scenario context stored between steps +_scenario_ctx: dict[str, Any] = dict() + + +def _run_status(*, plan_id_arg: str, fmt_flags: list[str] | None = None) -> tuple[int, str]: + """Run ``agents plan status`` via the CLI module without real subprocess.""" + from cleveragents.cli.commands.plan import app as _cli_app + + captured = _StringIO() + code = 0 + try: + _cli_app.console = type(_cli_app.console)(file=captured, force_terminal=False) + ms = _MockService() + if plan_id_arg not in _MockService._plans_by_id: + _MockService.make_plan(plan_id=plan_id_arg) + + args = ["plan", "status", plan_id_arg] + (fmt_flags or []) + + with _patch( + "cleveragents.cli.commands.plan._get_lifecycle_service", + return_value=ms, + ): + old_stdout = _sys.stdout + _sys.stdout = captured + try: + _cli_app(args) + except SystemExit as exc: + code = int(str(exc)) if str(exc).isdigit() else 0 + + finally: + _sys.stdout = old_stdout + + return code, captured.getvalue().strip() + + +# --------------------------------------------------------------------------- +# Given steps — setup is implicit; a default plan is created by default in +# the CLI. These steps let BDD tests override defaults. +# --------------------------------------------------------------------------- + +@given('a lifecycle service that returns a plan with:') +def given_plan_fields(context: Any, table: Any) -> None: # type: ignore[name-defined] + pid = _scenario_ctx.get("plan_id", "01HQEXAMPLE0000000000000AB") + data = {dict(r): dict(dict(r))[k] for k in ["phase", "processing_state", "action_name", + "project_name", "automation_profile"]} + _MockService.make_plan( + plan_id=pid, + phase=data.get("Phase", "strategize"), + processing_state=data.get("Processingstate", data.get("Processing_State", "")) or data.get("ProcessingState", "queued"), + action_name=data.get("ActionName", data.get("Action_Name", "")) or data.get("Action name", "local/test"), + ) + +# --------------------------------------------------------------------------- +# When steps — actual CLI invocation +# --------------------------------------------------------------------------- + +@when('I run "agents plan status --format json"') +def when_run_json(context: Any, plan_id: str) -> None: # type: ignore[name-defined] + _scenario_ctx["plan_id"] = plan_id + code, raw = _run_status(plan_id_arg=plan_id, fmt_flags=["--format", "json"]) + context.exit_code = code + context.raw_output = raw + +@when('I run "agents plan status {plan_id} --format json"') +def when_run_json_var(context: Any, plan_id: str) -> None: # type: ignore[name-defined] + _scenario_ctx["plan_id"] = plan_id + code, raw = _run_status(plan_id_arg=plan_id, fmt_flags=["--format", "json"]) + context.exit_code = code + context.raw_output = raw + + +@when('I run "agents plan status --format yaml"') +def when_run_yaml(context: Any, plan_id: str) -> None: # type: ignore[name-defined] + _scenario_ctx["plan_id"] = plan_id + code, raw = _run_status(plan_id_arg=plan_id, fmt_flags=["--format", "yaml"]) + context.exit_code = code + context.raw_output = raw + + +@when('I run "agents plan status --format rich"') +def when_run_rich(context: Any, plan_id: str) -> None: # type: ignore[name-defined] + _scenario_ctx["plan_id"] = plan_id + code, raw = _run_status(plan_id_arg=plan_id) # default is rich + context.exit_code = code + context.raw_output = raw + + +@when('I run "agents plan status --format json"') +def when_run_multi(context: Any) -> None: # type: ignore[name-defined] + """Multi-plan listing path — keep existing behaviour.""" + pid = _scenario_ctx.get("plan_id", list(_MockService._plans_by_id.keys())[0]) if _MockService._plans_by_id else "01HQEXAMPLE0000000000000AB" + code, raw = _run_status(plan_id_arg=pid, fmt_flags=["--format", "json"]) + context.exit_code = code + context.raw_output = raw + + +# --------------------------------------------------------------------------- +# Then steps — JSON envelope validation helpers +# --------------------------------------------------------------------------- + +def _assert_json(context: Any) -> dict[str, Any]: + with _suppress(ValueError): + ctx = _json.loads(context.raw_output) + _scenario_ctx["parsed"] = ctx + return ctx + raise AssertionError(f"Not valid JSON: {context.raw_output[:200]}") + + +# --------------------------------------------------------------------------- +# Then steps — header fields (command, status, exit_code) +# --------------------------------------------------------------------------- + +@then('the output is valid JSON') +def then_valid_json(context: Any) -> None: # type: ignore[name-defined] + _assert_json(context) + assert "parsed" in _scenario_ctx + +@then("the output is valid JSON with:") +def then_envelope_header(context: Any, table: Any) -> None: # type: ignore[name-defined] + parsed = _assert_json(context) + for row in table.rows: + fd, val = row["field"], row["value"] + if fd == "command": + assert isinstance(parsed.get("command"), str), f"command missing or wrong type in {parsed}" + elif fd == "status": + assert isinstance(parsed.get("status"), str), f"status missing or wrong type in {parsed}" + elif fd == "exit_code": + ec = parsed.get("exit_code") + assert ec is not None and isinstance(ec, int), f"exit_code: {ec} (expected 0)" + + +# --------------------------------------------------------------------------- +# Then steps — data fields +# --------------------------------------------------------------------------- + +@then('the output JSON "data" contains:') +def then_data_fields(context: Any, table: Any) -> None: # type: ignore[name-defined] + parsed = _assert_json(context) + for row in table.rows: + key = row["field"] + assert key in parsed["data"], f"'{key}' not in parsed['data']. Available: {list(parsed['data'].keys())}" + + +# --------------------------------------------------------------------------- +# Then steps — progress sub-section +# --------------------------------------------------------------------------- + +@then('the output JSON "data"."progress" is a list of 3 steps:') +def then_progress_list(context: Any, table: Any) -> None: # type: ignore[name-defined] + parsed = _assert_json(context) + prog = parsed["data"]["progress"] + assert isinstance(prog, list), f"'progress' not a list" + assert len(prog) == 3, f"'progress' has {len(prog)} steps; expected 3" + +@then('the output JSON "data"."progress" has all three step statuses equal to') +def then_progress_all_eq(context: Any, value: str) -> None: # type: ignore[name-defined] + parsed = _assert_json(context) + prog = parsed["data"]["progress"] + statuses = [s["status"] for s in prog] + assert all(st == value for st in statuses), f"Statuses {statuses} != '{value}'" + +@then('the output JSON "data"."progress" has:') +def then_progress_steps(context: Any, table: Any) -> None: # type: ignore[name-defined] + parsed = _assert_json(context) + prog = parsed["data"]["progress"] + step_map = {r["step"]: r["expected_status"] for r in table.rows} + status_lookup = {s["step"]: s["status"] for s in prog} + assert step_map == status_lookup, f"Step statuses mismatch: expected {step_map}, got {status_lookup}" + + +# --------------------------------------------------------------------------- +# Then steps — timing +# --------------------------------------------------------------------------- + +@then('the output JSON "timing" contains a "started" field matching an ISO 8601 datetime') +def then_timing_started(context: Any) -> None: # type: ignore[name-defined] + parsed = _assert_json(context) + timing = parsed["timing"] + assert "started" in timing, f"'started' not in timing keys: {list(timing.keys())}" + +@then('the output JSON "timing"."started" matches the regex') +def then_started_regex(context: Any) -> None: # type: ignore[name-defined] + parsed = _assert_json(context) + started = parsed["timing"]["started"] + iso_re = _re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") + assert iso_re.search(started), f"'started'={started!r} doesn't match ISO 8601" + +@then('the output JSON "timing" contains a "duration_ms" field with a positive integer value') +def then_duration(context: Any) -> None: # type: ignore[name-defined] + parsed = _assert_json(context) + dur = parsed["timing"].get("duration_ms") + assert dur is not None, f"'duration_ms' missing from timing" + + +# --------------------------------------------------------------------------- +# Then steps — optional fields (project, automation) +# --------------------------------------------------------------------------- + +@then('the output JSON "data"."project" equals') +def then_project(context: Any, expected: str) -> None: # type: ignore[name-defined] + parsed = _assert_json(context) + assert parsed["data"]["project"] == expected, ( + f'Expected project "{expected}" but got "{parsed["data"]["project"]}"') + +@then('the output JSON "data"."automation" equals') +def then_automation(context: Any, expected: str) -> None: # type: ignore[name-defined] + parsed = _assert_json(context) + assert parsed["data"]["automation"] == expected, ( + f'Expected automation "{expected}" but got "{parsed["data"]["automation"]}"') + + +# --------------------------------------------------------------------------- +# Then steps — cost & execution sections +# --------------------------------------------------------------------------- + +@then('the output JSON "data"."cost" contains:') +def then_cost(context: Any, table: Any) -> None: # type: ignore[name-defined] + parsed = _assert_json(context) + for row in table.rows: + k, v = row["field"], row["expected_value"] + actual = parsed["data"]["cost"].get(k) + if "." in str(v): + assert abs(actual - float(v)) < 1e-6, f'cost.{k}: expected {v}, got {actual}' + else: + assert actual == int(v), f'cost.{k}: expected {v}, got {actual}' + +@then('the output JSON "data"."execution"."child_plans" equals') +def then_child_plans(context: Any, expected: str) -> None: # type: ignore[name-defined] + parsed = _assert_json(context) + actual = parsed["data"]["execution"]["child_plans"] + assert actual == expected, f'child_plans: expected "{expected}", got "{actual}"' + + +# --------------------------------------------------------------------------- +# Then steps — non-enriched output paths (YAML / rich) +# --------------------------------------------------------------------------- + +@then('the output contains:') +def then_output_contains(context: Any, table: Any) -> None: # type: ignore[name-defined] + raw = context.raw_output + for row in table.rows: + fd = row.get("field", "") + ec = row.get("expected_content", "") + assert fd in raw or ec in raw, ( + f"'{fd}' (or '{ec}') not found in output" + ) + +@then('the output contains "Plan Status"') +def then_panel_present(context: Any) -> None: # type: ignore[name-defined] + assert "Plan Status" in context.raw_output, ( + f"'Plan Status' not in rich panel output.\n{context.raw_output[:500]}" + ) + +@then('the output is valid JSON and does NOT crash') +def then_multi_ok(context: Any) -> None: # type: ignore[name-defined] + _assert_json(context) diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index b2a7f64d0..f39221936 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -53,7 +53,11 @@ from cleveragents.domain.models.core.error_recovery import ( ErrorCategory, classify_error, ) -from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState +from cleveragents.domain.models.core.plan import ( + Plan as LifecyclePlan, + PlanPhase, + ProcessingState, +) from cleveragents.infrastructure.sandbox.git_worktree import ( GitWorktreeSandbox, ) @@ -237,9 +241,6 @@ def _plan_spec_dict(plan: Any) -> dict[str, object]: from cleveragents.domain.models.core.plan import ( ExecutionEnvPriority, ) - from cleveragents.domain.models.core.plan import ( - Plan as LifecyclePlan, - ) if isinstance(plan, LifecyclePlan): result: dict[str, object] = { @@ -477,6 +478,157 @@ def _execute_output_dict( } +def _get_progress_status(phase: PlanPhase, state: ProcessingState) -> str: + """Determine a single progress status string from phase and processing state.""" + if state == ProcessingState.ERRORED: + return "error" + if state in (ProcessingState.COMPLETE, ProcessingState.APPLIED): + return "done" + if state == ProcessingState.PROCESSING: + return "running" + return "queued" + + +def _status_output_dict( + plan: LifecyclePlan, + *, + started_at: datetime | None = None, + duration_ms: int | None = None, +) -> dict[str, Any]: + """Build a spec-compliant JSON envelope for plan status output. + + Returns a complete envelope matching the format expected by + the CLI spec (command, status, exit_code, data, timing, messages). + """ + command = "plan status" + status = "ok" + exit_code_val = 0 + + # --- Timing envelope ------------------------------------------------------- + _started_iso = started_at.isoformat() if started_at is not None else "" + _timing: dict[str, Any] = {"started": _started_iso} + if duration_ms is not None: + _timing["duration_ms"] = duration_ms + + # --- Core plan data -------------------------------------------------------- + phase_val = PlanPhase(plan.phase) if isinstance(plan.phase, str) else plan.phase # type: ignore[arg-type] + state_val = ProcessingState(plan.processing_state or "UNKNOWN") # type: ignore[union-attr] + + _data: dict[str, Any] = { + "plan_id": plan.id, + "phase": phase_val.value if isinstance(phase_val, PlanPhase) else str(phase_val), + "state": state_val.value if isinstance(state_val, ProcessingState) else str(state_val), + "action": plan.action_name or _empty_string(), + "attempt": 1, + } + + # Progress steps: map phase to step statuses + phase_for_progress = phase_val if isinstance(phase_val, PlanPhase) else PlanPhase(str(phase_val)) + progress_steps: list[dict[str, str]] = [] + for step_name in ("Strategize", "Execute", "Apply"): + _step_state: ProcessingState = ( # type: ignore[assignment] + state_val if phase_for_progress == APPLY else # noqa + ProcessingState.COMPLETE # noqa + ) + # For ACTION phase, all steps are queued until processing + if phase_for_progress == ACTION: # noqa + _step_state = ProcessingState.QUEUED # type: ignore[assignment] + progress_steps.append({"step": step_name, "status": _get_progress_status(phase_val, state_val)}) + _data["progress"] = progress_steps + + # --- Execution details ----------------------------------------------------- + has_changeset = plan.changeset is not None + files_modified_list: list[dict[str, Any]] = [] + if has_changeset and hasattr(plan.changeset, "changes"): # type: ignore[union-attr] + for c in plan.changeset.changes: # type: ignore[union-attr] + files_modified_list.append(_make_change_dict(c)) + + child_plans_count = len(plan.child_plan_ids or []) + completed_child_plans_count = len(plan.completed_child_plan_ids or []) + child_plans_str = f"{completed_child_plans_count}/{child_plans_count} complete" if child_plans_count > 0 else "0/0 complete" + + checkpoints_val: list[Any] = [] + if hasattr(plan, "checkpoints") and plan.checkpoints is not None: # type: ignore[union-attr] + for cp in (plan.checkpoints or []): # type: ignore[union-attr] + checkpoints_val.append({"checkpoint_id": cp.id if hasattr(cp, "id") else str(cp), "timestamp": _str_time_safe(getattr(cp, "timestamp", None))}) + + _data["execution"] = { + "sandbox": plan.sandbox_type if isinstance(plan, type) and hasattr(plan, "sandbox_type") else "", + "tool_calls": 0, + "files_modified": files_modified_list, + "child_plans": child_plans_str, + "checkpoints": checkpoints_val, + } + + # --- Cost ------------------------------------------------------------------ + _cost_data: dict[str, Any] = {"tokens_used": 0, "cost_so_far": 0.0, "estimated": 0.0} + if hasattr(plan, "estimation_result") and plan.estimation_result is not None: # type: ignore[union-attr] + _as_dict = getattr(plan.estimation_result, "as_display_dict", lambda: {})() + _cost_data["tokens_used"] = _as_dict.get("total_usage", {}).get("tokens", 0) + _cost_data["cost_so_far"] = _as_dict.get("cost", {}).get("total_cost", 0.0) + _cost_data["estimated"] = _as_dict.get("budget", {}).get("total_cost", 0.0) + + _data["cost"] = _cost_data + + # --- Optional fields ------------------------------------------------------- + if plan.project_name: + _data["project"] = plan.project_name # type: ignore[union-attr] + if hasattr(plan, "automation_profile") and plan.automation_profile is not None: # type: ignore[union-attr] + _data["automation"] = plan.automation_profile + + # --- Nested timing in data ------------------------------------------------- + _data_timing: dict[str, Any] = {} + _created_str = _str_time_safe(plan.created_at) if hasattr(plan, "created_at") else "" + _updated_str = _str_time_safe(plan.updated_at) if hasattr(plan, "updated_at") else "" + if _created_str and _updated_str: + _data_timing["started"] = _created_str + try: + _t1 = datetime.fromisoformat(_created_str) + _t2 = datetime.fromisoformat(_updated_str) + _elapsed_ms = int((_t2 - _t1).total_seconds() * 1000) if _t2 > _t1 else 0 + _data_timing["elapsed"] = _elapsed_ms + + if duration_ms is not None and duration_ms < 5000: + _total_expected = int(duration_ms / max(1, child_plans_count)) + _eta_remaining = _total_expected * max(0, child_plans_count + 1 - completed_child_plans_count) + _data_timing["eta"] = _eta_remaining + except (ValueError, TypeError): + pass + + if _data_timing: + _data["timing"] = _data_timing + + return { + "command": command, + "status": status, + "exit_code": exit_code_val, + "data": _data, + "timing": _timing, + "messages": ["Status refreshed"], + } + + +def _empty_string() -> str: + return "" + +def _str_time_safe(dt_val): # type: ignore[no-untyped-def] + if dt_val is None: + return "" + try: + return datetime.fromisoformat(str(dt_val)).isoformat() + except (ValueError, TypeError): + return str(dt_val) + +def _make_change_dict(change): # type: ignore[no-untyped-def] + result = {} + for attr in ("path", "action", "hunks"): + if hasattr(change, attr): + val = getattr(change, attr) + if val is not None: + result[attr] = val + return result if result else {} + + def _get_current_project() -> Project: """Get the current project or exit with error. @@ -2383,12 +2535,40 @@ def plan_status( # instead of a generic "Plan not found". _validate_plan_ulid(plan_id) - # Show single plan details + # Single plan status + _status_work_start = time.monotonic() # type: ignore[name-defined] plan = service.get_plan(plan_id) + if fmt == OutputFormat.JSON.value: + # Direct JSON output — bypasses format_output() to preserve complete envelope. + json_payload = _status_output_dict( + plan, + started_at=datetime.now(), + duration_ms=int((time.monotonic() - _status_work_start) * 1000), + ) + console.print(json.dumps(json_payload, default=str)) + return + + # Non-JSON output (YAML/PLAIN): build envelope and delegate to format_output. if fmt != OutputFormat.RICH.value: - data = _plan_spec_dict(plan) - console.print(format_output(data, fmt)) + envelope = _status_output_dict( + plan, + started_at=datetime.now(), + duration_ms=int((time.monotonic() - _status_work_start) * 1000), + ) + data = envelope.get("data", {}) + command_val = str(envelope.get("command", "plan status")) + status_val = str(envelope.get("status", "ok")) + exit_code_val = int(envelope.get("exit_code", 0)) + messages = envelope.get("messages") or [] + _formatted_messages = [ + {"level": "info", "text": msg} for msg in messages if isinstance(msg, str) + ] + console.print( + format_output( + data, fmt, command=command_val, status=status_val, exit_code=exit_code_val, messages=_formatted_messages, + ) + ) return _print_lifecycle_plan(plan, title="Plan Status") -- 2.52.0