Commit f5d404870dc for woocommerce

commit f5d404870dcd366008b4ee013dc964e4f368185f
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date:   Thu Nov 12 15:40:08 2026 +0100

    Agent readiness: ability to read the report (#70436)

    * Agent readiness: ability to read the report

    Part of #70436.

    * Agent readiness: the read ability says when the queued check has stalled

    GET wc/v3/agent-readiness answers scan_stalled, but the ability answered
    only scan_pending, so an agent on a store whose scheduled actions have
    stopped would wait for a check that never starts. The ability now returns
    scan_stalled too, with the schema and a test.

    * Agent readiness: register the read ability through AbilitiesLoader

    Trunk now requires WooCommerce abilities to be registered by
    AbilitiesLoader with the Ability class, which converts the input to the
    schema types. ReportAbility is the definition class; it has no name
    while the feature is disabled, so the loader skips it. The arguments
    stay in ReadinessAbilities::registration_args().

    * Agent readiness: mark the changelog entry as a patch

    A Comment-only changelog entry must be a patch; changelogger validate rejects it as minor.

    * Agent readiness: import Ability from its Internal namespace in the abilities test

    Trunk moved the class in #70706.

    * Agent readiness: list the read ability before the shipping and tax abilities

    Trunk now appends its own abilities after TaxRatesQuery, so adding this one at
    the same spot conflicted with it.

diff --git a/plugins/woocommerce/changelog/add-agent-readiness-read-ability b/plugins/woocommerce/changelog/add-agent-readiness-read-ability
new file mode 100644
index 00000000000..c90086ab12e
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-read-ability
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Agent readiness: ability that reads the report over MCP, behind an experimental flag; nothing ships to merchants.
+
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index 703bcecfe4f..c662515471b 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -30,6 +30,7 @@ use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewsQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewUpdate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ShippingZonesQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\TaxRatesQuery;
+use Automattic\WooCommerce\Internal\AgentReadiness\ReportAbility;

 defined( 'ABSPATH' ) || exit;

@@ -68,6 +69,7 @@ class AbilitiesLoader {
 		ProductCreate::class,
 		ProductDelete::class,
 		ProductUpdate::class,
+		ReportAbility::class,
 		ShippingZonesQuery::class,
 		TaxRatesQuery::class,
 		CustomersQuery::class,
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
index b83db495150..c803e74339f 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
@@ -246,6 +246,37 @@ class AgentReadiness {
 		return is_array( $report ) ? $report : null;
 	}

+	/**
+	 * Store checks as they stand now: the store conditions read live, then the
+	 * store output checks from the stored report, since those run only in the scan.
+	 * Output checks are always recommended, whatever the stored report says.
+	 *
+	 * @param array<string, mixed>|null                                                                                                               $report Stored report.
+	 * @param array<int, array{id:string,ok:bool|null,blocks:string,level:string,name:string,label:string,why:string,fix:string,fix_url:string}>|null $live   Store conditions already read, or null to read them.
+	 * @return array<int, array{id:string,ok:bool|null,blocks:string,level:string,name:string,label:string,why:string,fix:string,fix_url:string}>
+	 */
+	public function get_store_checks( ?array $report, ?array $live = null ): array {
+		$checks   = $live ?? $this->store_conditions->all();
+		$live_ids = array_column( $checks, 'id' );
+		foreach ( is_array( $report['store_checks'] ?? null ) ? $report['store_checks'] : array() as $check ) {
+			if ( ! is_array( $check ) || ! isset( $check['id'], $check['name'] ) || in_array( $check['id'], $live_ids, true ) ) {
+				continue;
+			}
+			$checks[] = array(
+				'id'      => (string) $check['id'],
+				'ok'      => isset( $check['ok'] ) ? (bool) $check['ok'] : null,
+				'blocks'  => (string) ( $check['blocks'] ?? '' ),
+				'level'   => StoreConditions::LEVEL_RECOMMENDED,
+				'name'    => (string) $check['name'],
+				'label'   => (string) ( $check['label'] ?? '' ),
+				'why'     => (string) ( $check['why'] ?? '' ),
+				'fix'     => (string) ( $check['fix'] ?? '' ),
+				'fix_url' => (string) ( $check['fix_url'] ?? '' ),
+			);
+		}
+		return $checks;
+	}
+
 	/**
 	 * Run the report and store it. Not autoloaded: only the readiness screens read it.
 	 *
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
index fb9f207f604..4188bfda4a3 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
@@ -148,7 +148,7 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
 		$this->render_switch( $checks, count( $failing ) );
 		$this->render_visibility( $report );
 		$this->render_status( $checks, $this->ucp_switch->status( $failing ), count( $failing ) );
-		$this->render_readiness( self::scanned_checks( $report, $checks ), (bool) ( array_column( $checks, 'ok', 'id' )['launched'] ?? true ) );
+		$this->render_readiness( array_slice( $this->agent_readiness->get_store_checks( $report, $checks ), count( $checks ) ), (bool) ( array_column( $checks, 'ok', 'id' )['launched'] ?? true ) );

 		echo '<div class="wc-agentic-commerce-card wc-agentic-commerce-policies">';
 		parent::output();
@@ -159,33 +159,6 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
 		echo '</div>';
 	}

-	/**
-	 * Store checks the last scan ran that aren't read live here: the ones that
-	 * read what the store returns. Never required, so they don't affect the switch.
-	 *
-	 * @param array<string, mixed>|null    $report Stored report.
-	 * @param array<int, array{id:string}> $live   Store conditions read now.
-	 * @return array<int, array{id:string,level:string,ok:bool|null,name:string,fix:string,fix_url:string}>
-	 */
-	private static function scanned_checks( ?array $report, array $live ): array {
-		$live_ids = array_column( $live, 'id' );
-		$scanned  = array();
-		foreach ( is_array( $report['store_checks'] ?? null ) ? $report['store_checks'] : array() as $check ) {
-			if ( ! is_array( $check ) || ! isset( $check['id'], $check['name'] ) || in_array( $check['id'], $live_ids, true ) ) {
-				continue;
-			}
-			$scanned[] = array(
-				'id'      => (string) $check['id'],
-				'level'   => StoreConditions::LEVEL_RECOMMENDED,
-				'ok'      => isset( $check['ok'] ) ? (bool) $check['ok'] : null,
-				'name'    => (string) $check['name'],
-				'fix'     => (string) ( $check['fix'] ?? '' ),
-				'fix_url' => (string) ( $check['fix_url'] ?? '' ),
-			);
-		}
-		return $scanned;
-	}
-
 	/**
 	 * Accept only published pages from a page dropdown on this tab, or an empty
 	 * value to clear it. A stored page that isn't published is listed in its own
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/README.md b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
index 58ffe2d6e4d..f73e9794bdf 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/README.md
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
@@ -126,12 +126,18 @@ Per check, `channels` maps a channel id to the feed field and the effect, the sa

 Product changes don't update the stored report: a fixed product leaves it at the next daily scan or "Run check". On a store with 11,000 products, a scan (stopping at 5,000) took about 4 seconds and 144 MB.

-`GET wc/v3/agent-readiness` returns `{ "report": <report or null>, "scan_pending": <bool>, "scan_stalled": <bool> }`; `scan_stalled` is true while a queued scan waits on a stalled Action Scheduler. Both routes need `manage_woocommerce` and exist only while the feature is enabled.
+`GET wc/v3/agent-readiness` returns `{ "report": <report or null>, "scan_pending": <bool>, "scan_stalled": <bool> }`, with `store_checks` read now; `scan_stalled` is true while a queued scan waits on a stalled Action Scheduler. Both routes need `manage_woocommerce` and exist only while the feature is enabled.

 The same shape is meant for every consumer: this Status tab, an outside scan of a store, and extensions that rank the findings by sales or offer fixes. Check and channel ids are stable keys.

 The scan walks published simple and variable products shown in the shop catalog in batches of 200, most recently modified first, primes meta, terms, images and variations per batch, and stops at 5,000 products.

+## Abilities
+
+While the feature is enabled, `AbilitiesLoader` registers these abilities in the WordPress Abilities API with the `Ability` class, which converts their input to the schema types. Their definition classes in this directory read the arguments from `ReadinessAbilities::registration_args()`. They show in REST and to MCP clients, for users with `manage_woocommerce`:
+
+- `woocommerce/get-agent-readiness` and `GET wc/v3/agent-readiness` both return `{ report, scan_pending, scan_stalled }` with `store_checks` read now (`AgentReadiness::get_store_checks()`): settings changed since the scan show their current state, and store output checks come from the stored report.
+
 ## Agentic commerce settings page

 WooCommerce > Settings > Agentic commerce (`AgenticCommerceSettings`, `AgenticCommerceSettingsPage`) shows while the feature is enabled. It follows the WOOPRD-3796 design, one card per part:
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessAbilities.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessAbilities.php
new file mode 100644
index 00000000000..7e1ef518326
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessAbilities.php
@@ -0,0 +1,256 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Abilities that let an agent connected to the store (over MCP) read the
+ * readiness report. Registered only while the agent_readiness feature is
+ * enabled, and only for users who can manage WooCommerce.
+ *
+ * AbilitiesLoader registers them through the definition classes in this directory,
+ * which read their arguments from registration_args().
+ *
+ * @since 11.4.0
+ */
+class ReadinessAbilities {
+
+	public const GET_REPORT = 'woocommerce/get-agent-readiness';
+
+	/**
+	 * Features controller.
+	 *
+	 * @var FeaturesController
+	 */
+	private FeaturesController $features_controller;
+
+	/**
+	 * Readiness service.
+	 *
+	 * @var AgentReadiness
+	 */
+	private AgentReadiness $agent_readiness;
+
+	/**
+	 * Background scan.
+	 *
+	 * @var ScheduledScan
+	 */
+	private ScheduledScan $scheduled_scan;
+
+	/**
+	 * Initialize dependencies.
+	 *
+	 * @internal
+	 *
+	 * @param FeaturesController $features_controller Features controller.
+	 * @param AgentReadiness     $agent_readiness     Readiness service.
+	 * @param ScheduledScan      $scheduled_scan      Background scan.
+	 */
+	final public function init( FeaturesController $features_controller, AgentReadiness $agent_readiness, ScheduledScan $scheduled_scan ): void {
+		$this->features_controller = $features_controller;
+		$this->agent_readiness     = $agent_readiness;
+		$this->scheduled_scan      = $scheduled_scan;
+	}
+
+	/**
+	 * Whether the abilities are registered: while the feature is enabled.
+	 *
+	 * @internal
+	 */
+	public function is_enabled(): bool {
+		return $this->features_controller->feature_is_enabled( StatusTab::FEATURE_ID );
+	}
+
+	/**
+	 * Registration arguments of an ability, for the definition class AbilitiesLoader registers it with.
+	 *
+	 * @internal
+	 *
+	 * @param string $name Ability name.
+	 * @return array<string, mixed>
+	 */
+	public function registration_args( string $name ): array {
+		if ( self::GET_REPORT === $name ) {
+			return array(
+				'label'               => __( 'Get agent readiness', 'woocommerce' ),
+				'description'         => __( 'Read the last agent readiness check: store settings and pages that keep AI shopping agents from finding or buying products, and product problems per channel (ChatGPT, Google, Copilot), each with why it matters and what to change. Store settings are read now; product results come from the last background check.', 'woocommerce' ),
+				'category'            => 'woocommerce',
+				'input_schema'        => array(
+					'type'                 => 'object',
+					'properties'           => array(),
+					'additionalProperties' => false,
+					'default'              => array(),
+				),
+				'output_schema'       => self::report_schema(),
+				'execute_callback'    => array( $this, 'get_report' ),
+				'permission_callback' => array( $this, 'can_manage' ),
+				'meta'                => array(
+					'show_in_rest' => true,
+					'mcp'          => array(
+						'public' => true,
+						'type'   => 'tool',
+					),
+					'annotations'  => array(
+						'readonly'    => true,
+						'idempotent'  => true,
+						'destructive' => false,
+					),
+				),
+			);
+		}
+		return array();
+	}
+
+	/**
+	 * Whether the current user may use the abilities.
+	 *
+	 * @internal
+	 */
+	public function can_manage(): bool {
+		return current_user_can( 'manage_woocommerce' );
+	}
+
+	/**
+	 * The stored report with store checks as they stand now, whether a scan is pending, and whether it has stalled.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $input Ability input; this ability takes none.
+	 * @return array{report:array<string,mixed>|null,scan_pending:bool,scan_stalled:bool}
+	 */
+	public function get_report( $input = null ): array {
+		unset( $input );
+		$report = $this->agent_readiness->get_cached_report();
+		if ( null !== $report ) {
+			$report['store_checks'] = $this->agent_readiness->get_store_checks( $report );
+		}
+		$pending = $this->scheduled_scan->is_pending();
+		return array(
+			'report'       => $report,
+			'scan_pending' => $pending,
+			'scan_stalled' => $pending && $this->scheduled_scan->is_stalled(),
+		);
+	}
+
+	/**
+	 * Output schema of the report ability, following the report format in this directory's README.
+	 *
+	 * @return array<string, mixed>
+	 */
+	private static function report_schema(): array {
+		$channel_map = array(
+			'type'                 => 'object',
+			'description'          => __( 'Feed field and effect by channel id: not_listable when the channel requires the field, gaps when the product lists with a gap. Empty for checks about the product page.', 'woocommerce' ),
+			'additionalProperties' => array(
+				'type'       => 'object',
+				'properties' => array(
+					'field'  => array( 'type' => 'string' ),
+					'effect' => array(
+						'type' => 'string',
+						'enum' => array( ReadinessRule::EFFECT_NOT_LISTABLE, ReadinessRule::EFFECT_GAPS ),
+					),
+				),
+			),
+		);
+		return array(
+			'type'       => 'object',
+			'properties' => array(
+				'report'       => array(
+					'type'        => array( 'object', 'null' ),
+					'description' => __( 'The last agent readiness check, or null before the first one.', 'woocommerce' ),
+					'properties'  => array(
+						'generated_at'        => array(
+							'type'   => 'string',
+							'format' => 'date-time',
+						),
+						'products'            => array(
+							'type'        => 'integer',
+							'description' => __( 'Published simple and variable products checked.', 'woocommerce' ),
+						),
+						'hidden_from_catalog' => array(
+							'type'        => 'integer',
+							'description' => __( 'Products not checked because the merchant hid them from the shop catalog.', 'woocommerce' ),
+						),
+						'unsupported_types'   => array(
+							'type'        => 'integer',
+							'description' => __( 'Products not checked because agents don\'t support their type yet.', 'woocommerce' ),
+						),
+						'store_checks'        => array(
+							'type'  => 'array',
+							'items' => array(
+								'type'       => 'object',
+								'properties' => array(
+									'id'      => array( 'type' => 'string' ),
+									'ok'      => array(
+										'type'        => array( 'boolean', 'null' ),
+										'description' => __( 'True when the store passes, false when it fails, null when the check could not run.', 'woocommerce' ),
+									),
+									'blocks'  => array(
+										'type'        => 'string',
+										'description' => __( 'What a failure stops: listing (every product everywhere), ucp (agents using UCP) or checkout (agent orders).', 'woocommerce' ),
+									),
+									'level'   => array(
+										'type' => 'string',
+										'enum' => array( StoreConditions::LEVEL_REQUIRED, StoreConditions::LEVEL_RECOMMENDED ),
+									),
+									'name'    => array( 'type' => 'string' ),
+									'label'   => array( 'type' => 'string' ),
+									'why'     => array( 'type' => 'string' ),
+									'fix'     => array( 'type' => 'string' ),
+									'fix_url' => array( 'type' => 'string' ),
+								),
+							),
+						),
+						'channels'            => array(
+							'type'                 => 'object',
+							'additionalProperties' => array(
+								'type'       => 'object',
+								'properties' => array(
+									'name'         => array( 'type' => 'string' ),
+									'listable'     => array( 'type' => 'integer' ),
+									'not_listable' => array( 'type' => 'integer' ),
+									'with_gaps'    => array( 'type' => 'integer' ),
+									'complete'     => array( 'type' => 'integer' ),
+								),
+							),
+						),
+						'checks'              => array(
+							'type'  => 'array',
+							'items' => array(
+								'type'       => 'object',
+								'properties' => array(
+									'id'          => array( 'type' => 'string' ),
+									'label'       => array( 'type' => 'string' ),
+									'why'         => array( 'type' => 'string' ),
+									'fix'         => array( 'type' => 'string' ),
+									'channels'    => $channel_map,
+									'count'       => array( 'type' => 'integer' ),
+									'product_ids' => array(
+										'type'        => 'array',
+										'description' => __( 'Up to 10 failing products, most recently modified first.', 'woocommerce' ),
+										'items'       => array( 'type' => 'integer' ),
+									),
+									'fix_url'     => array( 'type' => 'string' ),
+								),
+							),
+						),
+						'truncated'           => array( 'type' => 'boolean' ),
+					),
+				),
+				'scan_pending' => array(
+					'type'        => 'boolean',
+					'description' => __( 'Whether a requested check has not finished yet.', 'woocommerce' ),
+				),
+				'scan_stalled' => array(
+					'type'        => 'boolean',
+					'description' => __( 'Whether the requested check has not started because scheduled actions are not running on the store. It stays queued and runs once they do; until then the report does not change.', 'woocommerce' ),
+				),
+			),
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php
index f7ff1f79a4e..fdd552e3b5b 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php
@@ -134,9 +134,13 @@ class ReadinessRestController extends RestApiControllerBase {
 	 * @return array{report:array<string,mixed>|null,scan_pending:bool,scan_stalled:bool}
 	 */
 	protected function get_status( WP_REST_Request $request ): array {
+		$report = $this->agent_readiness->get_cached_report();
+		if ( null !== $report ) {
+			$report['store_checks'] = $this->agent_readiness->get_store_checks( $report );
+		}
 		$pending = $this->scheduled_scan->is_pending();
 		return array(
-			'report'       => $this->agent_readiness->get_cached_report(),
+			'report'       => $report,
 			'scan_pending' => $pending,
 			'scan_stalled' => $pending && $this->scheduled_scan->is_stalled(),
 		);
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
new file mode 100644
index 00000000000..987c575d2bc
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
@@ -0,0 +1,35 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Definition of the ability that reads the readiness report, for AbilitiesLoader.
+ * No name while the feature is disabled, so the loader skips it.
+ *
+ * @since 11.4.0
+ */
+class ReportAbility implements AbilityDefinition {
+
+	/**
+	 * Ability name, or an empty string while the feature is disabled.
+	 *
+	 * @return string
+	 */
+	public static function get_name(): string {
+		return wc_get_container()->get( ReadinessAbilities::class )->is_enabled() ? ReadinessAbilities::GET_REPORT : '';
+	}
+
+	/**
+	 * Registration arguments.
+	 *
+	 * @return array
+	 */
+	public static function get_registration_args(): array {
+		return wc_get_container()->get( ReadinessAbilities::class )->registration_args( ReadinessAbilities::GET_REPORT );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessAbilitiesTest.php
new file mode 100644
index 00000000000..e9153a94cd0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessAbilitiesTest.php
@@ -0,0 +1,165 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\Abilities\Ability;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
+use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
+use Automattic\WooCommerce\Internal\AgentReadiness\ReadinessAbilities;
+use Automattic\WooCommerce\Internal\AgentReadiness\ScheduledScan;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the agent readiness abilities.
+ */
+class ReadinessAbilitiesTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ReadinessAbilities
+	 */
+	private $sut;
+
+	/**
+	 * Action counts to restore.
+	 *
+	 * @var array<string, int|null>
+	 */
+	private array $action_counts = array();
+
+	/**
+	 * Register the abilities with the feature on, as an administrator.
+	 */
+	public function setUp(): void {
+		global $wp_actions;
+		parent::setUp();
+		StoreOutputChecksTest::block_requests();
+		foreach ( array( 'init', 'wp_abilities_api_init', 'wp_abilities_api_categories_init' ) as $action ) {
+			$this->action_counts[ $action ] = $wp_actions[ $action ] ?? null;
+		}
+		// The Abilities API registry needs init to have fired.
+		$wp_actions['init'] = max( 1, (int) ( $wp_actions['init'] ?? 0 ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		if ( ! wp_has_ability_category( 'woocommerce' ) ) {
+			$category = static fn() => wp_register_ability_category(
+				'woocommerce',
+				array(
+					'label'       => 'WooCommerce',
+					'description' => 'WooCommerce.',
+				)
+			);
+			add_action( 'wp_abilities_api_categories_init', $category );
+			do_action( 'wp_abilities_api_categories_init' );
+			remove_action( 'wp_abilities_api_categories_init', $category );
+		}
+		update_option( 'woocommerce_feature_agent_readiness_enabled', 'yes' );
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+		$this->sut = wc_get_container()->get( ReadinessAbilities::class );
+		$this->register();
+	}
+
+	/**
+	 * Unregister the abilities and restore the action counts, which live outside the rollback.
+	 */
+	public function tearDown(): void {
+		global $wp_actions;
+		try {
+			foreach ( array( ReadinessAbilities::GET_REPORT ) as $name ) {
+				if ( wp_has_ability( $name ) ) {
+					wp_unregister_ability( $name );
+				}
+			}
+			foreach ( $this->action_counts as $action => $count ) {
+				if ( null === $count ) {
+					unset( $wp_actions[ $action ] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+				} else {
+					$wp_actions[ $action ] = $count; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+				}
+			}
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * Register this class's abilities again. Checking for one loads the registry
+	 * first, which runs the hooked registration once already.
+	 */
+	private function register(): void {
+		if ( wp_has_ability( ReadinessAbilities::GET_REPORT ) ) {
+			wp_unregister_ability( ReadinessAbilities::GET_REPORT );
+		}
+		$callback = array( AbilitiesLoader::class, 'register_abilities' );
+		add_action( 'wp_abilities_api_init', $callback );
+		do_action( 'wp_abilities_api_init' );
+		remove_action( 'wp_abilities_api_init', $callback );
+	}
+
+	/**
+	 * @testdox AbilitiesLoader registers the abilities with the Ability class, only while the feature is enabled.
+	 */
+	public function test_registered_only_with_feature(): void {
+		$this->assertTrue( wp_has_ability( ReadinessAbilities::GET_REPORT ) );
+		$this->assertInstanceOf( Ability::class, wp_get_ability( ReadinessAbilities::GET_REPORT ) );
+		$meta = wp_get_ability( ReadinessAbilities::GET_REPORT )->get_meta();
+		$this->assertTrue( $meta['mcp']['public'] );
+		$this->assertTrue( $meta['annotations']['readonly'] );
+
+		update_option( 'woocommerce_feature_agent_readiness_enabled', 'no' );
+		$this->register();
+		$this->assertFalse( wp_has_ability( ReadinessAbilities::GET_REPORT ) );
+	}
+
+	/**
+	 * @testdox Only users who can manage WooCommerce can read the report.
+	 */
+	public function test_permission(): void {
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
+		$this->assertTrue( wp_get_ability( ReadinessAbilities::GET_REPORT )->check_permissions() );
+
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+		$this->assertFalse( wp_get_ability( ReadinessAbilities::GET_REPORT )->check_permissions() );
+		$this->assertInstanceOf( \WP_Error::class, wp_get_ability( ReadinessAbilities::GET_REPORT )->execute() );
+	}
+
+	/**
+	 * @testdox Before the first scan the report is null; after one it matches the output schema, with store checks read now.
+	 */
+	public function test_report_matches_output_schema(): void {
+		delete_option( AgentReadiness::REPORT_OPTION );
+		$ability = wp_get_ability( ReadinessAbilities::GET_REPORT );
+		$this->assertNull( $ability->execute()['report'] );
+
+		WC_Helper_Product::create_simple_product();
+		wc_get_container()->get( AgentReadiness::class )->refresh_report();
+		update_option( 'woocommerce_enable_guest_checkout', 'no' );
+
+		$output = $ability->execute();
+
+		$this->assertTrue( rest_validate_value_from_schema( $output, $ability->get_output_schema() ) );
+		$store_checks = array_column( $output['report']['store_checks'], 'ok', 'id' );
+		$this->assertFalse( $store_checks['guest_checkout'], 'A setting changed after the scan shows its current state' );
+		$this->assertArrayHasKey( 'sitemap_lists_products', $store_checks, 'Store output checks come from the stored report' );
+		$this->assertContains( 'missing_brand', array_column( $output['report']['checks'], 'id' ) );
+		$this->assertFalse( $output['scan_pending'] );
+		$this->assertFalse( $output['scan_stalled'] );
+	}
+
+	/**
+	 * @testdox A queued check that has stalled is reported as scan_stalled, so an agent stops waiting for it.
+	 */
+	public function test_report_says_when_the_scan_stalled(): void {
+		global $wpdb;
+		as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
+		$wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}actionscheduler_actions SET last_attempt_gmt = %s WHERE status = 'complete'", gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) ) );
+		as_schedule_single_action( time() - ScheduledScan::STALLED_AFTER - 60, ScheduledScan::RUN_NOW_HOOK, array(), ScheduledScan::GROUP );
+
+		$output = wp_get_ability( ReadinessAbilities::GET_REPORT )->execute();
+
+		$this->assertTrue( $output['scan_pending'] );
+		$this->assertTrue( $output['scan_stalled'] );
+		as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php
index 2e0ca7343de..ffd2e57bc2a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php
@@ -60,6 +60,20 @@ class ReadinessRestControllerTest extends WC_REST_Unit_Test_Case {
 		$this->assertSame( $report['generated_at'], $response->get_data()['report']['generated_at'] );
 	}

+	/**
+	 * @testdox GET reads setup checks now, so a setting changed after the scan shows its current state.
+	 */
+	public function test_get_reads_store_checks_now(): void {
+		StoreConditionsTest::make_store_ready( $this );
+		wc_get_container()->get( AgentReadiness::class )->refresh_report();
+		update_option( 'woocommerce_enable_guest_checkout', 'no' );
+
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/agent-readiness' ) );
+		$checks   = array_column( $response->get_data()['report']['store_checks'], 'ok', 'id' );
+
+		$this->assertFalse( $checks['guest_checkout'] );
+	}
+
 	/**
 	 * @testdox POST scan queues a background scan and answers 202 with scan_pending.
 	 */