Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.20% covered (success)
97.20%
660 / 679
76.32% covered (warning)
76.32%
29 / 38
CRAP
0.00% covered (danger)
0.00%
0 / 1
AI_Launchpad_REST
97.35% covered (success)
97.35%
660 / 678
76.32% covered (warning)
76.32%
29 / 38
187
0.00% covered (danger)
0.00%
0 / 1
 is_private_site
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
146 / 146
100.00% covered (success)
100.00%
1 / 1
1
 can_read
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 can_write
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 check_eligibility
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 get_data
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
9
 get_current_tasks
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
15.15
 backfill_to_minimum
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
9
 backfill_pool
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 maybe_mark_completed
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 report_task_completions
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
10
 update_wizard
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 update_tailored
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
11
 log_tailoring
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
7.23
 tailoring_log_extra
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 complete_task
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 skip_task
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 dismiss
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_skipped_task_ids
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 apply_skipped_tasks
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 sanitize_subtitle
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 get_available_tasks
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 excluded_task_ids_for_goal
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 available_task_ids
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
4
 build_all_catalog_tasks
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 build_tasks
95.45% covered (success)
95.45%
63 / 66
0.00% covered (danger)
0.00%
0 / 1
28
 get_cta_override
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 get_themes_showcase_path
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 ensure_theme_task
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 move_task_after
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 build_store_tasks
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 build_install_woocommerce_task
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 build_setup_store_task
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 insert_before_launch_task
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
 get_in_progress_draft_url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 get_task_title
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 get_output_schema
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * AI Launchpad REST endpoints.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 * @since $$next-version$$
7 */
8
9/**
10 * REST endpoints for the AI Launchpad wizard and AI output options.
11 */
12class AI_Launchpad_REST extends WP_REST_Controller {
13
14    const OPTION_WIZARD    = 'wpcom_ai_launchpad_wizard';
15    const OPTION_AI_OUTPUT = 'wpcom_ai_launchpad_ai_output';
16    const OPTION_DISMISSED = 'wpcom_ai_launchpad_dismissed';
17    const OPTION_SKIPPED   = 'wpcom_ai_launchpad_skipped_tasks';
18    // Latched "every task is done" flag, autoloaded so the menu gate reads it without rebuilding the task list.
19    // Set once; cleared only by an explicit reset (re-tailor, dismiss, reset).
20    const OPTION_COMPLETED = 'wpcom_ai_launchpad_completed';
21
22    const MIN_VALID_TASKS = 4;
23
24    // `woo_launch_site`, `link_in_bio_launched`, and `videopress_launched` stay valid launch tasks so a stray AI
25    // emission passes PUT validation rather than failing the whole list into the deterministic fallback. It is
26    // normalized to `site_launched` as it is persisted (see update_tailored) and again on read (see build_tasks,
27    // which is what covers lists persisted before the write-side remap existed).
28    const LAUNCH_TASK_IDS = array( 'site_launched', 'blog_launched', 'woo_launch_site', 'link_in_bio_launched', 'videopress_launched' );
29
30    /**
31     * Tasks the AI Launchpad marks complete on CTA click, because their real signal is unreachable from wp-admin.
32     *
33     * Server-side allowlist so the complete-task route can only tick these ids. Mirrored client-side in model.ts.
34     *
35     * A registry task may be listed here, but only if its `is_complete` reads `launchpad_checklist_tasks_statuses`:
36     * that is the option complete_task() writes for it, and a definition computing completion from live site state
37     * would ignore the write and render as to-do straight after being ticked.
38     */
39    const COMPLETE_ON_CLICK_TASK_IDS = array(
40        'complete_profile',
41        'manage_subscribers',
42        'manage_paid_newsletter_plan',
43        'earn_money',
44        'start_building_your_audience',
45        'site_monitoring_page',
46        'setup_ssh',
47        'share_site',
48        'pick_fonts_colors',
49    );
50
51    /**
52     * Task ids the AI Launchpad synthesizes itself (never present in the AI payload): the sell goal's store-setup
53     * lead tasks. Skips must accept them alongside the AI-selected ids.
54     *
55     * Must list every id minted by build_store_tasks() â€” a synthetic task missing here renders with a
56     * Skip button whose write is rejected.
57     */
58    const SYNTHETIC_TASK_IDS = array(
59        'install_woocommerce',
60        'setup_woocommerce_store',
61    );
62
63    /**
64     * Tasks whose catalog visibility gate is a false negative in this read path, so the AI Launchpad overrides it.
65     *
66     * `add_10_email_subscribers` is gated off WordPress.com, but AI_Launchpad_Subscribers_Listener reads the count on
67     * Atomic, so the task must still render. `add_about_page` is gated on the `_wpcom_template_layout_category`
68     * page-meta key being registered, which does not happen during a REST request, so the task is wrongly hidden even
69     * though its "add a page" CTA works â€” force it visible so tailoring can offer this genuinely useful task.
70     */
71    const FORCE_VISIBLE_TASK_IDS = array(
72        'add_10_email_subscribers',
73        'add_about_page',
74    );
75
76    /**
77     * Commerce tasks whose catalog visibility gate requires WooCommerce to be active.
78     *
79     * On a fresh sell site these would be dropped, collapsing the list. Instead the sell branch keeps them as a
80     * disabled preview of the store roadmap until WooCommerce is active. See build_tasks()'s $disable_hidden_woo mode.
81     */
82    const WOO_TASK_IDS = array(
83        'woo_customize_store',
84        'woo_products',
85        'set_up_payments',
86    );
87
88    /**
89     * CTA destinations the AI Launchpad repoints to wp-admin, keyed by task id, each mapping to an `admin_url()` path.
90     *
91     * The catalog sends these to Calypso flows that are a poor fit for wp-admin. Overridden on read so the shared
92     * catalog (used by the legacy launchpad too) is left untouched.
93     */
94    const CTA_OVERRIDES = array(
95        'connect_social_media' => 'admin.php?page=jetpack-social',
96    );
97
98    /**
99     * Jetpack Social tasks, hidden on private sites where wpcom does not load Publicize (so their CTA page would 404).
100     * `drive_traffic` needs no entry: it remaps onto `connect_social_media` before this gate runs.
101     */
102    const SOCIAL_PAGE_TASK_IDS = array(
103        'connect_social_media',
104    );
105
106    /**
107     * Tasks the model may pick only when the site's goal is one of the listed goals.
108     *
109     * These were prose rules in the tailoring prompt ("Only include woo_* if the goal is sell OR the user
110     * explicitly mentions selling"). Prose is not enforcement: the model can ignore it, and when the
111     * available-tasks lookup fails the prompt falls back to the unfiltered menu. Enforced here instead, at
112     * both ends â€” the menu never offers them (available_task_ids) and PUT drops them (update_tailored).
113     *
114     * The free-text escape hatch is deliberately gone. The wizard goal is an explicit user choice, and the
115     * escape hatch was the non-determinism being removed.
116     *
117     * The two directions of disagreement with a task's `goals` annotation in js/lib/prompts.ts are not
118     * equivalent, and only one is acceptable:
119     *
120     * - Annotation BROADER than this map is fine. The annotation is soft affinity for the model, this is the
121     *   hard rule, and a task can be a tasteful fit for a goal it is not permitted on â€” the rule still blocks
122     *   it. The payment tasks are the live example: annotated for `newsletter` as well as `sell`, permitted
123     *   only on `sell`.
124     * - Annotation NARROWER than this map is a bug. It means a task the annotation itself calls goal-specific
125     *   can be selected and persisted on any goal, which is the inappropriate-task problem this whole change
126     *   exists to fix. `woo_tax`, `woo_marketing` and `woo_add_domain` were exactly that: annotated `sell`,
127     *   unrestricted here, and renderable on a blog â€” their catalog gate
128     *   (wpcom_launchpad_is_woocommerce_setup_visible) is goal-agnostic and passes on any WoA site with
129     *   WooCommerce active, which is every site this feature runs on.
130     *
131     * So: every id annotated with a single goal belongs here. No exceptions â€” `sensei_setup` is listed even
132     * though its catalog gate (WoA plus Sensei LMS active) already hides it almost everywhere, because a rule
133     * with one documented exception is a rule the next auditor has to re-derive. AI_Launchpad_Task_Menu_Test
134     * reads the annotations and fails if a single-goal one is missing here.
135     *
136     * The ids are matched after wpcom_ai_launchpad_remap_task_id(), so a twin of a restricted task is covered
137     * by the entry for the id it renders as and must not be listed separately.
138     */
139    const GOAL_RESTRICTED_TASK_IDS = array(
140        'woo_products'             => 'sell',
141        'woo_customize_store'      => 'sell',
142        'woo_woocommerce_payments' => 'sell',
143        'woo_tax'                  => 'sell',
144        'woo_marketing'            => 'sell',
145        'woo_add_domain'           => 'sell',
146        'set_up_payments'          => 'sell',
147        'stripe_connected'         => 'sell',
148        'add_10_email_subscribers' => 'newsletter',
149        'import_subscribers'       => 'newsletter',
150        'newsletter_plan_created'  => 'newsletter',
151        'sensei_setup'             => 'educate',
152    );
153
154    /**
155     * Tasks excluded for one specific goal and allowed on every other â€” the inverse of GOAL_RESTRICTED_TASK_IDS.
156     *
157     * Split into its own map rather than folded in behind a `!sell` marker, so neither map needs a value whose
158     * meaning flips on a prefix and each docblock describes all of its own entries.
159     *
160     * `add_gallery_page` is excluded for sell so a store site cannot end up with both the store sequence and a
161     * gallery. get_current_tasks() used to enforce that structurally, through the if/else that injected the
162     * gallery only on the non-sell branch; now that the model picks the gallery from the menu, this entry is
163     * the only thing holding it â€” the menu never offers it on sell, and PUT drops it if the model picks it anyway.
164     *
165     * Both ends are write-side: build_tasks() applies no exclusion, so a payload that already holds an excluded id
166     * renders it. Reaching that needs a compound failure (the availability lookup fails, so the prompt falls back
167     * to the full menu, AND the wizard-goal option has not landed yet, so the goal comes from the model's echo).
168     * True of every entry in both maps, not just this one; read-side enforcement is the fix if it ever bites.
169     */
170    const GOAL_EXCLUDED_TASK_IDS = array(
171        'add_gallery_page' => 'sell',
172    );
173
174    /**
175     * First-post tasks that can sit "in progress": the AI-created draft post exists but has not been published yet.
176     *
177     * Detected through the `_wpcom_ai_launchpad_first_post` marker meta (via AI_Launchpad_First_Post_Listener), so an
178     * unrelated pre-existing draft never counts. Paired with `add_about_page`, which has its own marker meta.
179     * `first_post_published_newsletter` needs no entry: it remaps onto `first_post_published` before this runs.
180     */
181    const IN_PROGRESS_FIRST_POST_TASK_IDS = array(
182        'first_post_published',
183    );
184
185    /**
186     * Whether the site's visibility is set to private (`blog_public = -1`).
187     *
188     * Read directly to avoid a hard dependency on the Status package in this read path.
189     *
190     * @return bool
191     */
192    private function is_private_site() {
193        return '-1' === (string) get_option( 'blog_public' );
194    }
195
196    /**
197     * Class constructor.
198     */
199    public function __construct() {
200        $this->namespace = 'wpcom/v2';
201        $this->rest_base = 'ai-launchpad';
202
203        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
204    }
205
206    /**
207     * Register our routes.
208     */
209    public function register_routes() {
210        register_rest_route(
211            $this->namespace,
212            $this->rest_base,
213            array(
214                array(
215                    'methods'             => WP_REST_Server::READABLE,
216                    'callback'            => array( $this, 'get_data' ),
217                    'permission_callback' => array( $this, 'can_read' ),
218                    'args'                => array(
219                        // Testing aid: render the full task catalog so every task can be exercised from one site.
220                        'all_tasks' => array(
221                            'description' => 'Return the full task catalog instead of the tailored list (testing aid).',
222                            'type'        => 'boolean',
223                            'default'     => false,
224                        ),
225                    ),
226                ),
227                array(
228                    'methods'             => WP_REST_Server::DELETABLE,
229                    'callback'            => array( $this, 'dismiss' ),
230                    'permission_callback' => array( $this, 'can_write' ),
231                ),
232            )
233        );
234
235        register_rest_route(
236            $this->namespace,
237            $this->rest_base . '/wizard',
238            array(
239                array(
240                    'methods'             => 'PUT',
241                    'callback'            => array( $this, 'update_wizard' ),
242                    'permission_callback' => array( $this, 'can_write' ),
243                    'args'                => array(
244                        'goal'        => array(
245                            'description' => 'The site goal picked in the wizard.',
246                            'type'        => 'string',
247                            'enum'        => array( 'write', 'build', 'sell', 'newsletter', 'educate', 'portfolio' ),
248                            'required'    => true,
249                        ),
250                        'site_name'   => array(
251                            'description'       => 'The site name entered in the wizard.',
252                            'type'              => 'string',
253                            'required'          => true,
254                            'sanitize_callback' => 'sanitize_text_field',
255                        ),
256                        'description' => array(
257                            'description'       => 'The free-text site description entered in the wizard.',
258                            'type'              => 'string',
259                            'required'          => true,
260                            'sanitize_callback' => 'sanitize_textarea_field',
261                        ),
262                        'locale'      => array(
263                            'description'       => 'The user locale.',
264                            'type'              => 'string',
265                            'default'           => 'en',
266                            'sanitize_callback' => 'sanitize_text_field',
267                        ),
268                    ),
269                ),
270            )
271        );
272
273        register_rest_route(
274            $this->namespace,
275            $this->rest_base . '/available-tasks',
276            array(
277                array(
278                    'methods'             => WP_REST_Server::READABLE,
279                    'callback'            => array( $this, 'get_available_tasks' ),
280                    'permission_callback' => array( $this, 'can_read' ),
281                    'args'                => array(
282                        'goal' => array(
283                            'description'       => 'The selected goal; sell keeps commerce tasks as available previews.',
284                            'type'              => 'string',
285                            'default'           => '',
286                            'sanitize_callback' => 'sanitize_key',
287                        ),
288                    ),
289                ),
290            )
291        );
292
293        register_rest_route(
294            $this->namespace,
295            $this->rest_base . '/complete-task',
296            array(
297                array(
298                    'methods'             => 'POST',
299                    'callback'            => array( $this, 'complete_task' ),
300                    'permission_callback' => array( $this, 'can_write' ),
301                    'args'                => array(
302                        'task_id' => array(
303                            'description'       => 'The acknowledgment task to mark complete.',
304                            'type'              => 'string',
305                            'required'          => true,
306                            'sanitize_callback' => 'sanitize_key',
307                        ),
308                    ),
309                ),
310            )
311        );
312
313        register_rest_route(
314            $this->namespace,
315            $this->rest_base . '/skip-task',
316            array(
317                array(
318                    'methods'             => 'POST',
319                    'callback'            => array( $this, 'skip_task' ),
320                    'permission_callback' => array( $this, 'can_write' ),
321                    'args'                => array(
322                        'task_id' => array(
323                            'description'       => 'The task to skip.',
324                            'type'              => 'string',
325                            'required'          => true,
326                            'sanitize_callback' => 'sanitize_key',
327                        ),
328                    ),
329                ),
330            )
331        );
332
333        register_rest_route(
334            $this->namespace,
335            $this->rest_base . '/tailored',
336            array(
337                array(
338                    'methods'             => 'PUT',
339                    'callback'            => array( $this, 'update_tailored' ),
340                    'permission_callback' => array( $this, 'can_write' ),
341                    'args'                => array(
342                        'source'      => array(
343                            'description' => 'Whether the payload came from the AI or the deterministic fallback. Query parameter; the JSON body must match the agent output schema exactly.',
344                            'type'        => 'string',
345                            'enum'        => array( 'ai', 'fallback' ),
346                            'default'     => 'ai',
347                        ),
348                        'duration_ms' => array(
349                            'description' => 'Client-measured tailoring duration in milliseconds, for the tailored Logstash record.',
350                            'type'        => 'integer',
351                            'minimum'     => 0,
352                        ),
353                        'attempts'    => array(
354                            'description' => 'How many jetpack-ai-query attempts the client made, for the tailored Logstash record.',
355                            'type'        => 'integer',
356                            'minimum'     => 0,
357                        ),
358                    ),
359                ),
360            )
361        );
362    }
363
364    /**
365     * Permission callback for reads.
366     *
367     * @return true|WP_Error|false
368     */
369    public function can_read() {
370        if ( ! current_user_can( 'edit_posts' ) ) {
371            return false;
372        }
373
374        return $this->check_eligibility();
375    }
376
377    /**
378     * Permission callback for writes.
379     *
380     * @return true|WP_Error|false
381     */
382    public function can_write() {
383        if ( ! current_user_can( 'manage_options' ) ) {
384            return false;
385        }
386
387        return $this->check_eligibility();
388    }
389
390    /**
391     * Returns a 404 error for ineligible sites, true otherwise.
392     *
393     * @return true|WP_Error
394     */
395    private function check_eligibility() {
396        // Fail closed: if the gate is unavailable, treat the site as not eligible.
397        if ( ! function_exists( 'wpcom_ai_launchpad_is_eligible' ) || ! wpcom_ai_launchpad_is_eligible() ) {
398            return new WP_Error(
399                'ai_launchpad_not_eligible',
400                __( 'This site is not eligible for the AI Launchpad.', 'jetpack-mu-wpcom' ),
401                array( 'status' => 404 )
402            );
403        }
404
405        return true;
406    }
407
408    /**
409     * Composite read: wizard payload, AI output, enriched tasks, statuses, and eligibility.
410     *
411     * @param WP_REST_Request|null $request Request object (for the `all_tasks` testing param).
412     * @return array
413     */
414    public function get_data( $request = null ) {
415        $wizard    = get_option( self::OPTION_WIZARD );
416        $ai_output = get_option( self::OPTION_AI_OUTPUT );
417        if ( is_array( $ai_output ) ) {
418            // Internal analytics bookkeeping (see report_task_completions), not part of the client contract.
419            unset( $ai_output['tracked_completed'] );
420        }
421
422        // Testing aid: ?all_tasks=1 renders the whole catalog, independent of the persisted tailored output.
423        if ( $request instanceof WP_REST_Request && $request->get_param( 'all_tasks' ) ) {
424            $tasks = $this->apply_skipped_tasks( $this->build_all_catalog_tasks() );
425        } else {
426            $tasks = $this->get_current_tasks();
427        }
428
429        // The membership tasks' completion is recomputed in build_tasks(), so overlay it to keep
430        // checklist_statuses consistent with tasks[].completed for them.
431        $checklist_statuses = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
432        foreach ( $tasks as $task ) {
433            if ( AI_Launchpad_Memberships::has_override( $task['id'] ) ) {
434                $checklist_statuses[ $task['id'] ] = $task['completed'];
435            }
436        }
437
438        $this->maybe_mark_completed();
439
440        return array(
441            'wizard'             => is_array( $wizard ) ? $wizard : null,
442            'ai_output'          => is_array( $ai_output ) ? $ai_output : null,
443            'tasks'              => $tasks,
444            'checklist_statuses' => $checklist_statuses,
445            'dismissed'          => (bool) get_option( self::OPTION_DISMISSED, false ),
446            'is_eligible'        => true,
447            // Site context the client needs for the launch-task CTA, the preview thumbnail/title, and wizard prefill.
448            'site'               => array(
449                'url'         => home_url(),
450                'title'       => get_bloginfo( 'name' ),
451                'description' => get_bloginfo( 'description' ),
452                // Block themes open the Site Editor; classic themes fall back to the Customizer.
453                'edit_url'    => wp_is_block_theme() ? admin_url( 'site-editor.php' ) : admin_url( 'customize.php' ),
454            ),
455        );
456    }
457
458    /**
459     * The site's tailored task list (AI-selected + the synthetic store tasks, skip overlay applied) â€” the tasks
460     * GET renders, minus the ?all_tasks testing view. Shared by GET and the completion check.
461     *
462     * @return array
463     */
464    public function get_current_tasks() {
465        $ai_output = get_option( self::OPTION_AI_OUTPUT );
466
467        // Guard the nested payload: partial/failed writes may leave the option without payload.tasks.
468        $payload = is_array( $ai_output ) && isset( $ai_output['payload'] ) && is_array( $ai_output['payload'] )
469            ? $ai_output['payload']
470            : array();
471        // Validate `inferred` as an array before reading from it, since a partial write could leave it non-array.
472        $inferred       = isset( $payload['inferred'] ) && is_array( $payload['inferred'] ) ? $payload['inferred'] : array();
473        $theme_category = isset( $inferred['theme_category'] ) && is_string( $inferred['theme_category'] )
474            ? $inferred['theme_category']
475            : '';
476
477        // The same authority update_tailored() enforces against: the user's own wizard goal, not the
478        // model's echo of it. Anything else lets PUT strip a goal's tasks while GET injects them.
479        $goal = wpcom_ai_launchpad_resolve_goal( $payload );
480
481        if ( ! function_exists( 'is_plugin_active' ) ) {
482            require_once ABSPATH . 'wp-admin/includes/plugin.php';
483        }
484        $woo_active = is_plugin_active( 'woocommerce/woocommerce.php' );
485
486        // On a sell site without WooCommerce, keep the gated commerce tasks as a disabled preview of the store
487        // roadmap instead of dropping them (which would collapse the list to almost nothing).
488        $disable_hidden_woo = 'sell' === $goal && ! $woo_active;
489
490        $theme_cta = $this->get_themes_showcase_path( $goal, $theme_category );
491
492        $ai_tasks = isset( $payload['tasks'] ) && is_array( $payload['tasks'] ) ? $payload['tasks'] : array();
493
494        // A store needs a theme, so the sell list always offers "Choose a theme" â€” the AI is not required to pick
495        // one. Add it when absent so build_tasks enriches it like any catalog task (get_ai_task_ids mirrors this).
496        if ( 'sell' === $goal ) {
497            $ai_tasks = $this->ensure_theme_task( $ai_tasks );
498        }
499
500        $tasks = empty( $ai_tasks ) ? array() : $this->build_tasks( $ai_tasks, false, $theme_cta, $disable_hidden_woo );
501
502        // The sell goal leads with the store-setup tasks; the theme task then follows them: pick the
503        // store's look once the store exists. Other goals need no injection â€” the gallery task is now
504        // on the menu, so the AI picks it when the site is visual.
505        if ( 'sell' === $goal ) {
506            $tasks = array_merge( $this->build_store_tasks( $woo_active ), $tasks );
507            $tasks = $this->move_task_after( $tasks, 'site_theme_selected', 'setup_woocommerce_store' );
508        }
509
510        // Restore the list toward six after the visibility gate has dropped tasks (before skips, which are the
511        // user's own removals). No-op on an empty list so the wizard still runs when there is no AI output.
512        $tasks = $this->backfill_to_minimum( $tasks, $theme_cta, $disable_hidden_woo );
513
514        return $this->apply_skipped_tasks( $tasks );
515    }
516
517    /**
518     * Tops a short rendered list back up toward the six tasks the AI is asked to return.
519     *
520     * The catalog visibility gate in build_tasks() drops any task it hides on this site (e.g. add_about_page needs a
521     * page-template meta key that is absent during a REST request) with no replacement, so a gate-heavy AI pick can
522     * collapse the list to two or three cards. This backfills from a small pool of broadly-useful tasks and keeps the
523     * launch task last. Candidates are built one at a time, stopping at the target, so the tail of the pool is only
524     * evaluated when the earlier fillers were not enough â€” mobile_app_installed's completion check does a remote
525     * lookup while incomplete, which the common short-by-one list never has to pay for. Backfilled cards are
526     * skippable (see skip_task); a fuller, AI-ranked overflow pool is the eventual replacement.
527     *
528     * @param array  $tasks              The rendered task list, already gated, launch task last.
529     * @param string $theme_cta          Pre-resolved themes-showcase CTA passed through to build_tasks().
530     * @param bool   $disable_hidden_woo Whether hidden commerce tasks render as a disabled preview.
531     * @return array
532     */
533    private function backfill_to_minimum( $tasks, $theme_cta, $disable_hidden_woo ) {
534        $target = 6;
535        if ( count( $tasks ) >= $target || empty( $tasks ) ) {
536            return $tasks;
537        }
538
539        foreach ( $this->backfill_pool() as $id => $subtitle ) {
540            if ( count( $tasks ) >= $target ) {
541                break;
542            }
543            $present = array_column( $tasks, 'id' );
544            if ( in_array( $id, $present, true ) ) {
545                continue;
546            }
547
548            // build_tasks() applies the same gating and remap the AI list gets, so a pool task the site
549            // hides simply does not appear.
550            $built = $this->build_tasks(
551                array(
552                    array(
553                        'id'       => $id,
554                        'subtitle' => $subtitle,
555                    ),
556                ),
557                false,
558                $theme_cta,
559                $disable_hidden_woo
560            );
561            if ( empty( $built ) ) {
562                continue;
563            }
564            $task = $built[0];
565            // A filler card that is already complete offers nothing to do; better a shorter list. The remap
566            // inside build_tasks() can also land the card on an id already present â€” skip that too.
567            if ( ! empty( $task['completed'] ) || in_array( $task['id'], $present, true ) ) {
568                continue;
569            }
570            $tasks = $this->insert_before_launch_task( $tasks, $task );
571        }
572
573        return $tasks;
574    }
575
576    /**
577     * The ordered id => subtitle pool the short-list backfill draws from: broadly-useful tasks that render on most
578     * sites and are skippable, most-broadly-applicable first. Each has a distinct card title, and none duplicate work
579     * the wizard already did (e.g. no site-title task â€” the wizard captured the name). Excludes tasks whose completion
580     * depends on the AI-task list (the theme/social listeners, the complete-on-click route), since a backfilled card is
581     * not on that list. skip_task() reads the ids here to keep every backfilled card skippable, so the set lives here.
582     *
583     * @return array<string, string>
584     */
585    private function backfill_pool() {
586        return array(
587            'design_edited'        => __( 'Make the design your own.', 'jetpack-mu-wpcom' ),
588            'add_new_page'         => __( 'Add a page your visitors will want, like About or Contact.', 'jetpack-mu-wpcom' ),
589            'connect_social_media' => __( 'Connect your social accounts to reach more people.', 'jetpack-mu-wpcom' ),
590            'mobile_app_installed' => __( 'Manage your site from anywhere with the Jetpack app.', 'jetpack-mu-wpcom' ),
591        );
592    }
593
594    /**
595     * Runs the completion pass: reports newly-completed tasks to Tracks, then latches OPTION_COMPLETED (and records
596     * the all-tasks-completed event) the first time the list is fully done. Called on every path that can finish a
597     * task (read, skip, complete-on-click); the already-set check skips the rebuild once latched.
598     *
599     * @return void
600     */
601    private function maybe_mark_completed() {
602        if ( get_option( self::OPTION_COMPLETED ) ) {
603            return;
604        }
605
606        // No AI output means the wizard hasn't produced a list yet: nothing to report or latch.
607        if ( ! get_option( self::OPTION_AI_OUTPUT ) ) {
608            return;
609        }
610
611        $tasks = $this->get_current_tasks();
612        // An empty list is not "complete" â€” the wizard still needs to run.
613        if ( empty( $tasks ) ) {
614            return;
615        }
616
617        $this->report_task_completions( $tasks );
618
619        foreach ( $tasks as $task ) {
620            // A skip coerces `completed` true; a disabled preview task stays incomplete until its prerequisite is met.
621            if ( empty( $task['completed'] ) ) {
622                return;
623            }
624        }
625
626        update_option( self::OPTION_COMPLETED, true, true );
627        // After the per-task reports above, so any task_completed reports precede it (a list finished
628        // purely by skips or born-completed tasks latches with none).
629        wpcom_ai_launchpad_record_tracks_event(
630            'jetpack_ai_launchpad_all_tasks_completed',
631            array(),
632            array_column( $tasks, 'id' )
633        );
634    }
635
636    /**
637     * Records a `task_completed` Tracks event for every rendered task that newly reads as completed, whatever
638     * completed it (client click, PHP listener, or live recomputation Ã  la Woo/domains/memberships) â€” the only
639     * uniform signal is the rendered list itself, so completions are diffed on read.
640     *
641     * The already-reported ids are embedded in the existing AI-output envelope (`tracked_completed`) rather than a
642     * new option; a re-tailor rewrites the envelope, which re-baselines the set at the same moment the skip/completed
643     * options reset. PUT /tailored seeds the key with the born-completed tasks, so a pass here only ever reports
644     * user-triggered completions; an envelope persisted before the key existed gets the same silent baseline on its
645     * first pass. Skipped tasks render as completed but already emit `task_skipped`, so they are excluded (and never
646     * reported later).
647     *
648     * @param array $tasks The current rendered tasks.
649     * @return void
650     */
651    private function report_task_completions( $tasks ) {
652        $ai_output = get_option( self::OPTION_AI_OUTPUT );
653        if ( ! is_array( $ai_output ) ) {
654            return;
655        }
656
657        $skipped   = $this->get_skipped_task_ids();
658        $completed = array();
659        foreach ( $tasks as $task ) {
660            if ( ! empty( $task['completed'] ) && ! in_array( $task['id'], $skipped, true ) ) {
661                $completed[] = $task['id'];
662            }
663        }
664
665        // null = no key yet, i.e. the baseline pass right after tailoring. Remapped like the skip overlay: a
666        // baseline recorded under a since-remapped id must keep covering the id its card renders as now, or the
667        // same completion would be re-reported once under the new id.
668        $reported = isset( $ai_output['tracked_completed'] ) && is_array( $ai_output['tracked_completed'] )
669            ? array_values( array_unique( array_map( 'wpcom_ai_launchpad_remap_task_id', $ai_output['tracked_completed'] ) ) )
670            : null;
671        $newly    = array_values( array_diff( $completed, $reported ?? array() ) );
672
673        if ( null !== $reported ) {
674            if ( empty( $newly ) ) {
675                return;
676            }
677            $rendered_ids = array_column( $tasks, 'id' );
678            foreach ( $newly as $task_id ) {
679                wpcom_ai_launchpad_record_tracks_event(
680                    'jetpack_ai_launchpad_task_completed',
681                    array( 'task_id' => $task_id ),
682                    $rendered_ids
683                );
684            }
685        }
686
687        $ai_output['tracked_completed'] = array_merge( $reported ?? array(), $newly );
688        update_option( self::OPTION_AI_OUTPUT, $ai_output, false );
689    }
690
691    /**
692     * Persists the wizard input and writes the entered Name and Brief description back to blogname / blogdescription.
693     *
694     * Empty values are skipped so the wizard never blanks an existing title or tagline.
695     *
696     * @param WP_REST_Request $request Request object.
697     * @return array
698     */
699    public function update_wizard( $request ) {
700        $wizard = array(
701            'version'      => 1,
702            'goal'         => $request['goal'],
703            'site_name'    => $request['site_name'],
704            'description'  => $request['description'],
705            'locale'       => $request['locale'],
706            'generated_at' => time(),
707        );
708
709        update_option( self::OPTION_WIZARD, $wizard, false );
710
711        if ( '' !== trim( (string) $request['site_name'] ) ) {
712            update_option( 'blogname', $request['site_name'] );
713        }
714        if ( '' !== trim( (string) $request['description'] ) ) {
715            // Collapse the textarea brief's newlines to keep the inline-rendered tagline single-line.
716            update_option( 'blogdescription', sanitize_text_field( $request['description'] ) );
717        }
718
719        return array( 'wizard' => $wizard );
720    }
721
722    /**
723     * Validates the AI output payload against the agent output schema, wraps it, and persists it.
724     *
725     * @param WP_REST_Request $request Request object.
726     * @return array|WP_Error
727     */
728    public function update_tailored( $request ) {
729        $payload = $request->get_json_params();
730
731        $validation = rest_validate_value_from_schema( $payload, $this->get_output_schema(), 'payload' );
732        if ( is_wp_error( $validation ) ) {
733            return new WP_Error( 'ai_launchpad_invalid_payload', $validation->get_error_message(), array( 'status' => 422 ) );
734        }
735
736        $last_task = end( $payload['tasks'] );
737        if ( ! in_array( $last_task['id'], self::LAUNCH_TASK_IDS, true ) ) {
738            return new WP_Error(
739                'ai_launchpad_missing_launch_task',
740                __( 'The last task must be a launch task.', 'jetpack-mu-wpcom' ),
741                array( 'status' => 422 )
742            );
743        }
744
745        // The AI's raw picks, captured before the unknown-id filter below so hallucinated ids stay observable.
746        $raw_task_ids = array_column( $payload['tasks'], 'id' );
747
748        $definitions = wpcom_launchpad_get_task_definitions();
749        $tasks       = array();
750
751        // The exclusions key off the goal the user chose, resolved through the shared helper so this and the
752        // read path cannot disagree about which goal the site has.
753        $excluded = self::excluded_task_ids_for_goal( wpcom_ai_launchpad_resolve_goal( $payload ) );
754
755        foreach ( $payload['tasks'] as $task ) {
756            // Judge â€” and persist â€” the id this task will actually render as. build_tasks() remaps broken and
757            // twinned ids on read, so checking the raw id would let a restricted task in under its other name:
758            // `subscribers_added` is off the menu and unrestricted, yet renders as the newsletter-restricted
759            // `import_subscribers`. The rule has to see the card, not the spelling.
760            $task_id = wpcom_ai_launchpad_remap_task_id( $task['id'] );
761
762            if ( ! isset( $definitions[ $task_id ] ) && ! AI_Launchpad_Task_Registry::has( $task_id ) ) {
763                continue;
764            }
765
766            // Goal-restricted tasks are dropped even if the model picked them: the menu filter is advisory
767            // (a failed availability lookup falls back to the full menu), this is not.
768            if ( in_array( $task_id, $excluded, true ) ) {
769                continue;
770            }
771
772            $subtitle = $this->sanitize_subtitle( $task['subtitle'] );
773            if ( is_wp_error( $subtitle ) ) {
774                return $subtitle;
775            }
776
777            $tasks[] = array(
778                'id'       => $task_id,
779                'subtitle' => $subtitle,
780            );
781        }
782
783        if ( count( $tasks ) < self::MIN_VALID_TASKS ) {
784            return new WP_Error(
785                'ai_launchpad_unknown_tasks',
786                __( 'Too few tasks matched the task catalog.', 'jetpack-mu-wpcom' ),
787                array( 'status' => 422 )
788            );
789        }
790
791        $payload['tasks'] = $tasks;
792
793        $ai_output = array(
794            'version'      => 1,
795            'source'       => $request['source'],
796            'generated_at' => time(),
797            'payload'      => $payload,
798        );
799
800        update_option( self::OPTION_AI_OUTPUT, $ai_output, false );
801
802        // A fresh list must not inherit the previous one's skips or "done" flag.
803        delete_option( self::OPTION_SKIPPED );
804        delete_option( self::OPTION_COMPLETED );
805
806        // Baseline the born-completed tasks now (needs the fresh options above), so completion
807        // reporting only ever emits for completions the user actually triggers afterwards.
808        $rendered_tasks = $this->get_current_tasks();
809        $baseline       = array();
810        foreach ( $rendered_tasks as $task ) {
811            if ( ! empty( $task['completed'] ) ) {
812                $baseline[] = $task['id'];
813            }
814        }
815        $ai_output['tracked_completed'] = $baseline;
816        update_option( self::OPTION_AI_OUTPUT, $ai_output, false );
817
818        // After the writes, so the observed rendered list is the fresh one.
819        $this->log_tailoring( $ai_output, $raw_task_ids, $request['duration_ms'], $request['attempts'], array_column( $rendered_tasks, 'id' ) );
820
821        // The analytics bookkeeping stays out of responses, mirroring get_data().
822        unset( $ai_output['tracked_completed'] );
823
824        return array( 'ai_output' => $ai_output );
825    }
826
827    /**
828     * Emits the tailoring observation event to Logstash, keyed `feature: atomic_ai_launchpad`, `message: tailored`
829     * (the public-api logstash endpoint whitelists features by their `atomic_` prefix â€” a bare feature name is
830     * rejected with a 400 on the Atomic HTTP dispatch path). Best-effort: logging must never fail the tailoring write.
831     *
832     * @param array         $ai_output    The persisted AI output envelope.
833     * @param string[]      $raw_task_ids The AI's selected ids before the unknown-id filter.
834     * @param int|null      $duration_ms  Client-measured tailoring duration, or null when not sent.
835     * @param int|null      $attempts     Client-reported jetpack-ai-query attempt count, or null when not sent.
836     * @param string[]|null $rendered_ids The rendered task ids, when the caller already computed them.
837     * @return void
838     */
839    private function log_tailoring( $ai_output, $raw_task_ids, $duration_ms = null, $attempts = null, $rendered_ids = null ) {
840        try {
841            /**
842             * Gates the tailoring observation event sent to Logstash. Checked before the event
843             * is built, so disabling it also skips the extra task-list rebuild the event needs.
844             *
845             * @param bool $enabled Whether to send the event. Default true.
846             */
847            if ( ! apply_filters( 'wpcom_ai_launchpad_tailoring_log_enabled', true ) ) {
848                return;
849            }
850
851            \Automattic\Jetpack\Jetpack_Mu_Wpcom::log2logstash(
852                'atomic_ai_launchpad',
853                'tailored',
854                $this->tailoring_log_extra( $ai_output, $raw_task_ids, $duration_ms, $attempts, $rendered_ids )
855            );
856        } catch ( \Throwable $e ) {
857            unset( $e );
858        }
859    }
860
861    /**
862     * The tailoring observation event: how the AI's output relates to what the site will actually render.
863     *
864     * Carries the inferred details, the AI's raw selected ids (pre-filter, so hallucinated ids are observable), the
865     * rendered ids, and their delta â€” `dropped` is what the unknown-id filter and the visibility gate removed, `added`
866     * is what synthetics and the backfill floor put in. The delta is diffed post-remap so a selected id that renders
867     * under its working equivalent does not read as a drop plus an addition. The raw wizard title/description are
868     * never included, and the inferred fields that can echo the user's own words near-verbatim are stripped:
869     * `brand_name` restates the title and `tagline` is drafted from the description.
870     *
871     * @param array         $ai_output    The persisted AI output envelope.
872     * @param string[]      $raw_task_ids The AI's selected ids before the unknown-id filter.
873     * @param int|null      $duration_ms  Client-measured tailoring duration, or null when not sent.
874     * @param int|null      $attempts     Client-reported jetpack-ai-query attempt count, or null when not sent.
875     * @param string[]|null $rendered_ids The rendered task ids, when the caller already computed them.
876     * @return array
877     */
878    private function tailoring_log_extra( $ai_output, $raw_task_ids, $duration_ms = null, $attempts = null, $rendered_ids = null ) {
879        // Schema-validated on the write path, so `inferred` is always present here.
880        $inferred = $ai_output['payload']['inferred'];
881        unset( $inferred['brand_name'], $inferred['tagline'] );
882
883        $rendered = $rendered_ids ?? array_column( $this->get_current_tasks(), 'id' );
884        $remapped = array_unique( array_map( 'wpcom_ai_launchpad_remap_task_id', $raw_task_ids ) );
885
886        $extra = array(
887            'source'   => $ai_output['source'],
888            'inferred' => $inferred,
889            'selected' => $raw_task_ids,
890            'rendered' => $rendered,
891            'dropped'  => array_values( array_diff( $remapped, $rendered ) ),
892            'added'    => array_values( array_diff( $rendered, $remapped ) ),
893        );
894
895        // Client-measured timing, replacing the retired ai_response_received Tracks event.
896        if ( null !== $duration_ms ) {
897            $extra['duration_ms'] = (int) $duration_ms;
898        }
899        if ( null !== $attempts ) {
900            $extra['attempts'] = (int) $attempts;
901        }
902
903        return $extra;
904    }
905
906    /**
907     * Marks an acknowledgment task complete when the user clicks its CTA, since these tasks have no wp-admin signal.
908     *
909     * Restricted to the COMPLETE_ON_CLICK_TASK_IDS allowlist and to tasks on the site's AI-selected list.
910     *
911     * @param WP_REST_Request $request Request object.
912     * @return array|WP_Error
913     */
914    public function complete_task( $request ) {
915        $task_id = $request['task_id'];
916
917        if ( ! in_array( $task_id, self::COMPLETE_ON_CLICK_TASK_IDS, true ) ) {
918            return new WP_Error(
919                'ai_launchpad_task_not_completable',
920                __( 'This task cannot be completed this way.', 'jetpack-mu-wpcom' ),
921                array( 'status' => 400 )
922            );
923        }
924
925        // Only tasks the AI put on this site's list may be completed.
926        if ( ! in_array( $task_id, wpcom_ai_launchpad_get_ai_task_ids(), true ) ) {
927            return new WP_Error(
928                'ai_launchpad_task_not_selected',
929                __( 'This task is not on the tailored list.', 'jetpack-mu-wpcom' ),
930                array( 'status' => 404 )
931            );
932        }
933
934        // The registry's ids are not in the shared catalog, and wpcom_mark_launchpad_task_complete() drops
935        // what the catalog does not define, so those go through the registry's own write.
936        if ( AI_Launchpad_Task_Registry::has( $task_id ) ) {
937            AI_Launchpad_Task_Registry::mark_complete( $task_id );
938        } else {
939            wpcom_mark_launchpad_task_complete( $task_id );
940        }
941
942        // Latch now so completing the last task hides the menu on the next page load, not just on the next read.
943        $this->maybe_mark_completed();
944
945        return array(
946            'completed' => true,
947            'task_id'   => $task_id,
948        );
949    }
950
951    /**
952     * Marks a task as skipped: it renders (and counts) as completed without its real completion signal ever firing.
953     *
954     * Restricted to tasks on the site's AI-selected list, the synthetic ids the list adds itself, and the short-list
955     * backfill pool â€” so every rendered card (AI, synthetic, or filler) is skippable. Persisted separately from
956     * `launchpad_checklist_tasks_statuses` because several catalog tasks recompute completion live (memberships, woo,
957     * domains) and would ignore a status write; the skip set is overlaid on read instead.
958     *
959     * @param WP_REST_Request $request Request object.
960     * @return array|WP_Error
961     */
962    public function skip_task( $request ) {
963        $task_id = $request['task_id'];
964
965        $skippable = array_merge( wpcom_ai_launchpad_get_ai_task_ids(), self::SYNTHETIC_TASK_IDS, array_keys( $this->backfill_pool() ) );
966        if ( ! in_array( $task_id, $skippable, true ) ) {
967            return new WP_Error(
968                'ai_launchpad_task_not_skippable',
969                __( 'This task is not on the tailored list.', 'jetpack-mu-wpcom' ),
970                array( 'status' => 404 )
971            );
972        }
973
974        $skipped = $this->get_skipped_task_ids();
975        if ( ! in_array( $task_id, $skipped, true ) ) {
976            $skipped[] = $task_id;
977            update_option( self::OPTION_SKIPPED, $skipped, false );
978        }
979
980        // Latch now so skipping the last task hides the menu on the next page load, not just on the next read.
981        $this->maybe_mark_completed();
982
983        return array(
984            'skipped' => true,
985            'task_id' => $task_id,
986        );
987    }
988
989    /**
990     * Deletes the AI output and marks the AI Launchpad as dismissed.
991     *
992     * @return array
993     */
994    public function dismiss() {
995        delete_option( self::OPTION_AI_OUTPUT );
996        delete_option( self::OPTION_SKIPPED );
997        delete_option( self::OPTION_COMPLETED );
998        update_option( self::OPTION_DISMISSED, true, true );
999
1000        return array( 'dismissed' => true );
1001    }
1002
1003    /**
1004     * The persisted skipped task ids, always as a clean string array, remapped onto the
1005     * ids the launchpad renders â€” a skip recorded under a task's raw id before that id
1006     * was remapped must keep applying to the card it renders as now.
1007     *
1008     * @return string[]
1009     */
1010    private function get_skipped_task_ids() {
1011        $skipped = get_option( self::OPTION_SKIPPED, array() );
1012        if ( ! is_array( $skipped ) ) {
1013            return array();
1014        }
1015
1016        $skipped = array_map( 'wpcom_ai_launchpad_remap_task_id', array_filter( $skipped, 'is_string' ) );
1017
1018        return array_values( array_unique( $skipped ) );
1019    }
1020
1021    /**
1022     * Overlays the persisted skips onto the enriched tasks: a skipped task carries `skipped: true` and is coerced to
1023     * completed, so progress, auto-expand, and reloads all treat it as done (a skip must never pop back open).
1024     *
1025     * @param array $tasks The enriched task list.
1026     * @return array
1027     */
1028    private function apply_skipped_tasks( $tasks ) {
1029        $skipped = $this->get_skipped_task_ids();
1030
1031        foreach ( $tasks as &$task ) {
1032            $task['skipped'] = in_array( $task['id'], $skipped, true );
1033            if ( $task['skipped'] ) {
1034                $task['completed'] = true;
1035            }
1036        }
1037        unset( $task );
1038
1039        return $tasks;
1040    }
1041
1042    /**
1043     * Strips HTML from a subtitle and rejects URLs and template syntax.
1044     *
1045     * @param string $subtitle The raw subtitle.
1046     * @return string|WP_Error The sanitized subtitle, or an error.
1047     */
1048    private function sanitize_subtitle( $subtitle ) {
1049        $subtitle = trim( wp_strip_all_tags( $subtitle, true ) );
1050
1051        if ( '' === $subtitle ) {
1052            return new WP_Error(
1053                'ai_launchpad_invalid_subtitle',
1054                __( 'Task subtitles must contain text.', 'jetpack-mu-wpcom' ),
1055                array( 'status' => 422 )
1056            );
1057        }
1058
1059        if ( preg_match( '#https?://#i', $subtitle ) ) {
1060            return new WP_Error(
1061                'ai_launchpad_subtitle_contains_url',
1062                __( 'Task subtitles must not contain URLs.', 'jetpack-mu-wpcom' ),
1063                array( 'status' => 422 )
1064            );
1065        }
1066
1067        if ( str_contains( $subtitle, '{{' ) || str_contains( $subtitle, '[[' ) ) {
1068            return new WP_Error(
1069                'ai_launchpad_subtitle_contains_template',
1070                __( 'Task subtitles must not contain template syntax.', 'jetpack-mu-wpcom' ),
1071                array( 'status' => 422 )
1072            );
1073        }
1074
1075        return mb_substr( $subtitle, 0, 200 );
1076    }
1077
1078    /**
1079     * Read endpoint backing the client's availability-aware tailoring: the task ids that will render for the given
1080     * goal. Fetched before the AI call (which the wizard prewarms), so the prompt offers only renderable tasks.
1081     *
1082     * @param WP_REST_Request $request Request object.
1083     * @return array
1084     */
1085    public function get_available_tasks( $request ) {
1086        $ids = $this->available_task_ids( (string) $request['goal'] );
1087        return array(
1088            'available_task_ids'  => $ids['actionable'],
1089            'renderable_task_ids' => $ids['renderable'],
1090        );
1091    }
1092
1093    /**
1094     * The task ids that must not be offered to, or accepted from, the model for a given goal.
1095     *
1096     * The two maps read in opposite directions, and an unknown or empty goal matches neither: every
1097     * GOAL_RESTRICTED_TASK_IDS entry is excluded (it never got the goal it requires), while every
1098     * GOAL_EXCLUDED_TASK_IDS entry is allowed (it never hit the goal that excludes it).
1099     *
1100     * @param string $goal The selected goal.
1101     * @return string[]
1102     */
1103    public static function excluded_task_ids_for_goal( $goal ) {
1104        $excluded = array();
1105
1106        foreach ( self::GOAL_RESTRICTED_TASK_IDS as $task_id => $required_goal ) {
1107            if ( $goal !== $required_goal ) {
1108                $excluded[] = $task_id;
1109            }
1110        }
1111
1112        foreach ( self::GOAL_EXCLUDED_TASK_IDS as $task_id => $excluded_goal ) {
1113            if ( $goal === $excluded_goal ) {
1114                $excluded[] = $task_id;
1115            }
1116        }
1117
1118        return $excluded;
1119    }
1120
1121    /**
1122     * The task ids that will actually render on this site for the given goal â€” the menu tailoring should choose from.
1123     *
1124     * Built by running the whole catalog through the real gate (visibility + force-visible overrides, and the sell
1125     * goal's woo-preview mode), so a task the AI could pick but the site would drop is never offered. `actionable`
1126     * additionally excludes tasks that are already complete â€” they leave nothing to do â€” except the launch tasks,
1127     * which the output contract requires last even on a site that already launched. `renderable` keeps the completed
1128     * ones: the client falls back to it when completion leaves too few actionable tasks to fill a valid list. The
1129     * client intersects these with its own TASK_MENU. Computed once per wizard submit.
1130     *
1131     * The AI Launchpad's own tasks are appended separately, since they are not catalog entries and the catalog
1132     * sweep cannot find them. They run their own gate on the way in: a registry definition may declare an
1133     * `is_visible` callable, and one that fails it is withheld from both lists here exactly as a catalog task
1134     * failing wpcom_launchpad_checklists()->is_visible() is. A definition without one is visible everywhere â€”
1135     * the gallery's shape, since it asks nothing of the site.
1136     *
1137     * @param string $goal The inferred/selected goal.
1138     * @return array{renderable: string[], actionable: string[]}
1139     */
1140    private function available_task_ids( $goal ) {
1141        if ( ! function_exists( 'is_plugin_active' ) ) {
1142            require_once ABSPATH . 'wp-admin/includes/plugin.php';
1143        }
1144        // Sell keeps the commerce tasks as a disabled preview until WooCommerce is active, so they count as available.
1145        $disable_hidden_woo = 'sell' === $goal && ! is_plugin_active( 'woocommerce/woocommerce.php' );
1146
1147        $tasks = $this->build_all_catalog_tasks( false, $disable_hidden_woo );
1148
1149        $actionable = array_filter(
1150            $tasks,
1151            static function ( $task ) {
1152                return ! $task['completed'] || in_array( $task['id'], self::LAUNCH_TASK_IDS, true );
1153            }
1154        );
1155
1156        $excluded = self::excluded_task_ids_for_goal( $goal );
1157
1158        // The registry's tasks are not in the catalog, so the sweep above cannot see them. Offer every registry
1159        // task that this site can render and that is not already complete; a completed one still renders, so it
1160        // stays on the renderable list the client relaxes to.
1161        $registry_renderable = array_values(
1162            array_filter(
1163                AI_Launchpad_Task_Registry::task_ids(),
1164                static function ( $task_id ) {
1165                    return AI_Launchpad_Task_Registry::is_visible( $task_id );
1166                }
1167            )
1168        );
1169        $registry_actionable = array_values(
1170            array_filter(
1171                $registry_renderable,
1172                static function ( $task_id ) {
1173                    return ! AI_Launchpad_Task_Registry::is_complete( $task_id );
1174                }
1175            )
1176        );
1177
1178        // array_unique guards a future registry id that shadows a catalog one: the endpoint would otherwise
1179        // advertise it twice.
1180        $renderable = array_unique( array_merge( array_column( $tasks, 'id' ), $registry_renderable ) );
1181        $actionable = array_unique( array_merge( array_column( $actionable, 'id' ), $registry_actionable ) );
1182
1183        return array(
1184            'renderable' => array_values( array_diff( $renderable, $excluded ) ),
1185            'actionable' => array_values( array_diff( $actionable, $excluded ) ),
1186        );
1187    }
1188
1189    /**
1190     * Builds the enriched task list for every catalog task (backs `?all_tasks=1` when the gate is bypassed, and
1191     * available_task_ids() when it is not).
1192     *
1193     * Each task is enriched in isolation so one that can't be built is skipped rather than breaking the whole view.
1194     *
1195     * @param bool $bypass_visibility  Whether to skip the catalog visibility gate (the testing view does).
1196     * @param bool $disable_hidden_woo Whether hidden commerce tasks render as a disabled preview instead of dropping.
1197     * @return array
1198     */
1199    private function build_all_catalog_tasks( $bypass_visibility = true, $disable_hidden_woo = false ) {
1200        $built    = array();
1201        $seen_ids = array();
1202        foreach ( array_keys( wpcom_launchpad_get_task_definitions() ) as $task_id ) {
1203            try {
1204                $one = $this->build_tasks(
1205                    array(
1206                        array(
1207                            'id'       => $task_id,
1208                            'subtitle' => $task_id,
1209                        ),
1210                    ),
1211                    $bypass_visibility,
1212                    null,
1213                    $disable_hidden_woo
1214                );
1215            } catch ( \Throwable $e ) {
1216                continue;
1217            }
1218            // build_tasks runs per id here, so its own dedup can't see this collision: the catalog holds both
1219            // `woo_launch_site` and `site_launched`, and the former is remapped onto the latter. Keep the first.
1220            $card = $one[0] ?? null;
1221            if ( null === $card || isset( $seen_ids[ $card['id'] ] ) ) {
1222                continue;
1223            }
1224            $seen_ids[ $card['id'] ] = true;
1225            $built[]                 = $card;
1226        }
1227        return $built;
1228    }
1229
1230    /**
1231     * Enriches the persisted tasks with title, completion state, and CTA path from the catalog.
1232     *
1233     * @param array       $tasks              The persisted `payload.tasks` array.
1234     * @param bool        $bypass_visibility  Skip the catalog visibility gate (for the all-tasks testing view).
1235     * @param string|null $theme_cta          The resolved themes-showcase path for the theme-picker tasks, or
1236     *                                        null to keep their default CTAs.
1237     * @param bool        $disable_hidden_woo Keep WOO_TASK_IDS that fail the visibility gate as disabled preview
1238     *                                        cards instead of dropping them (sell goal while WooCommerce is inactive).
1239     * @return array
1240     */
1241    private function build_tasks( $tasks, $bypass_visibility = false, $theme_cta = null, $disable_hidden_woo = false ) {
1242        $definitions = wpcom_launchpad_get_task_definitions();
1243        $built       = array();
1244        $seen_ids    = array();
1245
1246        // Some catalog visibility callbacks call is_plugin_active(), which is not loaded during a REST request.
1247        if ( ! function_exists( 'is_plugin_active' ) ) {
1248            require_once ABSPATH . 'wp-admin/includes/plugin.php';
1249        }
1250
1251        $is_private_site = $this->is_private_site();
1252
1253        foreach ( $tasks as $task ) {
1254            if ( ! is_array( $task ) || ! isset( $task['id'] ) || ! isset( $task['subtitle'] ) ) {
1255                continue;
1256            }
1257
1258            // Broken/meaningless-in-context ids render as their working equivalent (see the helper for the why).
1259            $task['id'] = wpcom_ai_launchpad_remap_task_id( $task['id'] );
1260
1261            // One card per id â€” the client keys cards by id. The remap above can collide with the target id already
1262            // being present (notably the ?all_tasks=1 view, which enumerates every catalog id), so collapse any
1263            // repeat to the first occurrence.
1264            if ( isset( $seen_ids[ $task['id'] ] ) ) {
1265                continue;
1266            }
1267
1268            // Tasks the AI Launchpad owns are built from its own registry: the shared catalog does not
1269            // define them, and routing them through wpcom_launchpad_checklists() would mean relying on
1270            // the catalog accepting entries it never registered.
1271            if ( AI_Launchpad_Task_Registry::has( $task['id'] ) ) {
1272                // The registry's own visibility gate, filtered on read for the same reason as the catalog's
1273                // below: a persisted list outlives the site state it was tailored for, so a task whose
1274                // precondition has since gone must stop rendering rather than offer a CTA that leads nowhere.
1275                if ( ! $bypass_visibility && ! AI_Launchpad_Task_Registry::is_visible( $task['id'] ) ) {
1276                    continue;
1277                }
1278
1279                $card = AI_Launchpad_Task_Registry::build( $task['id'], (string) $task['subtitle'] );
1280                if ( null !== $card ) {
1281                    $seen_ids[ $task['id'] ] = true;
1282                    $built[]                 = $card;
1283                }
1284                continue;
1285            }
1286
1287            if ( ! isset( $definitions[ $task['id'] ] ) ) {
1288                continue;
1289            }
1290
1291            // The Jetpack Social tasks point at an admin page wpcom doesn't load on a private site, so hide them there.
1292            if ( $is_private_site && in_array( $task['id'], self::SOCIAL_PAGE_TASK_IDS, true ) ) {
1293                continue;
1294            }
1295
1296            $definition       = $definitions[ $task['id'] ];
1297            $definition['id'] = $task['id'];
1298            $disabled         = false;
1299
1300            // Honor the catalog's own visibility gate: a task the catalog would hide here must not render, since its
1301            // CTA would 404 and it could never complete. Filtered on read so the deterministic fallback stays usable.
1302            if (
1303                ! $bypass_visibility
1304                && ! in_array( $task['id'], self::FORCE_VISIBLE_TASK_IDS, true )
1305                && ! wpcom_launchpad_checklists()->is_visible( $definition )
1306            ) {
1307                // On a sell site without WooCommerce, keep the commerce tasks as a disabled preview of the store
1308                // roadmap rather than dropping them and collapsing the list. Everything else stays hidden.
1309                if ( $disable_hidden_woo && in_array( $task['id'], self::WOO_TASK_IDS, true ) ) {
1310                    $disabled = true;
1311                } else {
1312                    continue;
1313                }
1314            }
1315
1316            if ( $disabled ) {
1317                // A disabled preview always renders as the locked card: never resolve its completion (the woo
1318                // completion callback marks the task complete as a side effect, which must not fire on a read) and
1319                // never resolve a CTA path (it has no reachable action).
1320                $completed    = false;
1321                $calypso_path = null;
1322            } else {
1323                // The membership tasks' catalog callbacks are always false on Atomic; recompute from local signals.
1324                $completed = AI_Launchpad_Memberships::has_override( $task['id'] )
1325                    ? AI_Launchpad_Memberships::is_task_complete( $task['id'] )
1326                    : wpcom_launchpad_checklists()->is_task_complete( $definition );
1327
1328                // The theme-picker task points at the showcase pre-filtered for the site (Store category on sell,
1329                // the AI's inferred category elsewhere) instead of plain themes.php. The legacy design_selected/
1330                // design_completed ids consolidate onto site_theme_selected via wpcom_ai_launchpad_remap_task_id().
1331                $theme_showcase_path = 'site_theme_selected' === $task['id'] ? $theme_cta : null;
1332                $cta_override        = $this->get_cta_override( $task['id'] );
1333                if ( null !== $theme_showcase_path ) {
1334                    $calypso_path = $theme_showcase_path;
1335                } elseif ( null !== $cta_override ) {
1336                    $calypso_path = $cta_override;
1337                } else {
1338                    $calypso_path = wpcom_launchpad_checklists()->load_calypso_path( $definition );
1339                }
1340
1341                // Simple sites have no reachable wp-admin plugins screen; route any plugin-screen CTA to Calypso.
1342                $calypso_path = wpcom_ai_launchpad_to_simple_plugins_path( $calypso_path );
1343            }
1344
1345            $title       = isset( $definition['get_title'] ) ? $definition['get_title']() : '';
1346            $in_progress = false;
1347
1348            // A saved-but-unpublished draft (found by marker meta) puts a site-editor task "in progress": reopen that
1349            // draft instead of creating a new one, and surface the drafts icon + a "Continue…" prompt in the card.
1350            if ( ! $completed && ! $disabled ) {
1351                $draft_url = $this->get_in_progress_draft_url( $task['id'] );
1352                if ( null !== $draft_url ) {
1353                    $in_progress  = true;
1354                    $calypso_path = $draft_url;
1355                }
1356            }
1357
1358            // Title follows our precise in-progress signal so it, the icon, and the CTA agree.
1359            $title = $this->get_task_title( $task['id'], $in_progress, $title );
1360
1361            $seen_ids[ $task['id'] ] = true;
1362            $built[]                 = array(
1363                'id'           => $task['id'],
1364                'subtitle'     => $task['subtitle'],
1365                'title'        => $title,
1366                'completed'    => $completed,
1367                'in_progress'  => $in_progress,
1368                'disabled'     => $disabled,
1369                'calypso_path' => $calypso_path,
1370            );
1371        }
1372
1373        return $built;
1374    }
1375
1376    /**
1377     * The wp-admin CTA destination the AI Launchpad substitutes for a task's catalog path, or null to keep the catalog's.
1378     *
1379     * Static repoints live in CTA_OVERRIDES; `add_subscribe_block` is resolved here because its destination depends on
1380     * the active theme: the Site Editor is where a block theme adds the Subscribe block to a template (the action its
1381     * completion listener watches), and the block-based widget editor is the closest equivalent on a classic theme
1382     * (normally unreachable â€” the task's catalog visibility is FSE-only â€” but the theme can change after tailoring).
1383     *
1384     * @param string $task_id The catalog task id.
1385     * @return string|null
1386     */
1387    private function get_cta_override( $task_id ) {
1388        if ( 'add_subscribe_block' === $task_id ) {
1389            return admin_url( wp_is_block_theme() ? 'site-editor.php' : 'widgets.php' );
1390        }
1391
1392        if ( isset( self::CTA_OVERRIDES[ $task_id ] ) ) {
1393            return admin_url( self::CTA_OVERRIDES[ $task_id ] );
1394        }
1395
1396        return null;
1397    }
1398
1399    /**
1400     * The theme-showcase subject-category slugs (the `subject` taxonomy from /rest/v1.2/theme-filters).
1401     * Every category carries free themes, unlike free-text search, which surfaces mostly paid results.
1402     */
1403    const THEME_CATEGORIES = array(
1404        'blog',
1405        'portfolio',
1406        'business',
1407        'store',
1408        'art-design',
1409        'about',
1410        'real-estate',
1411        'health-wellness',
1412        'authors-writers',
1413        'newsletter',
1414        'education',
1415        'magazine',
1416        'music',
1417        'restaurant',
1418        'travel-lifestyle',
1419        'fashion-beauty',
1420        'community-non-profit',
1421        'podcast',
1422        'entertainment',
1423    );
1424
1425    /**
1426     * The wordpress.com themes-showcase path the theme-picker tasks should point at.
1427     *
1428     * Sell sites always land on the showcase's Store category so shop-ready templates lead; other goals get the
1429     * showcase pre-filtered by the AI's inferred category, re-checked against the allowlist since the envelope is
1430     * stored data. Without a valid category the plain showcase is returned (never null: the catalog CTA can resolve
1431     * to wp-admin's themes.php, which skips the showcase). The client's `toNavigableUrl` resolves the relative path
1432     * against wordpress.com.
1433     *
1434     * @param string $goal     The inferred goal.
1435     * @param string $category The AI's inferred theme_category slug.
1436     * @return string
1437     */
1438    private function get_themes_showcase_path( $goal, $category ) {
1439        if ( 'sell' === $goal ) {
1440            $category = 'store';
1441        }
1442
1443        if ( ! in_array( $category, self::THEME_CATEGORIES, true ) ) {
1444            return '/themes/' . rawurlencode( wpcom_get_site_slug() );
1445        }
1446
1447        return '/themes/filter/' . $category . '/' . rawurlencode( wpcom_get_site_slug() );
1448    }
1449
1450    /**
1451     * Ensures the persisted task list contains the theme-picker task, appending `site_theme_selected` when no
1452     * task already resolves to it. A design task that remaps onto it (via wpcom_ai_launchpad_remap_task_id)
1453     * counts as present, so a theme card is never duplicated.
1454     *
1455     * @param array $tasks The persisted `payload.tasks` array.
1456     * @return array
1457     */
1458    private function ensure_theme_task( $tasks ) {
1459        foreach ( $tasks as $task ) {
1460            if ( is_array( $task ) && isset( $task['id'] ) && is_string( $task['id'] )
1461                && 'site_theme_selected' === wpcom_ai_launchpad_remap_task_id( $task['id'] ) ) {
1462                return $tasks;
1463            }
1464        }
1465
1466        $tasks[] = array(
1467            'id'       => 'site_theme_selected',
1468            'subtitle' => __( 'Choose a theme that fits your store.', 'jetpack-mu-wpcom' ),
1469        );
1470
1471        return $tasks;
1472    }
1473
1474    /**
1475     * Moves the task with the given id to immediately after another task. The list is returned unchanged
1476     * unless both ids are present.
1477     *
1478     * @param array  $tasks    The built task list.
1479     * @param string $move_id  The id of the task to move.
1480     * @param string $after_id The id of the task to place it after.
1481     * @return array
1482     */
1483    private function move_task_after( $tasks, $move_id, $after_id ) {
1484        $ids  = array_column( $tasks, 'id' );
1485        $from = array_search( $move_id, $ids, true );
1486        if ( false === $from || false === array_search( $after_id, $ids, true ) ) {
1487            return $tasks;
1488        }
1489
1490        $moved = array_splice( $tasks, $from, 1 );
1491        // Recompute the anchor: extracting an earlier element shifts it left by one.
1492        $to = array_search( $after_id, array_column( $tasks, 'id' ), true );
1493        array_splice( $tasks, $to + 1, 0, $moved );
1494
1495        return $tasks;
1496    }
1497
1498    /**
1499     * Builds the synthetic store-setup lead tasks for the sell goal: an "install WooCommerce" task and a "set up
1500     * your store" task. Their ids are listed in SYNTHETIC_TASK_IDS so the tasks stay skippable.
1501     *
1502     * Both are read live (installed/active/profiler options), so no marker or listener is needed. While WooCommerce
1503     * is inactive the setup task shows as a disabled preview, matching the disabled commerce tasks below it. Callers
1504     * gate this on the sell goal.
1505     *
1506     * @param bool $active Whether WooCommerce is active.
1507     * @return array The lead tasks in display order.
1508     */
1509    private function build_store_tasks( $active ) {
1510        return array(
1511            $this->build_install_woocommerce_task( $active ),
1512            $this->build_setup_store_task( $active ),
1513        );
1514    }
1515
1516    /**
1517     * The "Install the WooCommerce plugin" lead task: to-do until the plugin exists, in-progress while it is
1518     * installed-but-inactive, and complete once active.
1519     *
1520     * @param bool $active Whether WooCommerce is active.
1521     * @return array
1522     */
1523    private function build_install_woocommerce_task( $active ) {
1524        $in_progress = ! $active && array_key_exists( 'woocommerce/woocommerce.php', get_plugins() );
1525
1526        $calypso_path = null;
1527        if ( ! $active ) {
1528            // Installed-but-inactive activates from the plugins list; not-installed installs from the plugin search.
1529            // On Simple both wp-admin screens are unreachable, so route through the Calypso WooCommerce plugin page.
1530            $wp_admin_path = $in_progress
1531                ? admin_url( 'plugins.php?plugin_status=inactive' )
1532                : admin_url( 'plugin-install.php?s=woocommerce&tab=search&type=term' );
1533            $calypso_path  = wpcom_ai_launchpad_to_simple_plugins_path( $wp_admin_path, 'woocommerce' );
1534        }
1535
1536        return array(
1537            'id'           => 'install_woocommerce',
1538            'subtitle'     => $in_progress
1539                ? __( 'Activate the WooCommerce plugin to continue.', 'jetpack-mu-wpcom' )
1540                : __( 'Add the WooCommerce plugin to start selling.', 'jetpack-mu-wpcom' ),
1541            'title'        => __( 'Install the WooCommerce plugin', 'jetpack-mu-wpcom' ),
1542            'completed'    => $active,
1543            'in_progress'  => $in_progress,
1544            'disabled'     => false,
1545            'calypso_path' => $calypso_path,
1546        );
1547    }
1548
1549    /**
1550     * The "Set up your store" lead task: to-do until the WooCommerce setup wizard (core profiler) is completed or
1551     * skipped, then complete. Shown as a disabled preview until WooCommerce is active, since the wizard needs it.
1552     *
1553     * @param bool $active Whether WooCommerce is active.
1554     * @return array
1555     */
1556    private function build_setup_store_task( $active ) {
1557        $profile   = (array) get_option( 'woocommerce_onboarding_profile', array() );
1558        $completed = $active && ( ! empty( $profile['completed'] ) || ! empty( $profile['skipped'] ) );
1559
1560        return array(
1561            'id'           => 'setup_woocommerce_store',
1562            'subtitle'     => __( 'Complete or skip the WooCommerce setup wizard.', 'jetpack-mu-wpcom' ),
1563            'title'        => __( 'Set up your store', 'jetpack-mu-wpcom' ),
1564            'completed'    => $completed,
1565            'in_progress'  => false,
1566            'disabled'     => ! $active,
1567            'calypso_path' => $completed || ! $active ? null : admin_url( 'admin.php?page=wc-admin&path=%2Fsetup-wizard' ),
1568        );
1569    }
1570
1571    /**
1572     * Inserts a task immediately before the trailing launch task (or appends it), idempotently by id.
1573     *
1574     * @param array $tasks The enriched task list.
1575     * @param array $task  The task entry to insert.
1576     * @return array
1577     */
1578    private function insert_before_launch_task( $tasks, $task ) {
1579        foreach ( $tasks as $existing ) {
1580            if ( isset( $existing['id'] ) && $existing['id'] === $task['id'] ) {
1581                return $tasks;
1582            }
1583        }
1584
1585        $insert_at = count( $tasks );
1586        foreach ( $tasks as $index => $existing ) {
1587            if ( isset( $existing['id'] ) && in_array( $existing['id'], self::LAUNCH_TASK_IDS, true ) ) {
1588                $insert_at = $index;
1589                break;
1590            }
1591        }
1592
1593        array_splice( $tasks, $insert_at, 0, array( $task ) );
1594        return $tasks;
1595    }
1596
1597    /**
1598     * Resolves the editor URL of a site-editor task's in-progress draft, or null when there is none.
1599     *
1600     * The About page is found by its marker meta; the first-post tasks by the latest draft post. Returned as an
1601     * `admin_url()` so the client reopens the existing draft rather than creating a duplicate.
1602     *
1603     * @param string $task_id The catalog task id.
1604     * @return string|null
1605     */
1606    private function get_in_progress_draft_url( $task_id ) {
1607        $draft_id = null;
1608
1609        if ( 'add_about_page' === $task_id ) {
1610            $draft_id = AI_Launchpad_About_Page_Listener::get_draft_id();
1611        } elseif ( in_array( $task_id, self::IN_PROGRESS_FIRST_POST_TASK_IDS, true ) ) {
1612            $draft_id = AI_Launchpad_First_Post_Listener::get_draft_id();
1613        }
1614
1615        if ( null === $draft_id ) {
1616            return null;
1617        }
1618
1619        return admin_url( 'post.php?post=' . $draft_id . '&action=edit' );
1620    }
1621
1622    /**
1623     * The card title for a site-editor task, chosen by our precise (marker-based) in-progress signal so the title,
1624     * icon, and CTA stay in agreement.
1625     *
1626     * This overrides `first_post_published`'s catalog title in both states: the catalog swaps it to "Continue…"
1627     * whenever ANY draft exists (a looser signal than our marker), so an unrelated draft would otherwise show a
1628     * "Continue…" title beside the not-started icon. Tasks not listed keep their catalog title.
1629     *
1630     * @param string $task_id     The catalog task id.
1631     * @param bool   $in_progress Whether our marker detected an in-progress draft.
1632     * @param string $default     The catalog-provided title, kept when we don't override.
1633     * @return string
1634     */
1635    private function get_task_title( $task_id, $in_progress, $default ) {
1636        switch ( $task_id ) {
1637            case 'add_about_page':
1638                return $in_progress ? __( 'Continue working on the About page', 'jetpack-mu-wpcom' ) : $default;
1639            case 'first_post_published':
1640                return $in_progress
1641                    ? __( 'Continue to write your first post', 'jetpack-mu-wpcom' )
1642                    : __( 'Write your first post', 'jetpack-mu-wpcom' );
1643            default:
1644                return $default;
1645        }
1646    }
1647
1648    /**
1649     * Loads the agent output schema used to validate `PUT /tailored` bodies.
1650     *
1651     * @return array
1652     */
1653    private function get_output_schema() {
1654        static $schema = null;
1655
1656        if ( null === $schema ) {
1657            $schema = json_decode( file_get_contents( __DIR__ . '/contracts/agent-output-schema.json' ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local package file.
1658        }
1659
1660        return $schema;
1661    }
1662}
1663
1664// @phan-suppress-next-line PhanNoopNew -- instantiated for the constructor's add_action side effect.
1665new AI_Launchpad_REST();