wp-studio 1.7.9 → 1.7.10-beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli/{_events-ByiJRMQ1.mjs → _events-MbsmeZ64.mjs} +5 -5
  2. package/dist/cli/{certificate-manager-Bp1E0km4.mjs → certificate-manager-SVYcCL_i.mjs} +6 -1
  3. package/dist/cli/{delete-D1lAYFtg.mjs → delete-Br0zEYcv.mjs} +8 -4
  4. package/dist/cli/{helpers-Bh-WikQr.mjs → helpers-DE340RS-.mjs} +4 -153
  5. package/dist/cli/{index-CyYXE85k.mjs → index-4lan3TI_.mjs} +24 -4
  6. package/dist/cli/{index-DBCpWm8k.mjs → index-B-JMTSj2.mjs} +525 -256
  7. package/dist/cli/{index-BiCiEz-r.mjs → index-upH2G_fw.mjs} +510 -365
  8. package/dist/cli/{list-COLca0rr.mjs → list-CqctqyGC.mjs} +4 -3
  9. package/dist/cli/{login-OvZJPTV5.mjs → login-IwZhKWzT.mjs} +3 -3
  10. package/dist/cli/{logout-BaG-QFSN.mjs → logout-BJ7AzNzE.mjs} +3 -3
  11. package/dist/cli/main.mjs +2 -2
  12. package/dist/cli/paths-CqXGLB7R.mjs +195 -0
  13. package/dist/cli/plugin/.claude-plugin/plugin.json +5 -0
  14. package/dist/cli/plugin/skills/need-for-speed/SKILL.md +55 -0
  15. package/dist/cli/plugin/skills/site-spec/SKILL.md +35 -0
  16. package/dist/cli/plugin/skills/taxonomist/SKILL.md +270 -0
  17. package/dist/cli/plugin/skills/taxonomist/scripts/apply-changes.php +223 -0
  18. package/dist/cli/plugin/skills/taxonomist/scripts/backup.php +112 -0
  19. package/dist/cli/plugin/skills/taxonomist/scripts/export-posts.php +119 -0
  20. package/dist/cli/plugin/skills/taxonomist/scripts/restore.php +233 -0
  21. package/dist/cli/process-manager-daemon.mjs +11 -4
  22. package/dist/cli/{process-manager-ipc-heiF195f.mjs → process-manager-ipc-BisO0qtU.mjs} +1 -1
  23. package/dist/cli/proxy-daemon.mjs +1 -1
  24. package/dist/cli/prune-pm-logs-COryxqeo.mjs +41 -0
  25. package/dist/cli/resume-CNesCmkg.mjs +113 -0
  26. package/dist/cli/{rewrite-wp-cli-post-content-DH3hRTU5.mjs → rewrite-wp-cli-post-content-2zlfFnKT.mjs} +1 -1
  27. package/dist/cli/{set-DY9OcXFg.mjs → set-C4J6ru7I.mjs} +3 -3
  28. package/dist/cli/{set-BX9MWFxi.mjs → set-T8A1lBPU.mjs} +4 -4
  29. package/dist/cli/{status-CgY39wpU.mjs → status-CPcjT8jc.mjs} +2 -2
  30. package/dist/cli/{well-known-paths-CG_o9mSO.mjs → well-known-paths-BYA1Bw5o.mjs} +1 -1
  31. package/dist/cli/wordpress-server-child.mjs +2 -2
  32. package/dist/cli/{wp-DeUSBbLc.mjs → wp-A9VDe8QE.mjs} +2 -2
  33. package/dist/cli/wp-files/latest/available-site-translations.json +1 -1
  34. package/dist/cli/wp-files/skills/STUDIO.md +1 -1
  35. package/dist/cli/wp-files/skills/studio-cli/SKILL.md +1 -1
  36. package/package.json +9 -10
  37. package/patches/@mariozechner+pi-tui+0.54.0.patch +12 -0
  38. package/scripts/postinstall-npm.mjs +1 -0
  39. package/dist/cli/paths-BPK_RySX.mjs +0 -31
  40. package/dist/cli/resume-DshNzC7q.mjs +0 -62
@@ -1,9 +1,10 @@
1
+ import { l as listAiSessions, g as getAiSessionsRootDirectory } from "./paths-CqXGLB7R.mjs";
1
2
  import { __ } from "@wordpress/i18n";
2
- import { l as listAiSessions, a as displaySessionsCompact } from "./helpers-Bh-WikQr.mjs";
3
- import { L as LoggerError, d as Logger } from "./well-known-paths-CG_o9mSO.mjs";
3
+ import { d as displaySessionsCompact } from "./helpers-DE340RS-.mjs";
4
+ import { L as LoggerError, d as Logger } from "./well-known-paths-BYA1Bw5o.mjs";
4
5
  const logger = new Logger();
5
6
  async function runCommand(format) {
6
- const sessions = await listAiSessions();
7
+ const sessions = await listAiSessions(getAiSessionsRootDirectory());
7
8
  if (format === "json") {
8
9
  console.log(JSON.stringify(sessions, null, 2));
9
10
  return;
@@ -1,7 +1,7 @@
1
1
  import { input } from "@inquirer/prompts";
2
- import { z as PROTOCOL_PREFIX, B as CLIENT_ID, d as Logger, L as LoggerError, E as DEFAULT_TOKEN_LIFETIME_MS } from "./well-known-paths-CG_o9mSO.mjs";
3
- import { A as AUTH_EVENTS } from "./certificate-manager-Bp1E0km4.mjs";
4
- import { r as readAuthToken, A as AuthCommandLoggerAction, n as getAppLocale, o as openBrowser, g as getUserInfo, u as updateSharedConfig, p as emitCliEvent } from "./index-BiCiEz-r.mjs";
2
+ import { z as PROTOCOL_PREFIX, B as CLIENT_ID, d as Logger, L as LoggerError, E as DEFAULT_TOKEN_LIFETIME_MS } from "./well-known-paths-BYA1Bw5o.mjs";
3
+ import { A as AUTH_EVENTS } from "./certificate-manager-SVYcCL_i.mjs";
4
+ import { r as readAuthToken, A as AuthCommandLoggerAction, p as getAppLocale, o as openBrowser, g as getUserInfo, u as updateSharedConfig, q as emitCliEvent } from "./index-upH2G_fw.mjs";
5
5
  import { __, sprintf } from "@wordpress/i18n";
6
6
  const SCOPES = "global";
7
7
  const REDIRECT_URI = `${PROTOCOL_PREFIX}://auth`;
@@ -1,7 +1,7 @@
1
- import { A as AUTH_EVENTS } from "./certificate-manager-Bp1E0km4.mjs";
2
- import { A as AuthCommandLoggerAction, r as readAuthToken, q as revokeAuthToken, u as updateSharedConfig, p as emitCliEvent } from "./index-BiCiEz-r.mjs";
1
+ import { A as AUTH_EVENTS } from "./certificate-manager-SVYcCL_i.mjs";
2
+ import { A as AuthCommandLoggerAction, r as readAuthToken, s as revokeAuthToken, u as updateSharedConfig, q as emitCliEvent } from "./index-upH2G_fw.mjs";
3
3
  import { __ } from "@wordpress/i18n";
4
- import { d as Logger, L as LoggerError } from "./well-known-paths-CG_o9mSO.mjs";
4
+ import { d as Logger, L as LoggerError } from "./well-known-paths-BYA1Bw5o.mjs";
5
5
  async function runCommand() {
6
6
  const logger = new Logger();
7
7
  logger.reportStart(AuthCommandLoggerAction.LOGOUT, __("Logging out…"));
package/dist/cli/main.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import "node:path";
3
- import "./index-BiCiEz-r.mjs";
3
+ import "./index-upH2G_fw.mjs";
4
4
  import "@wordpress/i18n";
5
5
  import "semver";
6
6
  import "yargs";
7
- import "./certificate-manager-Bp1E0km4.mjs";
7
+ import "./certificate-manager-SVYcCL_i.mjs";
@@ -0,0 +1,195 @@
1
+ import "crypto";
2
+ import fs from "fs/promises";
3
+ import path__default from "path";
4
+ import { G as getAppdataDirectory } from "./rewrite-wp-cli-post-content-2zlfFnKT.mjs";
5
+ const SESSION_FILE_EXTENSION = ".jsonl";
6
+ const SESSION_ID_REGEX = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i;
7
+ function buildAiSessionFileName(startedAt, sessionId) {
8
+ const sortableTimestamp = startedAt.toISOString().replace(/:/g, "-").replace(/\.\d{3}Z$/, "");
9
+ return `${sortableTimestamp}-${sessionId}${SESSION_FILE_EXTENSION}`;
10
+ }
11
+ function extractAiSessionIdFromFilePath(filePath) {
12
+ const fileName = path__default.basename(filePath, SESSION_FILE_EXTENSION);
13
+ const uuidMatch = fileName.match(SESSION_ID_REGEX);
14
+ return uuidMatch?.[1] ?? fileName;
15
+ }
16
+ function formatDatePart(value) {
17
+ return String(value).padStart(2, "0");
18
+ }
19
+ function getAiSessionsDirectoryForDate$1(rootDirectory, date) {
20
+ const year = String(date.getFullYear());
21
+ const month = formatDatePart(date.getMonth() + 1);
22
+ const day = formatDatePart(date.getDate());
23
+ return path__default.join(rootDirectory, year, month, day);
24
+ }
25
+ async function readAiSessionSummaryFromEvents(filePath, events) {
26
+ if (events.length === 0) {
27
+ return void 0;
28
+ }
29
+ const linkedAgentSessionIds = [];
30
+ let createdAt;
31
+ let updatedAt;
32
+ let sessionId = extractAiSessionIdFromFilePath(filePath);
33
+ let firstPrompt;
34
+ let ownerSitePath;
35
+ let ownerSiteName;
36
+ let selectedSiteName;
37
+ let activeEnvironment = "local";
38
+ let endReason;
39
+ let eventCount = 0;
40
+ for (const event of events) {
41
+ eventCount += 1;
42
+ updatedAt = event.timestamp;
43
+ if (event.type === "session.started") {
44
+ createdAt = event.timestamp;
45
+ if (event.sessionId.trim().length > 0) {
46
+ sessionId = event.sessionId;
47
+ }
48
+ }
49
+ if (event.type === "session.linked" && !linkedAgentSessionIds.includes(event.agentSessionId)) {
50
+ linkedAgentSessionIds.push(event.agentSessionId);
51
+ }
52
+ if (event.type === "site.selected") {
53
+ selectedSiteName = event.siteName;
54
+ if (ownerSitePath === void 0) {
55
+ ownerSitePath = event.sitePath;
56
+ ownerSiteName = event.siteName;
57
+ }
58
+ activeEnvironment = event.remote === true ? "live" : "local";
59
+ }
60
+ if (event.type === "environment.selected") {
61
+ activeEnvironment = event.environment;
62
+ }
63
+ if (event.type === "user.message" && event.source === "prompt" && !firstPrompt) {
64
+ firstPrompt = event.text;
65
+ }
66
+ if (event.type === "turn.closed") {
67
+ if (event.status === "error") {
68
+ endReason = "error";
69
+ } else if (event.status === "interrupted") {
70
+ endReason = "stopped";
71
+ }
72
+ }
73
+ }
74
+ const stats = await fs.stat(filePath);
75
+ const fallbackTimestamp = stats.mtime.toISOString();
76
+ return {
77
+ id: sessionId,
78
+ filePath,
79
+ createdAt: createdAt ?? fallbackTimestamp,
80
+ updatedAt: updatedAt ?? createdAt ?? fallbackTimestamp,
81
+ agentSessionId: linkedAgentSessionIds[linkedAgentSessionIds.length - 1],
82
+ linkedAgentSessionIds,
83
+ firstPrompt,
84
+ ownerSitePath,
85
+ ownerSiteName,
86
+ selectedSiteName,
87
+ activeEnvironment,
88
+ endReason,
89
+ eventCount
90
+ };
91
+ }
92
+ async function readAiSessionEventsFromFile(filePath) {
93
+ const content = await fs.readFile(filePath, "utf8");
94
+ const lines = content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
95
+ const events = [];
96
+ for (const line of lines) {
97
+ try {
98
+ events.push(JSON.parse(line));
99
+ } catch {
100
+ }
101
+ }
102
+ return events;
103
+ }
104
+ async function listSessionFilesRecursively(directory) {
105
+ try {
106
+ const entries = await fs.readdir(directory, { withFileTypes: true, encoding: "utf8" });
107
+ const nestedFiles = await Promise.all(
108
+ entries.map(async (entry) => {
109
+ const fullPath = path__default.join(directory, entry.name);
110
+ if (entry.isDirectory()) {
111
+ return listSessionFilesRecursively(fullPath);
112
+ }
113
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
114
+ return [fullPath];
115
+ }
116
+ return [];
117
+ })
118
+ );
119
+ return nestedFiles.flat();
120
+ } catch (error) {
121
+ const fsError = error;
122
+ if (fsError.code === "ENOENT") {
123
+ return [];
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+ async function resolveSessionByIdOrPrefix(rootDirectory, sessionIdOrPrefix) {
129
+ const sessions = await listAiSessions(rootDirectory);
130
+ const exactMatch = sessions.find((session) => session.id === sessionIdOrPrefix);
131
+ const candidates = exactMatch ? [exactMatch] : sessions.filter((session) => session.id.startsWith(sessionIdOrPrefix));
132
+ if (candidates.length === 0) {
133
+ throw new Error(`Code session not found: ${sessionIdOrPrefix}`);
134
+ }
135
+ if (candidates.length > 1) {
136
+ const sample = candidates.slice(0, 5).map((session) => session.id).join(", ");
137
+ throw new Error(
138
+ `Session id prefix is ambiguous: ${sessionIdOrPrefix}. Matches: ${sample}${candidates.length > 5 ? ", …" : ""}`
139
+ );
140
+ }
141
+ return candidates[0];
142
+ }
143
+ async function pruneEmptySessionDirectories(rootDirectory, startDirectory) {
144
+ let currentDirectory = startDirectory;
145
+ while (currentDirectory.startsWith(rootDirectory + path__default.sep) && currentDirectory !== rootDirectory) {
146
+ try {
147
+ await fs.rmdir(currentDirectory);
148
+ } catch (error) {
149
+ const fsError = error;
150
+ if (fsError.code === "ENOTEMPTY" || fsError.code === "ENOENT") {
151
+ return;
152
+ }
153
+ throw error;
154
+ }
155
+ currentDirectory = path__default.dirname(currentDirectory);
156
+ }
157
+ }
158
+ async function listAiSessions(rootDirectory) {
159
+ const sessionFiles = await listSessionFilesRecursively(rootDirectory);
160
+ const results = await Promise.allSettled(
161
+ sessionFiles.map(async (filePath) => {
162
+ const events = await readAiSessionEventsFromFile(filePath);
163
+ return readAiSessionSummaryFromEvents(filePath, events);
164
+ })
165
+ );
166
+ const sessions = results.filter(
167
+ (result) => result.status === "fulfilled"
168
+ ).map((result) => result.value).filter((session) => !!session);
169
+ return sessions.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
170
+ }
171
+ async function loadAiSession(rootDirectory, sessionIdOrPrefix) {
172
+ const summary = await resolveSessionByIdOrPrefix(rootDirectory, sessionIdOrPrefix);
173
+ const events = await readAiSessionEventsFromFile(summary.filePath);
174
+ return { summary, events };
175
+ }
176
+ async function deleteAiSession(rootDirectory, sessionIdOrPrefix) {
177
+ const sessionToDelete = await resolveSessionByIdOrPrefix(rootDirectory, sessionIdOrPrefix);
178
+ await fs.rm(sessionToDelete.filePath, { force: false });
179
+ await pruneEmptySessionDirectories(rootDirectory, path__default.dirname(sessionToDelete.filePath));
180
+ return sessionToDelete;
181
+ }
182
+ function getAiSessionsRootDirectory() {
183
+ return path__default.join(getAppdataDirectory(), "sessions");
184
+ }
185
+ function getAiSessionsDirectoryForDate(date) {
186
+ return getAiSessionsDirectoryForDate$1(getAiSessionsRootDirectory(), date);
187
+ }
188
+ export {
189
+ loadAiSession as a,
190
+ getAiSessionsDirectoryForDate as b,
191
+ buildAiSessionFileName as c,
192
+ deleteAiSession as d,
193
+ getAiSessionsRootDirectory as g,
194
+ listAiSessions as l
195
+ };
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "studio",
3
+ "description": "WordPress Studio AI skills",
4
+ "version": "1.0.0"
5
+ }
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: need-for-speed
3
+ description: Run a frontend performance audit on a WordPress site and get actionable optimization recommendations.
4
+ user-invokable: true
5
+ ---
6
+
7
+ # Performance Audit
8
+
9
+ Run a performance audit on a WordPress site to measure Core Web Vitals and page composition, then provide actionable recommendations.
10
+
11
+ ## How to Run
12
+
13
+ 1. Determine which site to audit. If the user hasn't specified, ask them or use the site from the current context.
14
+ 2. Ensure the site is running (use `site_start` if needed).
15
+ 3. Call `need_for_speed` with the site name and path (defaults to `/`).
16
+ 4. Analyze the results using the interpretation guide below.
17
+ 5. Present a clear summary with specific, actionable recommendations.
18
+
19
+ ## Interpreting Results
20
+
21
+ ### Core Web Vitals Thresholds
22
+
23
+ | Metric | Good | Needs Improvement | Poor |
24
+ |--------|------|-------------------|------|
25
+ | TTFB | < 800 ms | 800–1800 ms | > 1800 ms |
26
+ | FCP | < 1800 ms | 1800–3000 ms | > 3000 ms |
27
+ | LCP | < 2500 ms | 2500–4000 ms | > 4000 ms |
28
+ | CLS | < 0.1 | 0.1–0.25 | > 0.25 |
29
+
30
+ ### Page Composition Benchmarks
31
+
32
+ - **DOM elements**: Concern if > 1500
33
+ - **Total page weight**: Concern if > 3 MB
34
+ - **Total requests**: Concern if > 80
35
+ - **Scripts**: Concern if > 20 scripts or > 500 KB total
36
+ - **Stylesheets**: Concern if > 10 stylesheets or > 200 KB total
37
+
38
+ ## Common WordPress Recommendations
39
+
40
+ Based on the metrics, suggest specific actions:
41
+
42
+ - **High TTFB**: Enable page caching (e.g., `wp_cli: plugin install wp-super-cache --activate`), check for slow database queries, consider object caching.
43
+ - **High LCP / FCP**: Check for render-blocking CSS/JS, add lazy loading for below-the-fold images, defer non-critical scripts.
44
+ - **Large JS payload**: Identify heavy plugins from the scripts URL list, suggest deactivating unused plugins, check for jQuery dependency chains.
45
+ - **Large CSS payload**: Look for unused theme stylesheets, check for multiple Google Fonts loads.
46
+ - **Many HTTP requests**: Suggest reducing plugin count, enabling asset concatenation.
47
+ - **High CLS**: Check for images without explicit width/height dimensions, ads or dynamic content injection, web fonts causing layout shifts.
48
+ - **Large DOM**: Check theme template complexity, excessive nesting in block content, too many wrapper elements.
49
+
50
+ ## Important Notes
51
+
52
+ - These are **synthetic measurements on a local dev server**. TTFB will be artificially low compared to production. Focus on relative comparisons (before/after changes) rather than absolute values.
53
+ - CLS is measured during page load only, not during user interaction.
54
+ - INP (Interaction to Next Paint) is not measured — it requires real user interaction patterns.
55
+ - Page weight may undercount if some resources report 0 transfer size (e.g., service worker cached resources).
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: site-spec
3
+ description: Gather the site name and layout preference before building a WordPress site. Run this before creating any new site.
4
+ user-invokable: true
5
+ ---
6
+
7
+ # Site Spec Discovery
8
+
9
+ Before creating a new WordPress site, gather the user's basic preferences through a short interactive discovery phase. This produces a **Site Spec** that guides all subsequent design and development decisions.
10
+
11
+ ## How to Run
12
+
13
+ Gather preferences through 2 rounds. Keep it concise.
14
+
15
+ **AskUserQuestion constraints**: Each call supports 1-4 questions, each with 2-4 options. An "Other" free-form option is automatically provided by the system — do NOT add one yourself. Keep option labels short (1-5 words). Only use AskUserQuestion for questions that have meaningful predefined options. For open-ended questions (like asking for a name), just ask in your text output — the user will type their answer in the prompt.
16
+
17
+ ### Round 1 — Name
18
+
19
+ Ask the user for their business/site name in your text output. **Stop here and wait for their reply** — do NOT call any tools or continue to the next round. The user needs a chance to type their answer in the prompt.
20
+
21
+ ### Round 2 — Layout
22
+
23
+ After the user provides the name, use AskUserQuestion for:
24
+ - One-page site or multi-page site? (e.g., single scrollable page with sections vs. separate pages for each area)
25
+
26
+ ## After Gathering Answers
27
+
28
+ Call `site_create` with the provided name and use the layout preference to guide all subsequent design decisions.
29
+
30
+ ## When to Skip Discovery
31
+
32
+ Do NOT ask questions if:
33
+ - The user already provided the name and layout preference in the initial prompt. Proceed directly with site creation.
34
+ - The user says "just build something" or "surprise me". Pick a bold creative direction yourself and proceed.
35
+ - The user explicitly asks to skip the setup or says they don't want questions.
@@ -0,0 +1,270 @@
1
+ ---
2
+ name: taxonomist
3
+ description: Analyze and optimize a WordPress site's category taxonomy. Exports all posts, uses AI to suggest an improved category structure — merging duplicates, retiring dead categories, creating missing ones, writing descriptions, and re-categorizing posts. Run this when the user wants to clean up or improve their categories.
4
+ user-invokable: true
5
+ ---
6
+
7
+ # Taxonomist
8
+
9
+ AI-powered WordPress category taxonomy optimizer. Analyzes every post on a WordPress site and suggests an improved category structure — merging duplicates, retiring dead categories, creating missing ones, and re-categorizing posts.
10
+
11
+ Based on [Taxonomist](https://github.com/m/taxonomist) by Matt Mullenweg.
12
+
13
+ ## On Startup
14
+
15
+ When the user invokes this skill, introduce yourself:
16
+
17
+ > **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.
18
+ >
19
+ > Everything is safe: I'll preview all changes before doing anything, and log every modification so it can be reversed. Nothing touches your site until you approve it.
20
+
21
+ Then identify the target site. If there's only one local Studio site, use it automatically. If there are multiple, ask which one to analyze.
22
+
23
+ **Before anything else**, call the `install_taxonomy_scripts` tool with the target site to set up the PHP scripts.
24
+
25
+ ## How It Works
26
+
27
+ This skill operates through an interactive, step-by-step process on a local Studio site:
28
+
29
+ 1. **Connect** — Identify the target local site and verify it's running
30
+ 2. **Export** — Download all posts (full content) and categories to local JSON
31
+ 3. **Backup** — Snapshot current taxonomy state before any changes
32
+ 4. **Analyze** — Use parallel sub-agents to analyze every post's content and suggest optimal categories
33
+ 5. **Plan** — Present a comprehensive category plan with descriptions
34
+ 6. **Review** — Iterate with the user until the plan is approved
35
+ 7. **Apply descriptions** — Update category descriptions first
36
+ 8. **Apply categories** — Execute post re-categorization, logging every change
37
+ 9. **Verify** — Confirm site integrity
38
+
39
+ **Steps 1-6 require NO write access to the site.** The site is only modified after explicit user approval.
40
+
41
+ ## Working Directory
42
+
43
+ All data files go in a `taxonomist-data/` directory inside the site root:
44
+
45
+ ```
46
+ {site_path}/taxonomist-data/
47
+ ├── export/
48
+ │ ├── posts.json # Exported posts with full content
49
+ │ └── categories.json # Current category list
50
+ ├── batches/
51
+ │ ├── batch-000.json # Posts split into analysis batches
52
+ │ ├── batch-001.json
53
+ │ └── ...
54
+ ├── results/
55
+ │ ├── batch-000-results.json
56
+ │ └── ...
57
+ ├── backups/
58
+ │ └── pre-analysis-{timestamp}.json
59
+ └── logs/
60
+ └── changes-{timestamp}.tsv
61
+ ```
62
+
63
+ ## Step 1: Connect
64
+
65
+ 1. Use `site_list` to find available sites
66
+ 2. If multiple sites exist, ask the user which one to analyze
67
+ 3. Use `site_info` to verify the site is running
68
+ 4. If the site is stopped, start it with `site_start`
69
+ 5. Verify WordPress is working: `wp_cli` with `eval 'echo "OK";'`
70
+
71
+ ## Step 2: Export
72
+
73
+ Create the working directory structure, then export posts and categories.
74
+
75
+ ### Export categories
76
+
77
+ Use `wp_cli`:
78
+ ```
79
+ term list category --format=json --fields=term_id,name,slug,description,count,parent
80
+ ```
81
+
82
+ Save the output to `taxonomist-data/export/categories.json`.
83
+
84
+ ### Export posts
85
+
86
+ Use `wp_cli`:
87
+ ```
88
+ eval-file tmp/taxonomist/export-posts.php
89
+ ```
90
+
91
+ With the environment variable:
92
+ ```
93
+ TAXONOMIST_OUTPUT={site_path}/taxonomist-data/export/posts.json
94
+ ```
95
+
96
+ ### Post-export summary
97
+
98
+ Report to the user:
99
+ - Total posts exported
100
+ - Total categories found
101
+ - Top 20 categories by post count
102
+ - Any categories with 0 posts (candidates for retirement)
103
+ - The default category (cannot be deleted without changing the setting first)
104
+
105
+ ## Step 3: Backup
106
+
107
+ Create a full taxonomy snapshot before any analysis.
108
+
109
+ Use `wp_cli`:
110
+ ```
111
+ eval-file tmp/taxonomist/backup.php
112
+ ```
113
+
114
+ With the environment variable:
115
+ ```
116
+ TAXONOMIST_OUTPUT={site_path}/taxonomist-data/backups/pre-analysis-{timestamp}.json
117
+ ```
118
+
119
+ ## Step 4: Analyze
120
+
121
+ Split exported posts into batches and analyze each batch with a sub-agent.
122
+
123
+ ### Batch splitting
124
+
125
+ Read `taxonomist-data/export/posts.json` and split into batch files of ~20-50 posts each (adjust based on average post length — aim for batches that fit within a single agent context). Write each batch to `taxonomist-data/batches/batch-NNN.json`.
126
+
127
+ ### Parallel analysis
128
+
129
+ For each batch, spawn a sub-agent (use the Agent tool with model "haiku" for efficiency) with this prompt:
130
+
131
+ > Analyze these blog posts and suggest optimal category assignments.
132
+ >
133
+ > **Existing categories:** {list from categories.json with slugs}
134
+ >
135
+ > **Instructions:**
136
+ > - Read the FULL content of each post, not just the title
137
+ > - Suggest 1-3 categories per post using category **slugs** (not display names)
138
+ > - Prefer existing categories over creating new ones
139
+ > - Only propose a new category if the topic is genuinely unserved AND would apply to multiple posts
140
+ > - Avoid generic catch-alls like "Uncategorized" or "General"
141
+ > - For each post, provide a confidence level: "high", "medium", or "low"
142
+ >
143
+ > **Output format** (JSON array):
144
+ > ```json
145
+ > [
146
+ > {
147
+ > "post_id": 123,
148
+ > "cats": ["wordpress", "ai"],
149
+ > "new_cats": [],
150
+ > "confidence": "high"
151
+ > }
152
+ > ]
153
+ > ```
154
+ >
155
+ > If proposing a new category, add it to `new_cats` with a suggested slug and name:
156
+ > ```json
157
+ > "new_cats": [{"slug": "machine-learning", "name": "Machine Learning"}]
158
+ > ```
159
+ >
160
+ > **Batch data:**
161
+ > {batch JSON content}
162
+
163
+ Save each sub-agent's output to `taxonomist-data/results/batch-NNN-results.json`.
164
+
165
+ ### Aggregate results
166
+
167
+ After all batches complete:
168
+ 1. Merge all result files, de-duplicating by post_id
169
+ 2. Collect all proposed new categories across batches
170
+ 3. Compute category frequency statistics
171
+ 4. Save aggregated results to `taxonomist-data/results/aggregated.json`
172
+
173
+ ## Step 5: Plan
174
+
175
+ Present a single comprehensive table showing the recommended action for every category:
176
+
177
+ | Category | Posts | Action | Description |
178
+ |----------|-------|--------|-------------|
179
+ | WordPress | 142 | **Keep** | Articles about WordPress development, plugins, and the WordPress ecosystem |
180
+ | Tech | 89 | **Keep** | Technology industry news, trends, and analysis |
181
+ | Asides | 34 | **Retire** → merge into "Notes" | Short-form posts and quick thoughts |
182
+ | Uncategorised | 23 | **Retire** → re-categorize | Posts to be assigned proper categories |
183
+ | Machine Learning | — | **Create** | Posts about ML, neural networks, and AI model training |
184
+
185
+ Include:
186
+ - **Every existing category** with its current post count and recommended action (Keep / Rename / Merge / Retire)
187
+ - **Every proposed new category** with expected post count
188
+ - **Proposed descriptions** for all categories (new and existing)
189
+ - A summary of how many posts would be re-categorized
190
+
191
+ Then show the **full dry run** — a table of every post that would change, showing old categories → new categories.
192
+
193
+ ## Step 6: Review
194
+
195
+ **CRITICAL: You MUST use the `AskUserQuestion` tool here to get explicit approval before proceeding.** Do NOT continue to Step 7 without the user's explicit "yes" or approval.
196
+
197
+ Present the plan, then use `AskUserQuestion` with options like:
198
+ - "Approve and apply changes"
199
+ - "I want to adjust some categories first"
200
+ - "Cancel — don't make any changes"
201
+
202
+ If the user wants adjustments, iterate on the plan and use `AskUserQuestion` again for each revision until they approve.
203
+
204
+ ## Step 7: Apply Descriptions
205
+
206
+ After approval, first create any new categories and update descriptions using `wp_cli`:
207
+
208
+ ```
209
+ # Create new categories
210
+ term create category "Category Name" --slug=category-slug --description="Description here"
211
+
212
+ # Update existing category descriptions
213
+ term update category {term_id} --description="Updated description"
214
+ ```
215
+
216
+ ## Step 8: Apply Categories
217
+
218
+ Prepare the suggestions JSON file from the approved plan, then run the apply script.
219
+
220
+ First, do a **preview** (dry run) using `wp_cli`:
221
+
222
+ ```
223
+ eval-file tmp/taxonomist/apply-changes.php
224
+ ```
225
+
226
+ With environment variables:
227
+ ```
228
+ TAXONOMIST_SUGGESTIONS={site_path}/taxonomist-data/results/suggestions.json
229
+ TAXONOMIST_LOG={site_path}/taxonomist-data/logs/changes-{timestamp}.tsv
230
+ TAXONOMIST_MODE=preview
231
+ ```
232
+
233
+ Show the user the preview results. **Use `AskUserQuestion` to confirm** before applying. After they confirm, run again with:
234
+
235
+ ```
236
+ TAXONOMIST_SUGGESTIONS={site_path}/taxonomist-data/results/suggestions.json
237
+ TAXONOMIST_LOG={site_path}/taxonomist-data/logs/changes-{timestamp}.tsv
238
+ TAXONOMIST_MODE=apply
239
+ TAXONOMIST_REMOVE_CATS=uncategorized
240
+ ```
241
+
242
+ ## Step 9: Verify
243
+
244
+ After applying changes:
245
+
246
+ 1. List categories with counts using `wp_cli`: `term list category --format=table --fields=term_id,name,slug,count`
247
+ 2. Check for posts with no categories: `eval 'echo count(get_posts(["posts_per_page" => -1, "category__in" => [get_option("default_category")]]));'`
248
+ 3. Report the change log location to the user
249
+ 4. Remind them that a full backup exists and can be restored
250
+
251
+ ## Restoring from Backup
252
+
253
+ If the user wants to undo all changes, use `wp_cli`:
254
+
255
+ ```
256
+ eval-file tmp/taxonomist/restore.php
257
+ ```
258
+
259
+ With environment variable:
260
+ ```
261
+ TAXONOMIST_BACKUP={site_path}/taxonomist-data/backups/pre-analysis-{timestamp}.json
262
+ ```
263
+
264
+ ## Important Notes
265
+
266
+ - **Use the Studio MCP tools** (`site_list`, `site_info`, `site_start`, `wp_cli`, etc.) — not shell commands
267
+ - **Category slugs are the stable identifier** — always use slugs (not names or IDs) when referencing categories across steps
268
+ - **Never modify WordPress core files** — all changes go through WP-CLI commands
269
+ - **The default category cannot be deleted** — change it first via `wp_cli`: `option update default_category {new_id}` if needed
270
+ - **All data stays local** — exported posts, analysis results, and backups remain in the site's `taxonomist-data/` directory