Skip to content

Expose site score and recommendations as WordPress abilities - #782

Draft
ilicfilip wants to merge 21 commits into
developfrom
filip/abilities-api
Draft

ilicfilip wants to merge 21 commits into
developfrom
filip/abilities-api

Conversation

@ilicfilip

@ilicfilip ilicfilip commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Registers two read-only abilities with the core Abilities API, so an AI agent can read what this plugin knows about a site.

  • progress-planner/get-site-score
  • progress-planner/list-recommendations

The write ability (complete-recommendation) was split into a separate PR stacked on this one, so this can be reviewed as a self-contained read-only surface.

Why this shape

Registration is plain wp_register_ability(), guarded by function_exists(). The plugin stays unaware of any MCP bridge — a bridge that discovers WordPress abilities picks these up on its own, so there is no per-plugin integration to write on either side. That is how AI Connector already works, and it is why this needs no changes there.

It also means nothing downstream re-checks these abilities. A bridge serving a third-party ability runs that ability's own permission callback and nothing else, so the callbacks here are the only gate. Both require edit_others_posts — the same capability the admin pages are gated on, so an ability can never surface data the user could not open in wp-admin. list-recommendations additionally drops any task whose provider is unavailable to the current user.

For reference, Yoast SEO already ships yoast-seo/get-seo-scores; this plugin currently registers nothing.

Notes

  • The score payload is deliberately narrower than System_Status::get_system_status(). The active-plugin inventory, site URL and branding ID that endpoint reports are telemetry for progressplanner.com, not something an agent needs.
  • The monthly-score history is shared with System_Status, not copied, so the score an agent reads cannot drift from the score the SaaS endpoint reports.
  • These abilities report stored state, they do not trigger evaluation. Task evaluation runs on admin_init; an agent request is not an admin request, and evaluating here would mean a read silently writes.
  • Registration is idempotent. The constructor hooks the init actions, so a second instance would otherwise re-register and trip core's incorrect-usage notice. is_registered() is the notice-free probe for that.
  • PHPStan cannot see the Abilities API functions because the pinned WordPress stubs predate them (the API itself shipped in WP 6.9). Ignored the same way the optional Yoast symbols are.

Verified

Against a live WordPress 7.1 install, over a real MCP connection, and in CI:

  • End-to-end through MCP: with AI Connector active, tools/list returns progress-planner-get-site-score and progress-planner-list-recommendations alongside its own tools, and tools/call returns live data. No integration code on either side.
  • A subscriber is denied at both check_permissions() and execute() (ability_invalid_permissions) — no data leak.
  • All three status values work, provider filtering works, and an invalid enum is rejected with ability_invalid_input.
  • 20 PHPUnit tests, passing single-site and multisite. PHPStan level 10 clean, check-cs clean.

Two of the tests are regression guards rather than shape checks. get_checklist_results() keys its results by translated label, so reading them positionally returned false for every flag — the runtime output looked plausible because all-false was correct on the test site. The other asserts the score payload does not regrow the telemetry fields.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Test on Playground
Test this pull request on the Playground
or download the zip

@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

🔍 WordPress Plugin Check Report

⚠️ Status: Passed with warnings

📊 Report

🎯 Total Issues ❌ Errors ⚠️ Warnings
10 0 10

⚠️ Warnings (10)

📁 classes/suggested-tasks/data-collector/class-unpublished-content.php (1 warning)
📍 Line 🔖 Check 💬 Message
103 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
📁 classes/suggested-tasks/providers/class-content-review.php (4 warnings)
📍 Line 🔖 Check 💬 Message
232 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
377 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
381 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
388 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
📁 classes/activities/class-query.php (2 warnings)
📍 Line 🔖 Check 💬 Message
71 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $table_name used in $wpdb->query()\n$table_name assigned unsafely at line 58.
163 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $where_args used in $wpdb->get_results()\n$where_args assigned unsafely at line 153.
📁 classes/suggested-tasks/data-collector/class-yoast-orphaned-content.php (1 warning)
📍 Line 🔖 Check 💬 Message
111 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $query used in $wpdb->get_row()\n$query assigned unsafely at line 98.
📁 classes/suggested-tasks/data-collector/class-terms-without-description.php (1 warning)
📍 Line 🔖 Check 💬 Message
108 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $query used in $wpdb->get_results()\n$query assigned unsafely at line 106.
📁 classes/suggested-tasks/data-collector/class-terms-without-posts.php (1 warning)
📍 Line 🔖 Check 💬 Message
120 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $query used in $wpdb->get_results()\n$query assigned unsafely at line 118.

🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

✅ Code Coverage Report

Metric Value
Total Coverage 38.46% 📉
Base Coverage 34.27%
Difference 📈 4.19%

⚠️ Coverage below recommended 40% threshold

🎉 Great job maintaining/improving code coverage!

📊 File-level Coverage Changes (16 files)

🆕 New Files

Class Coverage Lines
🟢 Progress_Planner\Abilities\Abilities 95.40% 83/87
🟡 Progress_Planner\Abilities\Recommendation_Fixes 73.60% 92/125
🟢 Progress_Planner\Abilities\Recommendations 87.40% 111/127
🟢 Progress_Planner\Abilities\Schemas 100.00% 188/188
🟢 Progress_Planner\Abilities\Site_Score 89.13% 41/46

📈 Coverage Improved

Class Before After Change
Progress_Planner\Badges 67.21% 95.08% +27.87%
Progress_Planner\Suggested_Tasks 22.18% 30.38% +8.20%
Progress_Planner\Activities\Suggested_Task 88.89% 94.44% +5.55%
Progress_Planner\Suggested_Tasks\Providers\Set_Page_Task 36.67% 41.67% +5.00%
Progress_Planner\Admin\Page_Settings 60.66% 65.57% +4.91%
Progress_Planner\Suggested_Tasks\Task 20.00% 23.33% +3.33%
Progress_Planner\Suggested_Tasks\Providers\Hello_World 0.00% 2.50% +2.50%
Progress_Planner\Page_Types 55.36% 56.19% +0.83%
Progress_Planner\Base 51.59% 51.90% +0.31%
Progress_Planner\Utils\System_Status 91.95% 92.05% +0.10%

📉 Coverage Decreased

Class Before After Change
Progress_Planner\Suggested_Tasks\Providers\Email_Sending 7.79% 7.50% -0.29%
ℹ️ About this report
  • All tests run in a single job with Xdebug coverage
  • Security tests excluded from coverage to prevent output issues
  • Coverage calculated from line coverage percentages

Registers two read-only abilities with the core Abilities API so an AI
agent can read what this plugin knows:

- progress-planner/get-site-score
- progress-planner/list-recommendations

Registration is plain wp_register_ability(), guarded by function_exists().
The plugin stays unaware of any MCP bridge: a bridge that discovers
WordPress abilities picks these up on its own, so no per-bridge
integration is needed on either side.

Nothing downstream re-checks a third-party ability's risk classification,
so the permission callbacks here are the only gate that runs. Both require
edit_others_posts, matching the capability the admin pages are gated on,
and list-recommendations additionally drops any task whose provider is
unavailable to the current user.

The score payload is deliberately narrower than the SaaS status endpoint:
the active-plugin inventory, site URL and branding ID that endpoint reports
are telemetry for progressplanner.com, not something an agent needs. The
monthly-score history is shared with System_Status rather than copied, so
the two cannot drift.

Task evaluation runs on admin_init, so these abilities report stored state
rather than triggering it -- an agent request is not an admin request, and
evaluating here would mean a read silently writes.

Registration is idempotent: the constructor hooks the init actions, so a
second instance would otherwise re-register and trip core's incorrect-usage
notice. is_registered() is the notice-free probe for that.

PHPStan cannot see the Abilities API functions because the pinned
WordPress stubs predate them; ignored the same way the optional Yoast
symbols are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ilicfilip and others added 13 commits September 22, 2026 13:55
Registers two read-only abilities with the core Abilities API so an AI
agent can read what the plugin knows:

- progress-planner/get-site-score
- progress-planner/list-recommendations

Registration is plain wp_register_ability(), guarded by function_exists().
The plugin stays unaware of any MCP bridge: a bridge that discovers
WordPress abilities picks these up on its own, so no per-bridge
integration is needed on either side.

Nothing downstream re-checks a third-party ability's risk classification,
so the permission callbacks here are the only gate that runs. Both require
edit_others_posts, matching the capability the admin pages are gated on,
and list-recommendations additionally drops any task whose provider is
unavailable to the current user.

The score payload is deliberately narrower than the SaaS status endpoint:
the active-plugin inventory, site URL and branding ID that endpoint reports
are telemetry for progressplanner.com, not something an agent needs.

Task evaluation runs on admin_init, so these abilities report stored state
rather than triggering it -- an agent request is not an admin request, and
evaluating here would mean a read silently writes.

PHPStan cannot see the Abilities API functions because the pinned WordPress
stubs predate them; ignored the same way the optional Yoast symbols are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the permission gate, the payload shapes both abilities declare, the
status-to-post-status mapping and the provider filter.

Two of these are regression guards rather than shape checks. The checklist
test asserts stable keys and that no_pending_updates flips to true when
updates are mocked away: get_checklist_results() keys its results by
translated label, so reading them positionally silently returned false for
every flag. The telemetry test asserts the score payload does not grow the
plugin inventory, site URL or branding ID that the stats endpoint reports.

Registration itself is only asserted when wp_register_ability() exists,
since CI runs against a WordPress release that predates the Abilities API.
The rest of the logic is exercised directly and does not depend on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Abilities API shipped in WordPress 6.9, so it is present in CI. The
plugin has therefore already registered by the time the suite runs, and
firing wp_abilities_api_categories_init / wp_abilities_api_init again
re-registered every ability and category, which core reports as incorrect
usage. The registry is now read as-is instead.

For the same reason the guard test cannot be exercised where the API
exists: calling the register methods outside their hooks is itself
incorrect usage. It now skips unless the functions are absent, which is
the only situation the guard protects against.

Adds a check that both abilities are registered under the plugin's own
category.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The constructor hooks the registration actions, so every instance added
another listener and the next fire re-registered everything, which core
reports as incorrect usage. The test setUp() built a fresh instance per
test and tripped exactly that.

Registration now asks the registry first, via is_registered(). That is the
notice-free probe: wp_get_ability() and wp_get_ability_category() report a
miss as incorrect usage in their own right, so using them as a guard trades
one notice for another.

The tests now use the plugin's own instance from the service locator rather
than constructing one, and the guard test became an idempotency test: the
WordPress test harness fails any test that leaves a doing_it_wrong notice
behind, so re-registering being a no-op is what it now asserts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a site with no recommendations the loop body never ran, so the test
passed without asserting anything. CI hid this because it runs PHPUnit with
--dont-report-useless-tests; it shows up as a risky test locally, and on
multisite there are no recommendations at all.

The test now creates a recommendation and asserts the list is non-empty
before inspecting its fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lets an agent apply a recommendation that consists of one site setting,
either by provider ID or, with no argument, the highest-priority one that
needs no value.

The fixable set is an explicit list of six providers, not everything
extending Tasks_Interactive. That inference was wrong: the same base class
covers sending a test email (a diagnostic, not a fix), deleting terms
(irreversible), and rewriting permalinks (flushes rewrite rules and changes
every URL). Each entry in Recommendation_Fixes was read individually and is
there because it writes one known option and nothing else.

The option name always comes from that table and never from caller input,
so no ability argument can reach an arbitrary option. Recommendations
outside the list are returned with status "manual" and an admin URL rather
than half-applied.

Completion is observed, not asserted: after writing the setting the
provider decides whether the site now satisfies the task. Claiming
otherwise would award points for work that did not happen. Because
evaluation runs on admin_init, a just-fixed task is still pending in the
database, so next-mode skips providers that already report themselves
satisfied instead of picking the same one twice.

There is no nonce, deliberately. An authenticated agent call is not a
forged cross-origin form post, so manage_options and the fixed settings
list are what bound this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the Yoast and All in One SEO recommendations that consist of flipping
one setting: author, date and format archives, media pages, and the emoji,
author-feed and comment-feed crawl settings. Seventeen providers are now
applicable, up from six.

Yoast's tasks had no server-side save path at all. They predate the AIOSEO
integration and only ever got a UI affordance: the popover deep-links into
Yoast's own settings screen and highlights the field. That is useful to a
person and meaningless to an agent, which never loads the page -- the
underlying setting is a plain boolean, so it is set through
WPSEO_Options::set().

AIOSEO settings are a nested object walked from a declared path. The paths
were read from each provider's own submit handler rather than inferred:
media-pages writes redirectAttachmentUrls under dynamicOptions, not a
boolean under options, which guessing would have got wrong.

Settings and values still come only from the table, never from caller
input. Each SEO entry checks its plugin is active before writing, so an
entry reached on a site without that plugin returns an error instead of
fatalling.

aioseo-crawl-settings-feed-comments is deliberately left out: it writes two
settings, which the single-path model does not express.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Abilities class had grown to 813 lines doing three unrelated things:
registering abilities, building the score payload, and reading and applying
recommendations. Split into Abilities (registration and permissions only),
Site_Score, Recommendations and Schemas, none over 300 lines.

Removes two pieces of duplication:

- get_monthly_scores() was a verbatim copy of the chart configuration in
  System_Status, twenty lines defining what a monthly score is. That
  definition now lives once in System_Status::get_monthly_scores() and both
  callers use it, so the score an agent reads cannot drift from the score
  the SaaS endpoint reports.

- Three near-identical registration blocks each repeated the category,
  permission callback and annotations. They now share ability_args(), the
  same shape Yoast uses, so a new ability cannot quietly differ in its
  permission model or its annotations.

No behaviour change: same 436 tests, same assertions, and the live site
returns the same payloads before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI runs PHPCS at default severity, which surfaces two things the pre-commit
hook (severity 6) hides:

- 16 array double-arrow alignment warnings in class-recommendation-fixes.php
  (auto-fixed with phpcbf).
- `$object` used as a variable name in the abilities test — reserved in PHP 8;
  renamed to `$instance`.

No behaviour change. Full suite and PHPStan still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Email delivery is the one check a site cannot verify for itself: WordPress
knows wp_mail() returned true, not that anything arrived. The existing
token solves that by living only in the delivered message, so presenting it
back proves delivery.

The token is delivered as the href of a "Click here" link, which works for
a person who clicks and not for a person who wants to relay it. Reading it
off the screen is impossible; copying it means right-clicking and pasting a
128-character URL.

That matters now this question gets asked through an AI assistant rather
than the dashboard. An assistant with mailbox access can read the token and
needs nothing from the user. An assistant without one has to ask, and
"tell it the code from the email" only works if the code is short enough to
say out loud.

So the email now carries both: the link, unchanged, and a four-character
code shown as text. The code is deliberately weaker than the token, and the
docblock on generate_task_confirmation_code() records why that is
acceptable -- single use, 24-hour expiry, scoped to one user and one task,
manage_options required, and rate limiting around it. Anyone who could
satisfy all of that can complete the task from wp-admin anyway.

The alphabet omits characters that are misread when spoken or retyped
(0/O, 1/I/L, 5/S, 8/B), and comparison ignores case and surrounding
whitespace, because a code that survives being read aloud is the entire
point. A test asserts the alphabet holds across repeated generation.

The email body was built in two places; it is now built once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds hello-world and sample-page, bringing the applicable set to 19.

These are the first entries that remove something rather than change a
setting, and they are treated differently for it:

- Annotated destructive. The annotation describes what the ability can do,
  not what a given call does, so complete-recommendation as a whole is now
  destructive and a client prompts before any of it runs.
- Confirm-only, so next-mode skips them. A daily unattended run must never
  be the thing that deleted something; they can only be applied by naming
  the provider.
- Trashed, not force-deleted. The dashboard's JavaScript passes force=true
  and removes the post outright. An agent should leave a way back, and the
  completion check passes either way because it looks for a published post.

What makes them defensible at all is that the target is not ambiguous: the
data collector resolves the specific post WordPress ships, by slug with a
title fallback, and no ID is ever taken from the caller. The success message
says the content was trashed and can be restored, rather than reusing the
settings wording.

The new tests create and trash posts, which writes to the activities table.
That table is custom, so WP_UnitTestCase does not roll it back, and post IDs
are reused across tests -- a leftover row was being seen by an unrelated test
that asserts a fresh post has no activity. The class now clears the table in
tearDown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the applicable set to 22.

These recommendations do not create a page. They ask whether the site has
one, and record the answer plus which page it is. The interesting part is
who decides.

The plugin deliberately does not search for the page. Searching a real site
for "about" returns everything whose content mentions the word: on the
demo site that is eleven pages, including one called "Job opening", with
the actual About page ranked ninth and titled "About Emilia" at
/about-us/. Deciding which of those is the About page means weighing title,
slug, hierarchy and navigation in whatever language the site is written in.
A caller that can read the site's pages does that well; a title match in
PHP would get it wrong quietly, and worse on non-English sites.

So the division of labour is: the caller identifies the page, and this
verifies the ID before writing. A missing ID, an ID that does not exist, a
draft, or a post that is not a page are each a distinct error rather than a
silent no-op, because "we recorded your About page" is worth being true.
The page type comes from the table and never from the caller, and the
allowed post types are derived from the hierarchical public ones rather
than hardcoded to 'page', so a site serving these roles from a custom post
type still works.

They need a value, so next-mode skips them: there is no correct page to
choose on the owner's behalf.

One caveat is recorded in is_satisfied(). These providers do not override
is_task_completed(), so satisfaction now falls back to should_add_task().
That can still read pre-write state within the same request, because
page-type lookups are memoised in a static cache with no invalidation --
which is why the status distinguishes "applied" from "satisfied" instead of
assuming they are the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ilicfilip and others added 7 commits September 29, 2026 11:50
Let an agent apply recommendations that are a single setting
The MCP adapter's default server lists and runs only abilities whose meta
sets mcp.public. Without it, discovery returns none of ours and executing
one fails with "not exposed via MCP", so an agent connected through AI
Connector could not see or call any of them. Found by driving wp.test
through that server as an agent.

The flag is set once in ability_args(), so every ability gets it, and a
test asserts it for each registered ability.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Completion is two post statuses, not one: Task::is_completed() counts
both trash and pending. A completed task is pending until the dashboard
celebrates it, and only then trash. The status map returned only trash
for `completed`, so a recommendation finished outside wp-admin -- through
the email link, for one -- fell out of every list until someone opened
the dashboard.

A caller could complete a recommendation, be told it worked, then list to
confirm and find it gone from pending, completed and snoozed alike, with
no way to tell a completion from a deletion.

The existing test asserted the mapping returned 'trash' for completed,
which was true and was the bug: it encoded one of the two statuses as
though it were the only one.

Originally 10865f54 on the prototype branch, dropped before it was pushed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_posts_by_type() memoised its results in a function-static variable
nothing could clear. set_page_values() reads it before writing, so the
check that follows in the same request saw the page types from before the
write: setting the About page through complete-recommendation reported
applied_not_yet_complete although the recommendation was satisfied.

The cache is now a property, dropped whenever term relationships change
(set_object_terms, deleted_term_relationships) -- a page's type is a
term, and set_page_values() writes it both through Page_Types and through
wp_remove_object_terms() directly, so invalidating at the source covers
every writer. The comment in is_satisfied() that documented the stale
read goes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
complete-recommendation reported `completed` but recorded nothing: the
task stayed published, with no activity, until the next wp-admin page
load evaluated it. Until then it was still listed as pending and earned
no points.

It now records the completion itself, through a new
Suggested_Tasks::mark_completed() that the admin_init path uses too, so
there is one way a task gets completed: celebrate(), flush the task
cache, insert the activity.

The timezone and date-format providers never completed through the
ability at all. Neither has state to observe -- any timezone or date
format is valid -- so each counts a recorded completion, which the
popover writes on submit. Their fixes are marked completes_on_apply in
Recommendation_Fixes, making the exception explicit instead of relaxing
"completion is observed, never asserted" for every fix.

The abilities test cleared its activities with TRUNCATE, which commits
the open transaction and let posts leak into later tests. That was
invisible while complete() never changed a task; it is DELETE now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant