Skip to content

Let an agent apply recommendations that are a single setting - #783

Draft
ilicfilip wants to merge 4 commits into
filip/abilities-apifrom
filip/abilities-api-write
Draft

ilicfilip wants to merge 4 commits into
filip/abilities-apifrom
filip/abilities-api-write

Conversation

@ilicfilip

Copy link
Copy Markdown
Collaborator

Adds progress-planner/complete-recommendation, which applies a recommendation either by provider ID or — with no argument — the highest-priority one that needs no value from the caller.

Stacked on #782. Review that first; this PR targets its branch, so the diff here is only the write surface.

The fixable set is vetted, not inferred

Seventeen providers qualify: six core settings (tagline, timezone, date format, search-engine visibility, and two comment settings), seven Yoast, four All in One SEO.

The list is explicit, not "everything extending Tasks_Interactive". That inference is wrong, and I made it before checking: the same base class also covers sending a test email (a diagnostic, not a fix), deleting terms (irreversible), and rewriting permalinks (flushes rewrite rules and changes every URL on the site). Each entry in Recommendation_Fixes was read individually and is listed because it writes one known setting and nothing else.

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.

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

Safety

  • The setting and its value always come from the table, never from caller input, so no ability argument can reach an arbitrary option.
  • Anything outside the list is returned with status manual and an admin URL, never half-applied.
  • 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.
  • 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. Worth a reviewer's eye: that list is the real control.

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, and there is a distinct status for "setting changed but task not satisfied yet".

Because evaluation runs on admin_init, a just-fixed task is still pending in the database. Next-mode therefore skips providers that already report themselves satisfied, instead of picking the same one twice — found by testing a repeated run, not by reasoning about it.

Next-mode also skips any fix needing a value: there is no correct tagline to invent on the site owner's behalf.

Verified

Against a live WordPress 7.1 install with Yoast active:

  • Set blog_public to 0, called the ability, it wrote 1 and reported completed. Called next-mode again and it moved on to the next task rather than repeating.
  • Yoast: remove_emoji_scripts and remove_feed_authors both flipped through the ability and reported completed. Both restored afterwards.
  • Refusal paths: manual task → manual; invalid timezone → progress_planner_invalid_timezone with nothing written; missing value → rejected.
  • 32 PHPUnit tests (12 new), passing single-site and multisite. PHPStan level 10 clean, check-cs clean.

Guessing the AIOSEO paths would have broken one: media-pages writes redirectAttachmentUrls under dynamicOptions as a string, not a boolean show under options. The paths were read from each provider's own submit handler.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

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/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/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/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.
📁 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/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.

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

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

✅ Code Coverage Report

Metric Value
Total Coverage 36.40% 📉
Base Coverage 34.46%
Difference 📈 1.94%

⚠️ Coverage below recommended 40% threshold

🎉 Great job maintaining/improving code coverage!

📊 File-level Coverage Changes (7 files)

🆕 New Files

Class Coverage Lines
🟡 Progress_Planner\Abilities\Recommendation_Fixes 68.29% 84/123

📈 Coverage Improved

Class Before After Change
Progress_Planner\Admin\Page_Settings 39.34% 59.02% +19.68%
Progress_Planner\Suggested_Tasks 9.60% 15.04% +5.44%
Progress_Planner\Page_Types 52.68% 55.36% +2.68%
Progress_Planner\Suggested_Tasks\Providers\Hello_World 0.00% 2.50% +2.50%
Progress_Planner\Abilities\Abilities 94.03% 95.35% +1.32%

📉 Coverage Decreased

Class Before After Change
Progress_Planner\Abilities\Recommendations 96.97% 91.20% -5.77%
ℹ️ 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

Adds progress-planner/complete-recommendation, which applies a
recommendation either by provider ID or, with no argument, the
highest-priority one that needs no value from the caller.

Seventeen providers qualify. The set is an explicit list, not everything
extending Tasks_Interactive: that base class also covers sending a test
email (a diagnostic, not a fix), deleting terms, and rewriting permalinks,
which flushes rewrite rules and changes every URL on the site. Each entry
in Recommendation_Fixes was read individually and is listed because it
writes one known setting and nothing else.

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 setting and its value always come from that table and never from caller
input, so no ability argument can reach an arbitrary option. Anything
outside the list is returned with status "manual" and an admin URL rather
than half-applied, and each SEO entry checks its plugin is active first.

Completion is observed, not asserted: after writing, 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>
@ilicfilip
ilicfilip force-pushed the filip/abilities-api-write branch from c626cd3 to 33607cb Compare September 18, 2026 11:01
ilicfilip and others added 3 commits September 18, 2026 14:06
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>
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