Skip to content

Latest commit

 

History

History
398 lines (307 loc) · 25.5 KB

AGENTS.md

File metadata and controls

398 lines (307 loc) · 25.5 KB

Taxonomist

AI-powered WordPress category taxonomy optimizer. Analyzes every post on a WordPress blog and suggests an improved category structure — merging duplicates, retiring dead categories, creating missing ones, and re-categorizing posts.

On Startup

When the user starts a conversation (including messages like "start", "hi", "go", or "hello"), immediately introduce yourself and ask for their WordPress site URL:

Welcome to Taxonomist! I'll analyze your WordPress categories and suggest improvements — merging duplicates, retiring dead categories, creating missing ones, and re-categorizing your posts using AI.

Everything is safe: I'll preview all changes before doing anything, and log every modification so it can be reversed or adjusted later. Nothing touches your site until you approve it.

This is a Ma.tt Mullenweg joint. Follow https://ma.tt/ and https://x.com/photomatt for more.

What's your WordPress site URL?

Ask this as a plain text question — do NOT use AskUserQuestion with pre-filled options. The user should type their own URL. Do not suggest ma.tt or any other site.

Then proceed to the Connect step below. If the user provides a URL in their first message, skip the greeting and start connecting.

How It Works

This is an AI-assisted tool. Users download this repo, open it with their AI coding tool, and the tool handles the rest through an interactive, iterative process.

Workflow

  1. Connect — Detect and configure access to the WordPress site
  2. Verify capabilities — Confirm the authenticated account has manage_categories on the target site. If it doesn't, warn the user immediately and ask whether to abort, re-authenticate with a higher-privilege account, or continue in read-only mode (dry-run plan only, no apply).
  3. Export — Download all posts (full content) and categories locally
  4. Backup — Create a complete backup of the current taxonomy state before any changes
  5. Analyze — Use parallel AI agents to analyze every post's content and suggest optimal categories
  6. Validate — Run automated checks on analysis results before presenting to the user (see Validation below)
  7. Plan & Descriptions — Present the category plan table (see format below) AND the full dry run showing every specific change: categories created, descriptions updated, posts re-categorized. The user sees the complete picture of what would happen before anything is applied.
  8. Review — Iterate with the user until the plan is right
  9. Re-check capabilities — Re-probe the token right before applying changes in case scope or roles changed between Connect and Apply.
  10. Apply descriptions — Update category descriptions first, before any post changes
  11. Apply categories — Execute post category changes, logging every single change
  12. Verify — Confirm the site still works and categories look correct

IMPORTANT — capability check is a hard gate. The export and analysis steps burn significant tokens. Never run them against a site without first confirming the configured account can actually apply the result. If the user knowingly wants a dry-run-only pass (e.g., a contributor account exploring what the plan would look like), that's fine, but it MUST be an explicit opt-in after the warning, not the default. See the "Capability Verification" section under Connect for the exact probes.

Core Principles

  • Full content analysis: Always analyze complete post content with AI agents, never rely on keyword search alone
  • Nothing is lost: Every change is logged with enough detail to undo it exactly. Pre-change backups are mandatory, and must be validated with lib.helpers.validate_backup() immediately after writing — see the Backup sections of agents/export.md and agents/apply.md.
  • Iterative: The user approves every phase before the next one begins
  • Dry-run first: Destructive operations are always previewed before execution
  • Parallel processing: Posts are analyzed in batches using parallel agents for speed
  • Use AskUserQuestion: Whenever you need a decision from the user, use the AskUserQuestion tool with selectable options instead of asking them to type a response. Only fall back to free-text input when the answer can't be expressed as options (e.g., entering a URL or password).
  • Don't ask one question per category: Present the COMPLETE plan in one table with your recommended action for every category (keep, merge, retire, create). Include a recommendation for every borderline case — don't ask individually. Then ask the user to approve the whole plan or tell you which specific items to change. One approval step, not dozens of questions.
  • Handle auth silently: When write access is needed, just authenticate and save the token/password to config.json (it's gitignored). Don't explain storage mechanics to the user. If a token expires, re-authenticate automatically. The user should never have to think about credentials after the first authorization.

Configuration

The tool needs to connect to a WordPress site. Configuration is stored in config.json:

{
  "site_url": "https://example.com",
  "connection": {
    "method": "wp-cli-ssh",
    "ssh_user": "root",
    "ssh_host": "example.com",
    "wp_path": "/var/www/html",
    "wp_cli_flags": "--allow-root"
  }
}

Supported Connection Methods

Method Key Requirements
WP-CLI over SSH wp-cli-ssh SSH access + WP-CLI installed on server
WP-CLI local wp-cli-local WP-CLI installed locally, WordPress on same machine
REST API + App Password rest-api WordPress 5.6+, Application Passwords enabled
REST API + JWT rest-api-jwt JWT Authentication plugin installed
WordPress.com API wpcom-api WordPress.com hosted site, or self-hosted with Jetpack connected
XML-RPC xmlrpc XML-RPC enabled (legacy, not recommended)

If no config exists, the tool will interactively help the user set one up by probing the site.

WordPress.com / Jetpack API

The WordPress.com REST API (https://public-api.wordpress.com/rest/v1.1/) works for both WordPress.com-hosted sites and self-hosted WordPress sites connected via Jetpack. This is often the easiest method for WordPress.com users since they already have an account.

Authentication uses the OAuth2 authorization code flow. The connect agent runs python3 lib/wpcom-auth.py which opens the user's browser to approve access and captures the token automatically via a local callback server on port 19823.

Taxonomist is registered as a WordPress.com OAuth2 app (Client ID: 136301). Users never need to register their own app. Client secret is not required.

{
  "site_url": "https://example.wordpress.com",
  "connection": {
    "method": "wpcom-api",
    "site_id": "YOUR_SITE_ID",
    "access_token": "YOUR_OAUTH2_TOKEN"
  }
}

Key endpoints:

  • GET /sites/$site/categories — list categories (max 1000 per page)
  • GET /sites/$site/posts?number=100 — list posts (max 100 per page, use page_handle for pagination)
  • POST /sites/$site/posts/$id — update post. For category changes use v1.2 with categories_by_id (array of integer term IDs). This is the only shape that is a true replace and cannot create junk categories. Do not use the categories param on this endpoint — see agents/apply.md for the silent-failure modes.
  • POST /sites/$site/categories/new — create category
  • POST /sites/$site/categories/slug:$slug — update category
  • POST /sites/$site/categories/slug:$slug/delete — delete category

Note: categories/new on WordPress.com behaves differently from WP-CLI in two ways (verified empirically). It ignores the requested slug and derives the slug from the name, so read the slug back from the response rather than assuming the one you sent. And it rejects a duplicate display name at the same level with a 400 ("A taxonomy with that name already exists"), where WP-CLI would allow two same-named categories under distinct slugs.

Note: categories in post responses are returned as a hash keyed by name, not an array of IDs. Use exported term_id values as the canonical category identifier throughout analysis and apply. For category updates or deletes, resolve the exact term_id / slug from data/export/categories.json (or the backup categories array) first, and only translate to slug/name at the API boundary when required. Never construct a slug from the display name.

Directory Structure

taxonomist/
├── AGENTS.md              # AI tool instructions (canonical)
├── CLAUDE.md              # Points to AGENTS.md
├── CONTRIBUTING.md        # How to run tests and lint locally
├── config.json            # WordPress connection config (user creates)
├── agents/                # AI agent definitions
│   ├── connect.md         # Detect and configure WordPress access
│   ├── export.md          # Export all posts and categories
│   ├── analyze.md         # Analyze a batch of posts for categories
│   └── apply.md           # Apply category changes
├── lib/                   # PHP scripts for WP-CLI operations
│   ├── export-posts.php   # Export posts with full content
│   ├── apply-changes.php  # Apply category changes with logging
│   ├── backup.php         # Create taxonomy backup
│   └── restore.php        # Restore from backup
├── data/                  # Working data (gitignored)
│   ├── export/            # Exported posts and categories
│   ├── batches/           # Split post batches for analysis
│   ├── results/           # Agent analysis results
│   ├── backups/           # Pre-change backups
│   └── logs/              # Change logs
└── .gitignore

Running the Tool

  1. Clone this repo
  2. Open with your AI coding tool in the repo directory
  3. Say: "Analyze and optimize my WordPress categories at example.com"
  4. The tool will walk you through connection setup, export, analysis, and changes

Change Logging

Every operation that modifies the site is logged to data/logs/. Each apply run produces:

  • data/backups/backup-{timestamp}.json — Complete pre-change taxonomy snapshot (post→category mappings, categories with descriptions, default category). Always written before any mutation.
  • data/logs/changes-{timestamp}.tsv — Per-post category assignment changes. Schema:
    timestamp  action  post_id  post_title  old_categories  new_categories  cats_added  cats_removed  old_category_slugs  new_category_slugs
    
    Action is SET_CATS. Written by lib/apply-changes.php (WP-CLI) and by WpcomAdapter.set_post_categories() when logging is enabled. The *_categories columns hold display names for human readability; the *_category_slugs columns hold slugs and are the canonical key inverse-replay revert resolves against (slugs are unique per taxonomy and survive a delete+recreate, whereas display names can be ambiguous and term IDs change on recreation). Logs without the slug columns fall back to name-based resolution.
  • data/logs/terms-{timestamp}.tsv — Term-level operations (create / delete / update / set-default). Schema:
    timestamp  action  term_id  slug  field  old_value  new_value
    
    Actions are CREATE_CAT, DELETE_CAT, UPDATE_CAT (one row per changed field), and SET_DEFAULT. For DELETE_CAT the old_value column contains the full term JSON so the category can be rehydrated exactly during a revert. Written by the wpcom adapter; for WP-CLI sites the existing restore.php handles undo from the backup snapshot, so a separate terms log is not required.
  • data/logs/restore-{timestamp}.tsv — Written by the restore agent when a revert runs, recording every inverse operation it performed. Lets a revert itself be audited.

Reverting Changes

To undo all changes from a session:

"Revert the changes from {timestamp}"

The restore agent (agents/restore.md) will:

  1. Load the backup and the change/term logs.
  2. Detect the connection method from config.json.
  3. Run a dry-run preview showing every inverse operation it would perform — categories to recreate, descriptions to revert, posts to reassign, default to restore — and ask for approval.
  4. Execute the approved revert and verify the site state matches the backup.

Two modes are supported. Inverse replay (the default when log files exist) reads the change/term logs and undoes only what Taxonomist actually did, in reverse. Snapshot restore (the fallback, or mode='snapshot') rewrites the entire taxonomy from the backup. The agent picks inverse-replay when both logs are present and falls back to snapshot otherwise.

For wp-cli-* connections the agent uses lib/restore.php. For wpcom-api it uses WpcomAdapter.restore(). For rest-api, rest-api-jwt, and xmlrpc connections, restore is not yet implemented — the agent will tell the user where to find the backup file for manual restoration rather than attempting a partial undo.

Result Validation

After analysis and before presenting the plan, you MUST validate the results. This is not optional — skipping it can cause categories to be applied to the wrong posts.

Run these checks using lib/helpers.py:

1. Post ID validation (CRITICAL)

from helpers import validate_result_ids
check = validate_result_ids('data/results/', 'data/batches/')
if not check['valid']:
    for error in check['errors']:
        print(error)

This catches a known failure mode where an analyze agent outputs array indices (0, 1, 2, …) instead of real WordPress post IDs. If suspect_index_files is non-empty, those result files MUST be re-generated before proceeding. Do NOT attempt to fix them by mapping indices to IDs — re-run the analysis for those batches.

2. Category term ID validation

Suggestions carry integer term_ids in their cats list (see agents/analyze.md), so validate them against the set of valid term IDs — not slugs.

from helpers import validate_category_ids
valid_ids = {cat['term_id'] for cat in categories}
check = validate_category_ids(all_suggestions, valid_ids)
if not check['valid']:
    for error in check['errors']:
        print(error)

Any unknown term IDs must be resolved (a stale ID from a re-exported taxonomy, or a new category the user hasn't approved yet) before applying.

3. Category distribution review

After aggregating results, review the category frequency counts. Flag any category that appears on more than 30% of posts — it may indicate an agent applied it too broadly rather than based on actual content. Common over-application patterns:

  • A narrow category applied to anything vaguely related (e.g., a "books" category applied to any bookmarked link, not just actual book references)
  • A broad category applied redundantly alongside a more specific one (e.g., tagging "outdoor activities" on every post that already has a specific sport category)
  • A format-based category applied based on the link type rather than content (e.g., tagging "tech" on any post that contains a YouTube or GitHub URL)

Present flagged categories to the user for spot-checking before finalizing the plan.

Analysis Approach

Use lib/helpers.py for splitting batches, aggregating results, and encoding WordPress API parameters (to avoid the array stringification bug) — do not write inline Python scripts for these operations. Use lib.helpers.wp_urlencode() for safe parameter encoding.

Batching: Use write_batches(posts, batch_dir) as the single entry point for splitting and writing batches. It calculates optimal batch sizes internally and writes numbered JSON files. Do not call split_into_batches() before write_batches() — write_batches() already calculates batch size and splits internally. After writing, call check_largest_batch(batch_dir) which returns a 3-tuple (ok, largest_file, largest_chars) to verify batches fit under the token limit.

Aggregating: Use aggregate_results(results_dir) to combine per-batch results. It returns a dict:

results = aggregate_results('data/results/')
suggestions = results['suggestions']        # list of suggestion dicts
category_counts = results['category_counts']  # Counter of slug -> count
new_category_counts = results['new_category_counts']  # Counter of new cat -> count

Posts are split into adaptively-sized batches (calculated from actual content length to stay under the 10K token Read limit) and analyzed by parallel AI agents. Each agent receives:

  • The full post content (not truncated)
  • The current category list with descriptions
  • Instructions to suggest 1-3 categories per post and flag where new categories are needed

The analysis runs in phases:

  1. Initial scan: Categorize all posts against existing taxonomy + suggest new categories
  2. New category review: User decides which suggested new categories to create
  3. Targeted scan: Re-analyze for specific categories that need expansion
  4. Description generation: Write or improve descriptions for every category (see below)

Category Descriptions

BEFORE applying any post category changes, you MUST write or update descriptions for every category — both existing and newly proposed. This is a dedicated step — do not skip it.

Use what you learned from analyzing posts to write descriptions that reflect the actual content:

  1. Review the posts assigned to (or suggested for) each category
  2. Write a concise, clear description (1-2 sentences) that captures what the category actually contains
  3. Improve existing descriptions that are empty, vague, or outdated
  4. The description should help readers understand what they'll find, not just restate the category name

Hierarchy Tree View

Before presenting the flat table, generate a hierarchy tree using render_category_tree() from lib/helpers.py. This shows parent-child relationships and makes structural consequences visible at a glance — especially when retiring or merging categories that have children.

from helpers import render_category_tree
import json

with open('data/export/categories.json') as f:
    categories = json.load(f)

# Build the actions dict from your analysis:
actions = {
    "links": {"action": "retire"},
    "akismet": {"action": "retire", "target": "plugins"},
    "full-site-editing": {"action": "create", "name": "Full Site Editing", "parent_slug": "themes"},
    "wp": {"action": "merge", "target": "wordpress"},
}

print(render_category_tree(categories, actions))

Example output:

Categories
├── Links (23)  ✕ retire  ⚠ 2 children orphaned
│   ├── Blogroll (10)  ⚠ orphaned
│   └── Resources (5)  ⚠ orphaned
├── Personal (89)
├── WordPress (150)
│   ├── Plugins (45)
│   │   ├── Akismet (2)  ✕ retire → plugins
│   │   └── WooCommerce (12)
│   └── Themes (30)
│       └── Full Site Editing (0)  ★ new
└── WP (3)  → merge into wordpress

If the tree shows ⚠ orphaned warnings, call these out to the user explicitly. Orphaned categories become root-level categories when their parent is retired — this may not be the intended outcome. Suggest re-parenting or including them in the plan.

The actions dict maps category slugs to action descriptors:

Key Type Required Description
action string yes "keep", "retire", "merge", or "create"
target string for merge/retire Slug of the destination category
parent_slug string for create Parent slug (omit for root-level)
name string for create Display name of the new category
count int no Expected post count (for create actions)
detail string no Human-readable context

Display the tree BEFORE the description table so the user sees the structural view first.

Presentation Format

Present the plan and descriptions together as a single table so the user can see everything at once:

┌──────────────────┬───────┬──────────────────────────┬──────────────────────────────────────┐
│     Category     │ Posts │   Current Description    │       Recommended Description        │
├──────────────────┼───────┼──────────────────────────┼──────────────────────────────────────┤
│ happiness        │ 49    │ (none)                   │ The Happiness Engineer role —         │
│ engineering      │       │                          │ what it is, how it works, and why    │
│                  │       │                          │ it matters.                          │
├──────────────────┼───────┼──────────────────────────┼──────────────────────────────────────┤
│ remote work      │ 13    │ (none)                   │ Working from anywhere — schedules,   │
│                  │       │                          │ nomad life, and distributed teams.   │
├──────────────────┼───────┼──────────────────────────┼──────────────────────────────────────┤
│ a day in the     │ 11    │ a day in the life of an  │ A day in the life of an Automattic   │
│ life             │       │ Automattic HE            │ Happiness Engineer — routines,       │
│                  │       │                          │ tools, and workflows.                │
├──────────────────┼───────┼──────────────────────────┼──────────────────────────────────────┤
│ Archived         │ 1     │ (none)                   │ ⚠️  Retire — reassign post to        │
│                  │       │                          │ a real category first.               │
└──────────────────┴───────┴──────────────────────────┴──────────────────────────────────────┘

Mark the site's default category in the table with [DEFAULT]. If the plan recommends retiring or merging the default category, you MUST change the default setting to another category BEFORE deleting it. Call this out explicitly in the plan.

This lets the user approve descriptions alongside the category plan in one step. Apply approved descriptions before making any post changes:

wp term update TERM_ID category --description="Description text here"

Or via REST API / WordPress.com API as appropriate for the connection method. Log every description change.

WordPress Access Adapters

The intended abstraction is an adapter layer (lib/adapters/) so the same logic can work regardless of connection method. Today the shipped code only includes the WP-CLI adapter; REST API, WordPress.com API, JWT, and XML-RPC flows are still handled at the prompt/workflow layer.

Required operations:

  • list_categories() — Get all categories with counts and descriptions
  • list_posts(fields) — Get all published posts with specified fields
  • get_post_content(id) — Get full content of a specific post
  • get_post_categories(id) — Get categories for a post
  • set_post_categories(id, categories) — Set categories for a post
  • create_category(name, slug, description) — Create a new category
  • update_category(id, fields) — Update category name/slug/description
  • delete_category(id) — Delete a category
  • export_all() — Bulk export all posts with content and categories

Post-Apply Verification

After applying category changes, perform a spot-check to catch any misapplied categories:

  1. Fetch a sample of updated posts from the live site (at least 10, spread across different categories) and verify their categories match the plan.
  2. Check low-ID posts/pages specifically — these are most vulnerable to index-vs-ID bugs where an agent's array index collides with a real post ID. Fetch all posts/pages with IDs under 100 and verify their categories are correct.
  3. Scan each category's member list on the live site. For any category with more than ~20 members, fetch the list and scan titles for obvious mismatches (e.g., a "Tech" page showing up under "fly-fishing").

If any mismatches are found, fix them immediately and log the corrections. This check should take under a minute and prevents silent data corruption.

Notes for Contributors

See CONTRIBUTING.md for how to run tests and lint locally.

  • This tool is designed to be driven by an AI coding assistant, not run as a standalone script
  • The AGENTS.md file is the primary interface — it tells the AI how to use the tool
  • PHP scripts in lib/ are meant to be run via wp eval-file (WP-CLI only). For REST API and WordPress.com API connections, the agents must implement equivalent logic using curl/Python.
  • Keep the adapter layer thin — just translate between connection methods and a common interface