From cf15e0cc5fd7a7fa00447f03c9588f66739138fc Mon Sep 17 00:00:00 2001 From: CleverThis Date: Sun, 3 May 2026 22:46:22 +0000 Subject: [PATCH 1/6] fix(cli): wrap plan status --format json output in spec-required JSON envelope - Updated plan_status() to build spec-compliant JSON envelope via _status_output_dict() - Envelope includes: command, status, exit_code, data, timing, messages fields - data includes: plan_id, phase, state, action, attempt, progress, execution, cost - Computed elapsed/eta from plan timestamps and estimation_result - Derived files_modified from plan.changeset.changes count - Derived child_plans from plan.child_plan_ids/completed_child_plan_ids - Moved Plan/PlanPhase/ProcessingState imports to module level - Promoted _get_progress_status to module-level private function - Added Behave BDD test scenarios in features/plan_status_json_envelope.feature - Updated cli_output_formats.feature to use spec field name 'state' (not 'processing_state') - Added CHANGELOG entry for #9450 ISSUES CLOSED: #9450 --- CHANGELOG.md | 14 + features/cli_output_formats.feature | 4 +- features/plan_status_json_envelope.feature | 127 ++++++++ .../steps/plan_status_json_envelope_steps.py | 296 ++++++++++++++++++ src/cleveragents/cli/commands/plan.py | 244 ++++++++++++++- 5 files changed, 681 insertions(+), 4 deletions(-) create mode 100644 features/plan_status_json_envelope.feature create mode 100644 features/steps/plan_status_json_envelope_steps.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 905c8b89c..8657f8bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -350,6 +350,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). limit. 19 BDD scenarios cover all v3 paths including tool actors, update mode, LSP dict bindings, and field propagation. +- **Plan Status JSON Envelope Compliance** (#9450): `plan_status` in + `src/cleveragents/cli/commands/plan.py` now returns a spec-compliant JSON + envelope for `agents plan status --format json` instead of a raw plan + dictionary. The envelope includes all required fields: `command: "plan status"`, + `status: "ok"`, `exit_code: 0`, `data` (with `action`, `project`, `automation`, + `attempt`, `progress`, `timing`, `execution`, `cost`), `timing` (with `started` + and `duration_ms`), and `messages: ["Status refreshed"]`. The `elapsed` and + `eta` fields in `data.timing` are now computed from plan timestamps and + estimation results respectively. The `files_modified` and `child_plans` fields + in `data.execution` are derived from plan changeset and subplan references. + Imports `Plan as LifecyclePlan` and `ProcessingState` moved to module level; + `_get_progress_status` promoted to module-level private function. BDD test + scenarios added in `features/plan_status_json_envelope.feature`. + - **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in `features/environment.py` now emits its non-assertion exception guard warning to both the structured logger and `stderr` via a new `_warning_with_stderr` helper. diff --git a/features/cli_output_formats.feature b/features/cli_output_formats.feature index c8d2f1622..d5a528b46 100644 --- a/features/cli_output_formats.feature +++ b/features/cli_output_formats.feature @@ -33,14 +33,14 @@ Feature: CLI output formats parity Given there is a single plan for format testing When I run plan status with plan id and --format json Then the output should be valid JSON - And the JSON should contain key "processing_state" + And the JSON should contain key "state" # Plan status --format plain (single plan) Scenario: Plan status single plan outputs plain text Given there is a single plan for format testing When I run plan status with plan id and --format plain Then the format output should contain "plan_id:" - And the format output should contain "processing_state:" + And the format output should contain "state:" # Action list --format table Scenario: Action list outputs table format diff --git a/features/plan_status_json_envelope.feature b/features/plan_status_json_envelope.feature new file mode 100644 index 000000000..a82a241f8 --- /dev/null +++ b/features/plan_status_json_envelope.feature @@ -0,0 +1,127 @@ +Feature: Plan status JSON envelope compliance + As a programmatic consumer of the CleverAgents CLI + I want agents plan status --format json to return a spec-compliant JSON envelope + So that I can reliably parse plan status data from the structured output + + Background: + Given a plan status JSON envelope test runner + And a plan status JSON envelope mocked lifecycle service + + # ── Envelope field presence ────────────────────────────────────────────── + + Scenario: Plan status JSON output includes all required top-level envelope fields + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON envelope should contain field "command" + And the plan status JSON envelope should contain field "status" + And the plan status JSON envelope should contain field "exit_code" + And the plan status JSON envelope should contain field "data" + And the plan status JSON envelope should contain field "timing" + And the plan status JSON envelope should contain field "messages" + + Scenario: Plan status JSON envelope command field is "plan status" + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON envelope command should be "plan status" + + Scenario: Plan status JSON envelope status field is "ok" + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON envelope status should be "ok" + + Scenario: Plan status JSON envelope exit_code is 0 + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON envelope exit_code should be 0 + + Scenario: Plan status JSON envelope messages contains "Status refreshed" + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON envelope messages should contain "Status refreshed" + + # ── Data field presence ────────────────────────────────────────────────── + + Scenario: Plan status JSON data field contains action name + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON data should contain field "action" + + Scenario: Plan status JSON data field contains plan_id + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON data should contain field "plan_id" + + Scenario: Plan status JSON data field contains phase + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON data should contain field "phase" + + Scenario: Plan status JSON data field contains state + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON data should contain field "state" + + Scenario: Plan status JSON data field contains attempt + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON data should contain field "attempt" + + Scenario: Plan status JSON data field contains progress array + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON data should contain field "progress" + And the plan status JSON data progress should be a list + + Scenario: Plan status JSON data progress contains Strategize step + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON data progress should contain step "Strategize" + + Scenario: Plan status JSON data progress contains Execute step + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON data progress should contain step "Execute" + + Scenario: Plan status JSON data progress contains Apply step + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON data progress should contain step "Apply" + + Scenario: Plan status JSON data field contains execution details + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON data should contain field "execution" + And the plan status JSON data execution should contain "sandbox" + And the plan status JSON data execution should contain "tool_calls" + And the plan status JSON data execution should contain "files_modified" + And the plan status JSON data execution should contain "child_plans" + And the plan status JSON data execution should contain "checkpoints" + + Scenario: Plan status JSON data field contains cost details + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON data should contain field "cost" + And the plan status JSON data cost should contain "tokens_used" + And the plan status JSON data cost should contain "cost_so_far" + And the plan status JSON data cost should contain "estimated" + + # ── Timing envelope field ──────────────────────────────────────────────── + + Scenario: Plan status JSON timing envelope contains duration_ms + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON timing should contain "duration_ms" + + # ── Project and automation fields ──────────────────────────────────────── + + Scenario: Plan status JSON data contains project when plan has project links + Given a plan status JSON envelope plan exists with project "local/api-service" + When I run plan status with format json + Then the plan status JSON data should contain field "project" + And the plan status JSON data project should be "local/api-service" + + Scenario: Plan status JSON data contains automation when plan has automation profile + Given a plan status JSON envelope plan exists with automation profile "review" + When I run plan status with format json + Then the plan status JSON data should contain field "automation" + And the plan status JSON data automation should be "review" 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..8967979ef --- /dev/null +++ b/features/steps/plan_status_json_envelope_steps.py @@ -0,0 +1,296 @@ +"""Step definitions for plan status JSON envelope compliance feature.""" + +from __future__ import annotations + +import json +from datetime import datetime +from unittest.mock import MagicMock, patch + +from behave import given, then, when +from behave.runner import Context +from typer.testing import CliRunner + +from cleveragents.cli.commands.plan import app as plan_app +from cleveragents.domain.models.core.plan import ( + AutomationProfileProvenance, + AutomationProfileRef, + NamespacedName, + Plan, + PlanIdentity, + PlanPhase, + PlanTimestamps, + ProcessingState, + ProjectLink, +) + +_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S" + + +def _make_status_plan( + *, + action_name: str = "local/test-action", + phase: PlanPhase = PlanPhase.EXECUTE, + state: ProcessingState = ProcessingState.PROCESSING, + project_links: list[ProjectLink] | None = None, + automation_profile: AutomationProfileRef | None = None, +) -> Plan: + """Create a Plan instance for status JSON envelope tests.""" + now = datetime.now() + return Plan( + identity=PlanIdentity(plan_id=_PLAN_ULID), + namespaced_name=NamespacedName.parse("local/test-plan"), + description="Test plan description", + definition_of_done="All tests pass", + action_name=action_name, + phase=phase, + processing_state=state, + project_links=project_links or [], + arguments={}, + arguments_order=[], + automation_profile=automation_profile, + invariants=[], + strategy_actor="openai/gpt-4", + execution_actor="openai/gpt-4", + estimation_actor=None, + invariant_actor=None, + reusable=True, + read_only=False, + created_by=None, + timestamps=PlanTimestamps(created_at=now, updated_at=now), + ) + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("a plan status JSON envelope test runner") +def step_status_envelope_runner(context: Context) -> None: + """Set up the CLI runner.""" + context.runner = CliRunner() + + +@given("a plan status JSON envelope mocked lifecycle service") +def step_status_envelope_service(context: Context) -> None: + """Set up a mock PlanLifecycleService.""" + context.mock_service = MagicMock() + context.service_patcher = patch( + "cleveragents.cli.commands.plan._get_lifecycle_service", + return_value=context.mock_service, + ) + context.service_patcher.start() + if not hasattr(context, "_cleanup_handlers"): + context._cleanup_handlers = [] + context._cleanup_handlers.append(context.service_patcher.stop) + + +# --------------------------------------------------------------------------- +# Given steps +# --------------------------------------------------------------------------- + + +@given("a plan status JSON envelope plan exists") +def step_status_envelope_plan(context: Context) -> None: + """Set up a plan for status JSON envelope tests.""" + context.mock_plan = _make_status_plan( + project_links=[ProjectLink(project_name="local/api-service")], + automation_profile=AutomationProfileRef( + profile_name="review", + provenance=AutomationProfileProvenance.PLAN, + ), + ) + context.mock_service.get_plan.return_value = context.mock_plan + + +@given('a plan status JSON envelope plan exists with project "{project_name}"') +def step_status_envelope_plan_with_project(context: Context, project_name: str) -> None: + """Set up a plan with a specific project link.""" + context.mock_plan = _make_status_plan( + project_links=[ProjectLink(project_name=project_name)], + ) + context.mock_service.get_plan.return_value = context.mock_plan + + +@given('a plan status JSON envelope plan exists with automation profile "{profile_name}"') +def step_status_envelope_plan_with_profile(context: Context, profile_name: str) -> None: + """Set up a plan with a specific automation profile.""" + context.mock_plan = _make_status_plan( + automation_profile=AutomationProfileRef( + profile_name=profile_name, + provenance=AutomationProfileProvenance.PLAN, + ), + ) + context.mock_service.get_plan.return_value = context.mock_plan + + +# --------------------------------------------------------------------------- +# When steps +# --------------------------------------------------------------------------- + + +@when("I run plan status with format json") +def step_run_plan_status_json(context: Context) -> None: + """Run plan status with --format json and parse the JSON output.""" + result = context.runner.invoke( + plan_app, ["status", _PLAN_ULID, "--format", "json"] + ) + assert result.exit_code == 0, ( + f"plan status --format json failed ({result.exit_code}): {result.output}" + ) + context.result = result + # Parse the JSON output + try: + context.envelope = json.loads(result.output) + except json.JSONDecodeError as e: + raise AssertionError( + f"plan status --format json did not produce valid JSON: {e}\n" + f"Output was: {result.output}" + ) from e + + +# --------------------------------------------------------------------------- +# Then steps — envelope fields +# --------------------------------------------------------------------------- + + +@then('the plan status JSON envelope should contain field "{field_name}"') +def step_envelope_has_field(context: Context, field_name: str) -> None: + """Verify the envelope contains the specified top-level field.""" + assert field_name in context.envelope, ( + f"Expected envelope to contain field '{field_name}', " + f"but got keys: {list(context.envelope.keys())}" + ) + + +@then('the plan status JSON envelope command should be "{expected_command}"') +def step_envelope_command(context: Context, expected_command: str) -> None: + """Verify the envelope command field value.""" + actual = context.envelope.get("command") + assert actual == expected_command, ( + f"Expected envelope command to be '{expected_command}', got '{actual}'" + ) + + +@then('the plan status JSON envelope status should be "{expected_status}"') +def step_envelope_status(context: Context, expected_status: str) -> None: + """Verify the envelope status field value.""" + actual = context.envelope.get("status") + assert actual == expected_status, ( + f"Expected envelope status to be '{expected_status}', got '{actual}'" + ) + + +@then("the plan status JSON envelope exit_code should be {expected_code:d}") +def step_envelope_exit_code(context: Context, expected_code: int) -> None: + """Verify the envelope exit_code field value.""" + actual = context.envelope.get("exit_code") + assert actual == expected_code, ( + f"Expected envelope exit_code to be {expected_code}, got {actual}" + ) + + +@then('the plan status JSON envelope messages should contain "{expected_message}"') +def step_envelope_messages(context: Context, expected_message: str) -> None: + """Verify the envelope messages list contains the expected message.""" + messages = context.envelope.get("messages", []) + assert isinstance(messages, list), ( + f"Expected messages to be a list, got {type(messages)}" + ) + # Messages may be strings or dicts with "text" key + message_texts = [ + m["text"] if isinstance(m, dict) else m + for m in messages + ] + assert expected_message in message_texts, ( + f"Expected messages to contain '{expected_message}', got {messages}" + ) + + +# --------------------------------------------------------------------------- +# Then steps — data fields +# --------------------------------------------------------------------------- + + +@then('the plan status JSON data should contain field "{field_name}"') +def step_data_has_field(context: Context, field_name: str) -> None: + """Verify the data dict contains the specified field.""" + data = context.envelope.get("data", {}) + assert field_name in data, ( + f"Expected data to contain field '{field_name}', " + f"but got keys: {list(data.keys())}" + ) + + +@then("the plan status JSON data progress should be a list") +def step_data_progress_is_list(context: Context) -> None: + """Verify the data.progress field is a list.""" + data = context.envelope.get("data", {}) + progress = data.get("progress") + assert isinstance(progress, list), ( + f"Expected data.progress to be a list, got {type(progress)}: {progress}" + ) + + +@then('the plan status JSON data progress should contain step "{step_name}"') +def step_data_progress_has_step(context: Context, step_name: str) -> None: + """Verify the data.progress list contains a step with the given name.""" + data = context.envelope.get("data", {}) + progress = data.get("progress", []) + step_names = [s.get("step") for s in progress if isinstance(s, dict)] + assert step_name in step_names, ( + f"Expected progress to contain step '{step_name}', " + f"but found steps: {step_names}" + ) + + +@then('the plan status JSON data execution should contain "{field_name}"') +def step_data_execution_has_field(context: Context, field_name: str) -> None: + """Verify the data.execution dict contains the specified field.""" + data = context.envelope.get("data", {}) + execution = data.get("execution", {}) + assert field_name in execution, ( + f"Expected data.execution to contain '{field_name}', " + f"but got keys: {list(execution.keys())}" + ) + + +@then('the plan status JSON data cost should contain "{field_name}"') +def step_data_cost_has_field(context: Context, field_name: str) -> None: + """Verify the data.cost dict contains the specified field.""" + data = context.envelope.get("data", {}) + cost = data.get("cost", {}) + assert field_name in cost, ( + f"Expected data.cost to contain '{field_name}', " + f"but got keys: {list(cost.keys())}" + ) + + +@then('the plan status JSON timing should contain "{field_name}"') +def step_timing_has_field(context: Context, field_name: str) -> None: + """Verify the top-level timing dict contains the specified field.""" + timing = context.envelope.get("timing", {}) + assert field_name in timing, ( + f"Expected timing to contain '{field_name}', " + f"but got keys: {list(timing.keys())}" + ) + + +@then('the plan status JSON data project should be "{expected_project}"') +def step_data_project_value(context: Context, expected_project: str) -> None: + """Verify the data.project field value.""" + data = context.envelope.get("data", {}) + actual = data.get("project") + assert actual == expected_project, ( + f"Expected data.project to be '{expected_project}', got '{actual}'" + ) + + +@then('the plan status JSON data automation should be "{expected_automation}"') +def step_data_automation_value(context: Context, expected_automation: str) -> None: + """Verify the data.automation field value.""" + data = context.envelope.get("data", {}) + actual = data.get("automation") + assert actual == expected_automation, ( + f"Expected data.automation to be '{expected_automation}', got '{actual}'" + ) diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index b2a7f64d0..be80bed1f 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -53,6 +53,7 @@ from cleveragents.domain.models.core.error_recovery import ( ErrorCategory, classify_error, ) +from cleveragents.domain.models.core.plan import Plan as LifecyclePlan from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState from cleveragents.infrastructure.sandbox.git_worktree import ( GitWorktreeSandbox, @@ -477,6 +478,206 @@ def _execute_output_dict( } + + +def _get_progress_status(phase: PlanPhase, state: ProcessingState) -> str: + """Determine progress status based on plan phase and state. + + Args: + phase: The current plan phase. + state: The current processing state. + + Returns: + A string status: "error", "done", "running", or "queued". + """ + 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: Any, + started_at: datetime | None = None, + duration_ms: int | None = None, +) -> dict[str, object]: + """Build the spec-required status output envelope. + + Returns the structured JSON envelope for ``agents plan status --format json`` + as defined in the specification §agents plan status. + + Args: + plan: The ``Plan`` domain model. + started_at: When status was retrieved (used for timing). + duration_ms: Elapsed milliseconds. ``None`` when not available. + + Returns: + A JSON-serialisable dict matching the spec-required envelope. + """ + if not isinstance(plan, LifecyclePlan): + # Legacy plan fallback — return minimal envelope + return { + "command": "plan status", + "status": "ok", + "exit_code": 0, + "data": {"plan": str(plan)}, + "timing": {}, + "messages": ["Status refreshed"], + } + + plan_id = plan.identity.plan_id + + # ── Action name ─────────────────────────────────────────────────────────── + action: str = plan.action_name or "unknown" + + # ── Project name ────────────────────────────────────────────────────────── + project: str | None = None + if plan.project_links: + project = plan.project_links[0].project_name + + # ── Automation profile ──────────────────────────────────────────────────── + automation: str | None = None + if plan.automation_profile: + automation = plan.automation_profile.profile_name + + # ── Attempt ─────────────────────────────────────────────────────────────── + attempt: int = plan.identity.attempt + + # ── Progress steps ──────────────────────────────────────────────────────── + # Map plan phase to progress steps (Strategize, Execute, Apply) + progress: list[dict[str, str]] = [ + { + "step": "Strategize", + "status": ( + "done" + if plan.phase.value != "strategize" + else _get_progress_status(plan.phase, plan.processing_state) + ), + }, + { + "step": "Execute", + "status": ( + "done" + if plan.phase.value not in ("strategize", "execute") + else _get_progress_status(plan.phase, plan.processing_state) + ), + }, + { + "step": "Apply", + "status": ( + _get_progress_status(plan.phase, plan.processing_state) + if plan.phase.value == "apply" + else "queued" + ), + }, + ] + + # ── Timing ──────────────────────────────────────────────────────────────── + timing_data: dict[str, object] = {} + if started_at is not None: + timing_data["started"] = started_at.isoformat() + if duration_ms is not None: + timing_data["duration_ms"] = duration_ms + + # ── Execution details ───────────────────────────────────────────────────── + # Derive files_modified from changeset when available + files_modified: int = 0 + _changeset = getattr(plan, "changeset", None) + if _changeset is not None: + files_modified = len(getattr(_changeset, "changes", [])) + # Derive child_plans count from subplan references when available + child_plans_total: int = 0 + child_plans_complete: int = 0 + if hasattr(plan, "child_plan_ids"): + child_plan_ids = getattr(plan, "child_plan_ids", []) or [] + child_plans_total = len(child_plan_ids) + if hasattr(plan, "completed_child_plan_ids"): + completed_ids = getattr(plan, "completed_child_plan_ids", []) or [] + child_plans_complete = len(completed_ids) + child_plans_str = f"{child_plans_complete}/{child_plans_total}" + execution: dict[str, object] = { + "sandbox": "git_worktree", + "tool_calls": len(getattr(plan, "decisions", [])), + "files_modified": files_modified, + "child_plans": child_plans_str, + "checkpoints": ( + len(getattr(plan, "checkpoints", [])) + if hasattr(plan, "checkpoints") + else 0 + ), + } + + # ── Cost details ────────────────────────────────────────────────────────── + cost: dict[str, object] = { + "tokens_used": 0, + "cost_so_far": 0.0, + "estimated": 0.0, + } + if plan.estimation_result is not None: + est = plan.estimation_result.as_display_dict() + cost["tokens_used"] = est.get("tokens_used", 0) + cost["cost_so_far"] = est.get("cost_so_far", 0.0) + cost["estimated"] = est.get("estimated_cost", 0.0) + + # ── Data payload ────────────────────────────────────────────────────────── + data: dict[str, object] = { + "plan_id": plan_id, + "phase": plan.phase.value, + "state": plan.processing_state.value, + "action": action, + "attempt": attempt, + "progress": progress, + "execution": execution, + "cost": cost, + } + if project: + data["project"] = project + if automation: + data["automation"] = automation + + # Add timing to data if available + if plan.timestamps.created_at: + # Calculate elapsed time from created_at to updated_at (or now) + ts_created = plan.timestamps.created_at + ts_end = plan.timestamps.updated_at or ts_created + elapsed_seconds = int((ts_end - ts_created).total_seconds()) + elapsed_h = elapsed_seconds // 3600 + elapsed_m = (elapsed_seconds % 3600) // 60 + elapsed_s = elapsed_seconds % 60 + elapsed_str = f"{elapsed_h:02d}:{elapsed_m:02d}:{elapsed_s:02d}" + # ETA: derive from estimation_result when available + eta_str: str + if plan.estimation_result is not None: + est_display = plan.estimation_result.as_display_dict() + eta_seconds = int(est_display.get("estimated_duration_seconds", 0)) + remaining = max(0, eta_seconds - elapsed_seconds) + eta_h = remaining // 3600 + eta_m = (remaining % 3600) // 60 + eta_s = remaining % 60 + eta_str = f"{eta_h:02d}:{eta_m:02d}:{eta_s:02d}" + else: + eta_str = "00:00:00" + data["timing"] = { + "started": ts_created.strftime("%H:%M:%S"), + "elapsed": elapsed_str, + "eta": eta_str, + } + + return { + "command": "plan status", + "status": "ok", + "exit_code": 0, + "data": data, + "timing": timing_data, + "messages": ["Status refreshed"], + } + + +# Programmatic wrapper functions for testing and scripting +def tell_command(prompt: str, name: str | None = None) -> None: def _get_current_project() -> Project: """Get the current project or exit with error. @@ -2387,8 +2588,47 @@ def plan_status( plan = service.get_plan(plan_id) if fmt != OutputFormat.RICH.value: - data = _plan_spec_dict(plan) - console.print(format_output(data, fmt)) + _status_started_at = datetime.now() + _status_t0 = time.monotonic() + _status_duration_ms = int((time.monotonic() - _status_t0) * 1000) + envelope = _status_output_dict( + plan, + started_at=_status_started_at, + duration_ms=_status_duration_ms, + ) + # Pass the data payload to format_output so it builds the + # spec-required envelope correctly (command, status, timing, etc.) + _env_data = envelope.get("data", {}) + _env_messages = envelope.get("messages") + _env_exit_code_raw = envelope.get("exit_code", 0) + _env_data_dict: dict[str, Any] = ( + _env_data if isinstance(_env_data, dict) else {} + ) + _env_exit_code: int = ( + _env_exit_code_raw + if isinstance(_env_exit_code_raw, int) + else 0 + ) + # Convert string messages to the format_output-required dict format + _env_messages_list: list[dict[str, Any]] | None = None + if isinstance(_env_messages, list): + _env_messages_list = [ + {"level": "ok", "text": m} + if isinstance(m, str) + else m + for m in _env_messages + if isinstance(m, (str, dict)) + ] + console.print( + format_output( + _env_data_dict, + fmt, + command=str(envelope.get("command", "plan status")), + status=str(envelope.get("status", "ok")), + exit_code=_env_exit_code, + messages=_env_messages_list, + ) + ) return _print_lifecycle_plan(plan, title="Plan Status") -- 2.52.0 From 884e9ffbfd13ec94f824c80e9a5c8f7ffe9eb782 Mon Sep 17 00:00:00 2001 From: HAL9000 Date: Thu, 7 May 2026 08:21:44 +0000 Subject: [PATCH 2/6] fix(cli): remove triple blank lines and redundant inline import in plan.py Remove two triple blank lines (PEP 8 violation) between function definitions in plan.py that were causing the CI lint gate to fail. Also remove a redundant inline import of LifecyclePlan inside _execute_output_dict() since it is already imported at module level. ISSUES CLOSED: #9450 --- src/cleveragents/cli/commands/plan.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index be80bed1f..d02720e5a 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -347,8 +347,6 @@ def _execute_output_dict( Returns: A JSON-serialisable dict matching the spec-required envelope. """ - from cleveragents.domain.models.core.plan import Plan as LifecyclePlan - if not isinstance(plan, LifecyclePlan): # Legacy plan fallback — return minimal envelope return { @@ -479,7 +477,6 @@ def _execute_output_dict( - def _get_progress_status(phase: PlanPhase, state: ProcessingState) -> str: """Determine progress status based on plan phase and state. -- 2.52.0 From 21a5fa52e2fb37d8b9e800175a4d1132f3b69e03 Mon Sep 17 00:00:00 2001 From: HAL9000 Date: Thu, 7 May 2026 08:24:06 +0000 Subject: [PATCH 3/6] style(cli): apply ruff format to plan.py and plan_status_json_envelope_steps.py Bracket actual work for duration measurement in plan_status so the reported timing reflects real wall-clock cost instead of ~0ms. ISSUES CLOSED: #9450 --- .../steps/plan_status_json_envelope_steps.py | 13 ++++----- src/cleveragents/cli/commands/plan.py | 27 +++++++------------ 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/features/steps/plan_status_json_envelope_steps.py b/features/steps/plan_status_json_envelope_steps.py index 8967979ef..feb167ccb 100644 --- a/features/steps/plan_status_json_envelope_steps.py +++ b/features/steps/plan_status_json_envelope_steps.py @@ -112,7 +112,9 @@ def step_status_envelope_plan_with_project(context: Context, project_name: str) context.mock_service.get_plan.return_value = context.mock_plan -@given('a plan status JSON envelope plan exists with automation profile "{profile_name}"') +@given( + 'a plan status JSON envelope plan exists with automation profile "{profile_name}"' +) def step_status_envelope_plan_with_profile(context: Context, profile_name: str) -> None: """Set up a plan with a specific automation profile.""" context.mock_plan = _make_status_plan( @@ -132,9 +134,7 @@ def step_status_envelope_plan_with_profile(context: Context, profile_name: str) @when("I run plan status with format json") def step_run_plan_status_json(context: Context) -> None: """Run plan status with --format json and parse the JSON output.""" - result = context.runner.invoke( - plan_app, ["status", _PLAN_ULID, "--format", "json"] - ) + result = context.runner.invoke(plan_app, ["status", _PLAN_ULID, "--format", "json"]) assert result.exit_code == 0, ( f"plan status --format json failed ({result.exit_code}): {result.output}" ) @@ -198,10 +198,7 @@ def step_envelope_messages(context: Context, expected_message: str) -> None: f"Expected messages to be a list, got {type(messages)}" ) # Messages may be strings or dicts with "text" key - message_texts = [ - m["text"] if isinstance(m, dict) else m - for m in messages - ] + message_texts = [m["text"] if isinstance(m, dict) else m for m in messages] assert expected_message in message_texts, ( f"Expected messages to contain '{expected_message}', got {messages}" ) diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index d02720e5a..faf562fb3 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -476,7 +476,6 @@ def _execute_output_dict( } - def _get_progress_status(phase: PlanPhase, state: ProcessingState) -> str: """Determine progress status based on plan phase and state. @@ -601,9 +600,7 @@ def _status_output_dict( "files_modified": files_modified, "child_plans": child_plans_str, "checkpoints": ( - len(getattr(plan, "checkpoints", [])) - if hasattr(plan, "checkpoints") - else 0 + len(getattr(plan, "checkpoints", [])) if hasattr(plan, "checkpoints") else 0 ), } @@ -673,8 +670,6 @@ def _status_output_dict( } -# Programmatic wrapper functions for testing and scripting -def tell_command(prompt: str, name: str | None = None) -> None: def _get_current_project() -> Project: """Get the current project or exit with error. @@ -2581,17 +2576,17 @@ def plan_status( # instead of a generic "Plan not found". _validate_plan_ulid(plan_id) - # Show single plan details + # Show single plan details — time the actual work (plan retrieval + + # envelope construction) so the reported duration reflects real wall- + # clock cost rather than a near-zero back-to-back monotonic() read. + _status_work_start = time.monotonic() plan = service.get_plan(plan_id) if fmt != OutputFormat.RICH.value: - _status_started_at = datetime.now() - _status_t0 = time.monotonic() - _status_duration_ms = int((time.monotonic() - _status_t0) * 1000) envelope = _status_output_dict( plan, - started_at=_status_started_at, - duration_ms=_status_duration_ms, + started_at=datetime.now(), + duration_ms=int((time.monotonic() - _status_work_start) * 1000), ) # Pass the data payload to format_output so it builds the # spec-required envelope correctly (command, status, timing, etc.) @@ -2602,17 +2597,13 @@ def plan_status( _env_data if isinstance(_env_data, dict) else {} ) _env_exit_code: int = ( - _env_exit_code_raw - if isinstance(_env_exit_code_raw, int) - else 0 + _env_exit_code_raw if isinstance(_env_exit_code_raw, int) else 0 ) # Convert string messages to the format_output-required dict format _env_messages_list: list[dict[str, Any]] | None = None if isinstance(_env_messages, list): _env_messages_list = [ - {"level": "ok", "text": m} - if isinstance(m, str) - else m + {"level": "ok", "text": m} if isinstance(m, str) else m for m in _env_messages if isinstance(m, (str, dict)) ] -- 2.52.0 From f6f83d39f545012ac1e4ae42675655c57e9acae7 Mon Sep 17 00:00:00 2001 From: HAL9000 Date: Thu, 7 May 2026 18:56:23 +0000 Subject: [PATCH 4/6] fix(cli): resolve spec compliance blocks for plan status JSON envelope (PR #9827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address all 5 blocking issues from Cycle 10 review (ID 7866) for plan status --format json output. Changes: - BLOCKER A: Add optional started_iso parameter to _build_envelope() and format_output(). plan_status() now forwards the plan-created-at ISO timestamp through to the outer timing.started envelope field, per spec. - BLOCKER B: Fix child_plans string format - append " complete" suffix per spec (e.g. "0/2 complete" instead of "0/2"). - BLOCKER C: Fix progress step status logic so that plans in non-traditional phases (ACTION/SUBMIT) report "queued" for Strategize and Execute steps instead of incorrectly reporting "done". - BLOCKER D: Add 5 new Behave scenarios covering timing.started presence, child_plans format, and ACTION phase progress reporting. COMPLIANCE CHECKLIST: [ ] 1. CHANGELOG.md — added spec compliance follow-up entry under [Unreleased] \u2705 [ ] 2. CONTRIBUTORS.md — updated contribution entry for HAL9000 \u2705 [ ] 3. Commit footer — ISSUES CLOSED: #9450 [CI checks] 4. CI passes - lint \u2713, format \u2713 (typecheck/unit_tests in CI) [ ] 5. BDD/Behave tests added or updated \u2705 [ ] 6. Epic reference in PR description — v3.2.0 milestone / issue #9450 [ ] 7. Labels via forgejo-label-manager (already present: Type/Bug, State/In Review) [ ] 8. Milestone assigned to earliest open matching milestone \u2705 v3.2.0 ISSUES CLOSED: #9450 --- CHANGELOG.md | 2 + CONTRIBUTORS.md | 3 +- features/plan_status_json_envelope.feature | 30 ++++++ .../steps/plan_status_json_envelope_steps.py | 94 +++++++++++++++++++ src/cleveragents/cli/commands/plan.py | 34 +++++-- src/cleveragents/cli/formatting.py | 33 ++++++- 6 files changed, 185 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8657f8bfc..5cc1f0216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -364,6 +364,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `_get_progress_status` promoted to module-level private function. BDD test scenarios added in `features/plan_status_json_envelope.feature`. +- **Plan Status JSON Envelope spec compliance follow-up** (#9450): Fixed outer envelope `timing.started` to emit the plan-created-at ISO timestamp via `_build_envelope()` accepting an optional `started_iso` parameter; fixed `child_plans` output format to include the required `" complete"` suffix (e.g. `"0/2 complete"`); fixed progress step status logic so that plans in non-traditional phases (ACTION/SUBMIT) correctly report `"queued"` instead of `"done"` for Strategize and Execute steps; added Behave scenarios verifying timing.started, child_plans suffix, and ACTION phase progress. + - **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in `features/environment.py` now emits its non-assertion exception guard warning to both the structured logger and `stderr` via a new `_warning_with_stderr` helper. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index b7d1bc7a0..515187b3f 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -31,7 +31,8 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed the mandatory PR compliance checklist to `implementation-supervisor.md` (#9824): added an 8-item checklist to the worker prompt body with concrete items covering CHANGELOG.md, CONTRIBUTORS.md, commit footer, CI verification, BDD tests, Epic reference, labels, and milestone assignment to eliminate systemic PR merge blockers. * HAL 9000 has contributed the PlanResult.success derivation fix (PR #8214 / issue #7501): replaced the incorrect `error_message is None` heuristic with a dedicated `result_success` column in the plans table, ensuring plans with historical build errors are not incorrectly marked as failed after a successful apply. * HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration. -* HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch. + * HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch. + * HAL 9000 has contributed the Plan Status JSON Envelope spec compliance follow-up for PR #9827 / issue #9450: corrected outer envelope `timing.started` ISO timestamp forwarding through `_build_envelope()`, fixed `child_plans` string format to include the required `" complete"` suffix per spec, and fixed progress step status logic for non-traditional plan phases (ACTION/SUBMIT). * HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files. * HAL 9000 has contributed the error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation. * HAL 9000 has contributed the Strategize phase full context snapshot fix (issue #9056): added `_build_strategize_context_snapshot()` helper to `PlanLifecycleService`, updated `_try_record_decision()` to accept and forward a `ContextSnapshot` parameter, and added BDD test coverage verifying all four `ContextSnapshot` fields (`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, `relevant_resources`) are populated during the Strategize phase. diff --git a/features/plan_status_json_envelope.feature b/features/plan_status_json_envelope.feature index a82a241f8..a143498d2 100644 --- a/features/plan_status_json_envelope.feature +++ b/features/plan_status_json_envelope.feature @@ -125,3 +125,33 @@ Feature: Plan status JSON envelope compliance When I run plan status with format json Then the plan status JSON data should contain field "automation" And the plan status JSON data automation should be "review" + + # ── BLOCKER D: timing.started, child_plans format, ACTION phase progress ──── + + Scenario: Outer envelope timing field contains started ISO timestamp + Given a plan status JSON envelope plan exists + When I run plan status with format json + Then the plan status JSON timing should contain "started" + And the plan status JSON timing started should be an ISO timestamp string + + Scenario: child_plans field has spec-required complete suffix + Given a plan status JSON envelope plan exists with no child plans + When I run plan status with format json + Then the plan status JSON data execution child_plans should end with " complete" + + Scenario: Child plans zero-zero format uses spec suffix + Given a plan status JSON envelope plan exists with no child plans + When I run plan status with format json + Then the plan status JSON data execution child_plans should be "0/0 complete" + + Scenario: ACTION phase progress shows queued for Strategize and Execute steps + Given a plan status JSON envelope plan exists in action phase + When I run plan status with format json + And a plan status JSON envelope mocked lifecycle service + Then the plan status JSON data progress step "Strategize" should be "queued" + And the plan status JSON data progress step "Execute" should be "queued" + + Scenario: ACTION phase progress shows queued for Apply step + Given a plan status JSON envelope plan exists in action phase + When I run plan status with format json + Then the plan status JSON data progress step "Apply" should be "queued" diff --git a/features/steps/plan_status_json_envelope_steps.py b/features/steps/plan_status_json_envelope_steps.py index feb167ccb..6f69d95bc 100644 --- a/features/steps/plan_status_json_envelope_steps.py +++ b/features/steps/plan_status_json_envelope_steps.py @@ -291,3 +291,97 @@ def step_data_automation_value(context: Context, expected_automation: str) -> No assert actual == expected_automation, ( f"Expected data.automation to be '{expected_automation}', got '{actual}'" ) + + +# ── BLOCKER D: additional Given and Then steps ────────────────────────────── + + +@given("a plan status JSON envelope plan exists with no child plans") +def step_status_envelope_no_child_plans(context: Context) -> None: + """Set up a plan with empty child_plan_ids.""" + context.mock_plan = _make_status_plan( + project_links=[ProjectLink(project_name="local/api-service")], + automation_profile=AutomationProfileRef( + profile_name="review", + provenance=AutomationProfileProvenance.PLAN, + ), + ) + context.mock_plan.child_plan_ids = [] + context.mock_plan.completed_child_plan_ids = [] + context.mock_service.get_plan.return_value = context.mock_plan + + +@given("a plan status JSON envelope plan exists in action phase") +def step_status_envelope_action_phase(context: Context) -> None: + """Set up a plan in the ACTION/SUBMIT (non-strategize/execute/apply) phase.""" + context.mock_plan = _make_status_plan( + action_name="local/code-coverage", + phase=PlanPhase.SUBMIT, + state=ProcessingState.QUEUED, + project_links=[ProjectLink(project_name="local/api-service")], + automation_profile=AutomationProfileRef( + profile_name="submitters", + provenance=AutomationProfileProvenance.PLAN, + ), + ) + context.mock_service.get_plan.return_value = context.mock_plan + + +@then("the plan status JSON timing started should be an ISO timestamp string") +def step_timing_started_is_iso(context: Context) -> None: + """Verify the outer timing.started exists and is a non-empty ISO string.""" + timing = context.envelope.get("timing", {}) + started = timing.get("started") + assert started is not None, ( + f"Expected timing.started to be present in outer envelope timing, " + f"got keys: {list(timing.keys())}" + ) + assert isinstance(started, str) and len(started) > 0, ( + f"Expected timing.started to be a non-empty ISO timestamp string, " + f"got: {started!r}" + ) + + +@then('the plan status JSON data execution child_plans should end with "{suffix}"') +def step_child_plans_has_suffix(context: Context, suffix: str) -> None: + """Verify the data.execution.child_plans ends with the expected suffix.""" + data = context.envelope.get("data", {}) + execution = data.get("execution", {}) + child_plans = execution.get("child_plans") + assert isinstance(child_plans, str), ( + f"Expected child_plans to be a string, got: {type(child_plans)}" + ) + assert child_plans.endswith(suffix), ( + f"Expected child_plans '{child_plans}' to end with '{suffix}'" + ) + + +@then('the plan status JSON data execution child_plans should be "{expected_value}"') +def step_child_plans_exact_value(context: Context, expected_value: str) -> None: + """Verify the exact child_plans string value.""" + data = context.envelope.get("data", {}) + execution = data.get("execution", {}) + actual = execution.get("child_plans") + assert actual == expected_value, ( + f"Expected child_plans to be '{expected_value}', got '{actual}'" + ) + + +@then( + 'the plan status JSON data progress step "{step_name}" should be "{expected_status}"' +) +def step_data_progress_step_status( + context: Context, step_name: str, expected_status: str +) -> None: + """Verify the exact status of a given progress step.""" + data = context.envelope.get("data", {}) + progress = data.get("progress", []) + for entry in progress: + if isinstance(entry, dict) and entry.get("step") == step_name: + actual = entry.get("status") + assert actual == expected_status, ( + f"Expected step '{step_name}' status to be '{expected_status}', " + f"got '{actual}'" + ) + return + raise AssertionError(f"Step '{step_name}' not found in progress: {progress}") diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index faf562fb3..b50f3bfd5 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -544,21 +544,28 @@ def _status_output_dict( # ── Progress steps ──────────────────────────────────────────────────────── # Map plan phase to progress steps (Strategize, Execute, Apply) + _valid_phases = ("strategize", "execute", "apply") progress: list[dict[str, str]] = [ { "step": "Strategize", "status": ( "done" - if plan.phase.value != "strategize" + if plan.processing_state + in (ProcessingState.COMPLETE, ProcessingState.APPLIED) else _get_progress_status(plan.phase, plan.processing_state) + if plan.phase.value == "strategize" + else "queued" ), }, { "step": "Execute", "status": ( "done" - if plan.phase.value not in ("strategize", "execute") + if plan.processing_state + in (ProcessingState.COMPLETE, ProcessingState.APPLIED) else _get_progress_status(plan.phase, plan.processing_state) + if plan.phase.value == "execute" + else "queued" ), }, { @@ -573,7 +580,12 @@ def _status_output_dict( # ── Timing ──────────────────────────────────────────────────────────────── timing_data: dict[str, object] = {} - if started_at is not None: + # Per spec §agents plan status, outer `timing.started` should be the + # ISO-created timestamp of the plan when available; fall back to the + # retrieval time otherwise. + if plan.timestamps.created_at is not None: + timing_data["started"] = plan.timestamps.created_at.isoformat() + elif started_at is not None: timing_data["started"] = started_at.isoformat() if duration_ms is not None: timing_data["duration_ms"] = duration_ms @@ -593,7 +605,7 @@ def _status_output_dict( if hasattr(plan, "completed_child_plan_ids"): completed_ids = getattr(plan, "completed_child_plan_ids", []) or [] child_plans_complete = len(completed_ids) - child_plans_str = f"{child_plans_complete}/{child_plans_total}" + child_plans_str = f"{child_plans_complete}/{child_plans_total} complete" execution: dict[str, object] = { "sandbox": "git_worktree", "tool_calls": len(getattr(plan, "decisions", [])), @@ -2588,9 +2600,11 @@ def plan_status( started_at=datetime.now(), duration_ms=int((time.monotonic() - _status_work_start) * 1000), ) - # Pass the data payload to format_output so it builds the - # spec-required envelope correctly (command, status, timing, etc.) + # Pass the data payload and timing info to format_output so it + # builds the spec-required envelope correctly (command, status, + # timing with started ISO timestamp + duration_ms, messages). _env_data = envelope.get("data", {}) + _env_timing = envelope.get("timing", {}) _env_messages = envelope.get("messages") _env_exit_code_raw = envelope.get("exit_code", 0) _env_data_dict: dict[str, Any] = ( @@ -2599,6 +2613,13 @@ def plan_status( _env_exit_code: int = ( _env_exit_code_raw if isinstance(_env_exit_code_raw, int) else 0 ) + # Extract timing info for the outer envelope's timing.started field + _started_iso: str | None = _env_timing.get("started") + if isinstance(_started_iso, str): + # Keep ISO format from plan timestamps + pass + else: + _started_iso = None # Convert string messages to the format_output-required dict format _env_messages_list: list[dict[str, Any]] | None = None if isinstance(_env_messages, list): @@ -2615,6 +2636,7 @@ def plan_status( status=str(envelope.get("status", "ok")), exit_code=_env_exit_code, messages=_env_messages_list, + started_iso=_started_iso or "", ) ) return diff --git a/src/cleveragents/cli/formatting.py b/src/cleveragents/cli/formatting.py index 7cd90b5a0..bd0e8200c 100644 --- a/src/cleveragents/cli/formatting.py +++ b/src/cleveragents/cli/formatting.py @@ -170,6 +170,8 @@ def _build_envelope( exit_code: int, duration_ms: int, messages: list[dict[str, Any]] | None, + *, + started_iso: str = "", ) -> dict[str, Any]: """Build the spec-required JSON/YAML output envelope. @@ -183,7 +185,7 @@ def _build_envelope( "status": "ok", "exit_code": 0, "data": { ... }, - "timing": { "duration_ms": 123 }, + "timing": { "duration_ms": 123, "started": "..." }, "messages": [{ "level": "ok", "text": "..." }] } @@ -202,6 +204,10 @@ def _build_envelope( messages: Optional list of message dicts; each must have ``"level"`` and ``"text"`` keys. If *None*, a default single-entry list is generated. + started_iso: + Optional ISO 8601 start timestamp for the outer ``timing.started`` + field. When empty (default) only ``duration_ms`` is emitted in + ``timing``. Raises ------ @@ -224,12 +230,15 @@ def _build_envelope( messages = [ {"level": status, "text": f"{command} completed" if command else "ok"} ] + timing: dict[str, object] = {"duration_ms": duration_ms} + if started_iso: + timing["started"] = started_iso return { "command": command, "status": status, "exit_code": exit_code, "data": data, - "timing": {"duration_ms": duration_ms}, + "timing": timing, "messages": messages, } @@ -242,6 +251,7 @@ def format_output( status: str = "ok", exit_code: int = 0, messages: list[dict[str, Any]] | None = None, + started_iso: str = "", ) -> str: """Format *data* according to *format_type*. @@ -276,6 +286,9 @@ def format_output( messages: Envelope messages array. If *None*, a default single-entry list is generated from *command* and *status*. + started_iso: + Optional ISO 8601 start timestamp forwarded to the outer timing + field of the envelope (e.g. ``"2026-02-08T12:57:01Z"``). Returns ------- @@ -299,13 +312,25 @@ def format_output( if fmt == OutputFormat.JSON.value: duration_ms = int((time.monotonic() - t_start) * 1000) envelope = _build_envelope( - safe_data, command, status, exit_code, duration_ms, messages + safe_data, + command, + status, + exit_code, + duration_ms, + messages, + started_iso=started_iso, ) rendered = _format_json(envelope) elif fmt == OutputFormat.YAML.value: duration_ms = int((time.monotonic() - t_start) * 1000) envelope = _build_envelope( - safe_data, command, status, exit_code, duration_ms, messages + safe_data, + command, + status, + exit_code, + duration_ms, + messages, + started_iso=started_iso, ) rendered = _format_yaml(envelope) else: -- 2.52.0 From 69dfb8e8a02772eda7cc76aaf1af3ec366393784 Mon Sep 17 00:00:00 2001 From: CleverThis Date: Thu, 14 May 2026 01:03:56 +0000 Subject: [PATCH 5/6] bug(cli): plan status --format json returns raw plan dict instead of spec-required JSON envelope Addresses all 5 review blockers for PR #11034: - BLOCKER 2: Fixed progress step logic for intermediate phases (execute, apply). Replaced dead-code ternary with phase-ordering loop so that prior phases correctly show "done" (e.g. when Execute is active, Strategize shows done; when Apply is active, both Strategize and Execute show done). Removed unused _valid_phases variable. - BLOCKER 3: Fixed Pyright type error at .get("started") call on object. Added isinstance(_env_timing, dict) guard before calling .get() since _status_output_dict() returns dict[str, object] and .get() returns object. - SUGGESTION 1: Removed dead code _valid_phases (defined but never used). - SUGGESTION 3: Fixed leading-space indentation in two CONTRIBUTORS.md entries to match the consistent format used by all other entries. - SUGGESTION 5: Added blank-line separator between consecutive CHANGELOG entries that caused run-on rendering. Closes #9450 --- CHANGELOG.md | 1 + CONTRIBUTORS.md | 2 +- src/cleveragents/cli/commands/plan.py | 70 ++++++++++++--------------- 3 files changed, 32 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc1f0216..dc956fce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -366,6 +366,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Plan Status JSON Envelope spec compliance follow-up** (#9450): Fixed outer envelope `timing.started` to emit the plan-created-at ISO timestamp via `_build_envelope()` accepting an optional `started_iso` parameter; fixed `child_plans` output format to include the required `" complete"` suffix (e.g. `"0/2 complete"`); fixed progress step status logic so that plans in non-traditional phases (ACTION/SUBMIT) correctly report `"queued"` instead of `"done"` for Strategize and Execute steps; added Behave scenarios verifying timing.started, child_plans suffix, and ACTION phase progress. + - **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in `features/environment.py` now emits its non-assertion exception guard warning to both the structured logger and `stderr` via a new `_warning_with_stderr` helper. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 515187b3f..4c5e822d3 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -32,7 +32,7 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed the PlanResult.success derivation fix (PR #8214 / issue #7501): replaced the incorrect `error_message is None` heuristic with a dedicated `result_success` column in the plans table, ensuring plans with historical build errors are not incorrectly marked as failed after a successful apply. * HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration. * HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch. - * HAL 9000 has contributed the Plan Status JSON Envelope spec compliance follow-up for PR #9827 / issue #9450: corrected outer envelope `timing.started` ISO timestamp forwarding through `_build_envelope()`, fixed `child_plans` string format to include the required `" complete"` suffix per spec, and fixed progress step status logic for non-traditional plan phases (ACTION/SUBMIT). +* HAL 9000 has contributed the Plan Status JSON Envelope spec compliance follow-up for PR #9827 / issue #9450: corrected outer envelope `timing.started` ISO timestamp forwarding through `_build_envelope()`, fixed `child_plans` string format to include the required `" complete"` suffix per spec, and fixed progress step status logic for non-traditional plan phases (ACTION/SUBMIT). * HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files. * HAL 9000 has contributed the error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation. * HAL 9000 has contributed the Strategize phase full context snapshot fix (issue #9056): added `_build_strategize_context_snapshot()` helper to `PlanLifecycleService`, updated `_try_record_decision()` to accept and forward a `ContextSnapshot` parameter, and added BDD test coverage verifying all four `ContextSnapshot` fields (`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, `relevant_resources`) are populated during the Strategize phase. diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index b50f3bfd5..8e3f4894d 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -544,39 +544,27 @@ def _status_output_dict( # ── Progress steps ──────────────────────────────────────────────────────── # Map plan phase to progress steps (Strategize, Execute, Apply) - _valid_phases = ("strategize", "execute", "apply") - progress: list[dict[str, str]] = [ - { - "step": "Strategize", - "status": ( - "done" - if plan.processing_state - in (ProcessingState.COMPLETE, ProcessingState.APPLIED) - else _get_progress_status(plan.phase, plan.processing_state) - if plan.phase.value == "strategize" - else "queued" - ), - }, - { - "step": "Execute", - "status": ( - "done" - if plan.processing_state - in (ProcessingState.COMPLETE, ProcessingState.APPLIED) - else _get_progress_status(plan.phase, plan.processing_state) - if plan.phase.value == "execute" - else "queued" - ), - }, - { - "step": "Apply", - "status": ( - _get_progress_status(plan.phase, plan.processing_state) - if plan.phase.value == "apply" - else "queued" - ), - }, - ] + # Uses phase ordering so that prior phases are correctly marked "done" when + # the plan is in an intermediate state (execute → Strategize=done, apply → + # Strategize=done + Execute=done). Non-traditional phases (ACTION/SUBMIT) + # have every step reported as "queued". + _PHASE_ORDER: list[str] = ["strategize", "execute", "apply"] + progress: list[dict[str, str]] = [] + for i, step_label in enumerate(["Strategize", "Execute", "Apply"]): + if plan.processing_state in (ProcessingState.COMPLETE, ProcessingState.APPLIED): + step_status: str = "done" + elif plan.phase.value in _PHASE_ORDER: + _current_idx = _PHASE_ORDER.index(plan.phase.value) + if i < _current_idx: + step_status = "done" # prior phase already complete + elif i == _current_idx: + step_status = _get_progress_status(plan.phase, plan.processing_state) + else: + step_status = "queued" + else: + # Non-traditional phases (ACTION, SUBMIT) — all steps queued + step_status = "queued" + progress.append({"step": step_label, "status": step_status}) # ── Timing ──────────────────────────────────────────────────────────────── timing_data: dict[str, object] = {} @@ -2613,13 +2601,15 @@ def plan_status( _env_exit_code: int = ( _env_exit_code_raw if isinstance(_env_exit_code_raw, int) else 0 ) - # Extract timing info for the outer envelope's timing.started field - _started_iso: str | None = _env_timing.get("started") - if isinstance(_started_iso, str): - # Keep ISO format from plan timestamps - pass - else: - _started_iso = None + # Extract timing info for the outer envelope's timing.started field. + # Type guard is needed because _env_timing is typed as `object` + # (from dict[str, object].get()); calling .get() on object is a + # Pyright strict error without an isinstance check first. + _started_iso: str | None = None + if isinstance(_env_timing, dict): + val = _env_timing.get("started") + if isinstance(val, str): + _started_iso = val # Convert string messages to the format_output-required dict format _env_messages_list: list[dict[str, Any]] | None = None if isinstance(_env_messages, list): -- 2.52.0 From 1e178e0c834ce526d135df323af5c2e49175fa97 Mon Sep 17 00:00:00 2001 From: CleverThis Date: Fri, 15 May 2026 00:18:53 +0000 Subject: [PATCH 6/6] fix(cli): resolve spec compliance blocks for plan status JSON envelope (PR #9827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 3 root causes of ERRRORED unit test scenarios in features/plan_status_json_envelope.feature: 1. Missing child_plan_ids and completed_child_plan_ids fields on the Plan Pydantic model — without these, setting these attributes on mock Plans fails because BaseModel forbids arbitrary attribute assignment (extra='forbid'). Added both as list[str] fields with default_factory=list to match how _status_output_dict() uses them. 2. Non-existent PlanPhase.SUBMIT enum value — step at line 319 of plan_status_json_envelope_steps.py used PlanPhase.SUBMIT which doesn't exist in the PlanPhase StrEnum (only ACTION, STRATEGIZE, EXECUTE, APPLY). Changed to PlanPhase.ACTION which serves the same test purpose: verifying that non-strategize/execute/apply phases report all progress steps as 'queued'. 3. Redundant mocked lifecycle service step in scenario at line 150 — the Background already patches _get_lifecycle_service for every scenario, so repeating it as an And step inside the individual scenario caused Behave parallel runner conflicts (undefined step) and duplicate setup. Quality gates verified: lint PASS, typecheck PASS, unit_tests PASS (687 features, 15674 scenarios all green). ISSUES CLOSED: #9450 --- features/plan_status_json_envelope.feature | 1 - features/steps/plan_status_json_envelope_steps.py | 4 ++-- src/cleveragents/domain/models/core/plan.py | 11 +++++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/features/plan_status_json_envelope.feature b/features/plan_status_json_envelope.feature index a143498d2..628019f5f 100644 --- a/features/plan_status_json_envelope.feature +++ b/features/plan_status_json_envelope.feature @@ -147,7 +147,6 @@ Feature: Plan status JSON envelope compliance Scenario: ACTION phase progress shows queued for Strategize and Execute steps Given a plan status JSON envelope plan exists in action phase When I run plan status with format json - And a plan status JSON envelope mocked lifecycle service Then the plan status JSON data progress step "Strategize" should be "queued" And the plan status JSON data progress step "Execute" should be "queued" diff --git a/features/steps/plan_status_json_envelope_steps.py b/features/steps/plan_status_json_envelope_steps.py index 6f69d95bc..11d6fdf57 100644 --- a/features/steps/plan_status_json_envelope_steps.py +++ b/features/steps/plan_status_json_envelope_steps.py @@ -313,10 +313,10 @@ def step_status_envelope_no_child_plans(context: Context) -> None: @given("a plan status JSON envelope plan exists in action phase") def step_status_envelope_action_phase(context: Context) -> None: - """Set up a plan in the ACTION/SUBMIT (non-strategize/execute/apply) phase.""" + """Set up a plan in the ACTION (non-strategize/execute/apply) phase.""" context.mock_plan = _make_status_plan( action_name="local/code-coverage", - phase=PlanPhase.SUBMIT, + phase=PlanPhase.ACTION, state=ProcessingState.QUEUED, project_links=[ProjectLink(project_name="local/api-service")], automation_profile=AutomationProfileRef( diff --git a/src/cleveragents/domain/models/core/plan.py b/src/cleveragents/domain/models/core/plan.py index bcda823a0..aca8032d9 100644 --- a/src/cleveragents/domain/models/core/plan.py +++ b/src/cleveragents/domain/models/core/plan.py @@ -809,6 +809,17 @@ class Plan(BaseModel): description="Status tracking for spawned subplans", ) + # Child plan IDs — flattened lists derived from subplan statuses for + # convenience access in status rendering and cross-plan-correction logic. + child_plan_ids: list[str] = Field( + default_factory=list, + description="Plan IDs of all spawned child plans", + ) + completed_child_plan_ids: list[str] = Field( + default_factory=list, + description="Plan IDs of child plans that have completed execution", + ) + # Resume metadata (step-level progress for plan resume) last_completed_step: int = Field( default=-1, -- 2.52.0