fix(cli): wrap plan status --format json output in spec-required JSON envelope #11083

Closed
HAL9000 wants to merge 1 commits from fix/plan-status-json-envelope-pr-11034 into master
3 changed files with 690 additions and 7 deletions
+184
View File
@@ -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 <plan_id> --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 <plan_id> --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 <plan_id> --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
@@ -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 <plan_id> --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 <plan_id> --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 <plan_id> --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)
+187 -7
View File
@@ -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")