From f0fe7bac08a9f7aafd8570ccd5b984fe873a9f35 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 15 Oct 2026 23:38:07 -0400 Subject: [PATCH 001/199] Studio Code: point the agent at the WordPress source on disk instead of re-teaching WordPress in skills Spike. The system prompt gains a 'WordPress source is the reference' section; block-content keeps only Studio mechanics (validate_blocks gate, core/html policy, skeleton-first recipes); visual-polish keeps its diagnose/fix/re-check method and drops the WordPress layout/button explainers; visual-design, build-site and edit-manage-site drop their WordPress how-it-works bullets. The eval runner records token usage and cost per run. --- apps/cli/ai/eval-runner.ts | 20 +++ apps/cli/ai/skills/block-content/SKILL.md | 172 +++---------------- apps/cli/ai/skills/build-site/SKILL.md | 2 +- apps/cli/ai/skills/edit-manage-site/SKILL.md | 3 +- apps/cli/ai/skills/visual-design/SKILL.md | 8 +- apps/cli/ai/skills/visual-polish/SKILL.md | 65 +------ apps/cli/ai/system-prompt.ts | 16 +- 7 files changed, 69 insertions(+), 217 deletions(-) diff --git a/apps/cli/ai/eval-runner.ts b/apps/cli/ai/eval-runner.ts index b6baf2b4c3..e88e7136f7 100644 --- a/apps/cli/ai/eval-runner.ts +++ b/apps/cli/ai/eval-runner.ts @@ -454,6 +454,16 @@ async function runEval( input: EvalRunnerInput ) { let error: string | null = null; let timedOut = false; let lastTurnEndedEmpty = false; + // Summed from each assistant message's usage, so evals can compare the + // token and dollar cost of prompt variants, not just their outcomes. + const usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + costUsd: 0, + }; let seeded: SeededFixtures | null = null; let inspections: Inspection[] = []; @@ -479,6 +489,15 @@ async function runEval( input: EvalRunnerInput ) { const handleEvent = ( event: AgentSessionEvent ): void => { if ( event.type === 'message_end' && event.message.role === 'assistant' ) { + const messageUsage = event.message.usage; + if ( messageUsage ) { + usage.input += messageUsage.input; + usage.output += messageUsage.output; + usage.cacheRead += messageUsage.cacheRead; + usage.cacheWrite += messageUsage.cacheWrite; + usage.totalTokens += messageUsage.totalTokens; + usage.costUsd += messageUsage.cost.total; + } const now = Date.now(); turnDurationsMs.push( now - turnStart ); turnIndex += 1; @@ -602,6 +621,7 @@ async function runEval( input: EvalRunnerInput ) { autoContinues, phaseTimingsMs, turnDurationsMs, + usage, toolCalls, toolResults, toolEvents, diff --git a/apps/cli/ai/skills/block-content/SKILL.md b/apps/cli/ai/skills/block-content/SKILL.md index 4507f69167..a7dc7733fc 100644 --- a/apps/cli/ai/skills/block-content/SKILL.md +++ b/apps/cli/ai/skills/block-content/SKILL.md @@ -1,173 +1,45 @@ --- name: block-content -description: Write editable WordPress block markup and theme files for local Studio sites, including where theme changes go (block themes only, child themes), core/html limits, block-theme layout rules, full-width sections, validation, and skeleton-first page/CSS recipes. +description: Write editable WordPress block markup and theme files for local Studio sites — read the WordPress source the markup depends on, keep content editable, validate it with validate_blocks, and use the skeleton-first recipes for long page and CSS files. --- # Block Content Use this skill before writing or editing page content, post content, templates, template parts, patterns, or any other WordPress block markup, and before changing an existing theme's files. -## Theme Files +## Read the source first -- Build new themes as block themes. Work on an existing classic theme in its own terms rather than converting it, and build a new classic theme only when the user explicitly asks — first say once that Studio's design flow and block tools are built for block themes. -- Do NOT edit the files of installed third-party themes (default themes like twentytwentyfive, marketplace/community themes such as Ollie, anything installed via `wp theme install` or already present on the site) — a theme update silently wipes such edits. Default to a child theme: call `scaffold_theme` with `parentTheme` set to the installed theme's slug, then make every customization (style.css, theme.json, templates, parts, patterns) in the child theme. Themes Studio Code created — their style.css Description says "scaffolded by Studio Code" — are safe to edit directly. If the user explicitly asks you to edit an installed theme's files directly, comply, but first warn once that a theme update will overwrite the changes. +The site's WordPress install is on disk, and it is the reference for how blocks, layouts, and themes behave. Before writing markup or CSS that depends on WordPress behavior, read the code that produces it: -## Core Policy +- A block's attributes, supports, and wrapper classes: `wp-includes/blocks//block.json`, and `render_block_core_*` in `wp-includes/blocks/.php` for dynamic blocks. +- What a block support renders (layout, spacing, color, typography, alignment): `wp-includes/block-supports/.php`. +- What `theme.json` keys do and what core supplies by default: `wp-includes/class-wp-theme-json.php` and `wp-includes/theme.json`. +- Which template renders a page: `wp-includes/template.php` and `wp-includes/block-template-utils.php`. +- The active theme: its `theme.json`, `style.css`, `templates/`, `parts/`, and `functions.php`. A template saved in the Site Editor lives in the database (`wp_cli post list --post_type=wp_template,wp_template_part`) and wins over the file. -- Use editable WordPress blocks for content and layout. Prefer `core/group`, `core/columns`, `core/heading`, `core/paragraph`, `core/list`, `core/image`, `core/buttons`, and theme CSS. -- Only use `core/html` blocks for inline SVGs, interaction markup with no block equivalent such as marquee or custom cursor markup, or a single bottom-of-page `ipt>' }, - } ), - isError: false, - }, - false - ); - - expect( stripAnsi( rendered ?? '' ) ).not.toContain( '<' ); - } ); - it( 'does not trigger cap detection for non-wpcom providers even with a 429 error', () => { const ui = Object.create( AiChatUI.prototype ) as { handleEvent: ( e: unknown ) => unknown; diff --git a/apps/cli/ai/tests/wpcom-capabilities.test.ts b/apps/cli/ai/tests/wpcom-capabilities.test.ts deleted file mode 100644 index ea87dda36c..0000000000 --- a/apps/cli/ai/tests/wpcom-capabilities.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - describeWpcomSiteCapabilities, - getWpcomWriteRefusal, - parseWpcomSiteCapabilities, - parseWpcomUpgradePlans, - type WpcomSiteCapabilities, -} from '../wpcom-capabilities'; -// plan.features.active from real WordPress.com sites on each plan. -import planFeatures from './fixtures/wpcom-plan-features.json'; - -const admin = { manage_options: true, edit_posts: true }; - -function site( plan: keyof typeof planFeatures, capabilities = admin ): WpcomSiteCapabilities { - return parseWpcomSiteCapabilities( { - plan: { - product_name_short: plan, - features: { - active: planFeatures[ plan ], - available: { - 'install-plugins': [ 'personal-bundle', 'value_bundle', 'business-bundle', 'pro-plan' ], - 'custom-design': [ 'business-bundle', 'pro-plan' ], - }, - }, - }, - capabilities, - } )!; -} - -const globalStyles = ( css = '' ) => ( { - method: 'POST', - path: '/global-styles/12', - apiNamespace: 'wp/v2', - body: { styles: { color: { text: '#111' }, ...( css ? { css } : {} ) } }, -} ); -const installPlugin = { method: 'POST', path: '/plugins/jetpack/install', apiNamespace: '' }; - -// From a site's /sites/:id/plans: plans without a plan card are no longer offered, and -// Premium stands for a plan the site can only move down to. -const upgradePlans = async () => - parseWpcomUpgradePlans( { - '1': { product_slug: 'free_plan', plan_card_name: 'Free', plan_card_order: 1 }, - '1008': { - product_slug: 'business-bundle', - plan_card_name: 'Business', - plan_card_order: 4, - available_for_upgrade: true, - }, - '1009': { - product_slug: 'personal-bundle', - plan_card_name: 'Personal', - plan_card_order: 2, - available_for_upgrade: true, - }, - '1003': { - product_slug: 'value_bundle', - plan_card_name: 'Premium', - plan_card_order: 3, - available_for_upgrade: false, - }, - '1032': { product_slug: 'pro-plan', available_for_upgrade: true }, - } ); - -it( 'states the plan and the user role', () => { - expect( describeWpcomSiteCapabilities( site( 'Free' ) ) ).toBe( - 'Free plan; you manage this site' - ); - expect( - describeWpcomSiteCapabilities( site( 'Business', { manage_options: false, edit_posts: true } ) ) - ).toBe( 'Business plan; you edit content only' ); -} ); - -describe( 'getWpcomWriteRefusal', () => { - it( 'refuses a write the plan lacks a feature for, naming the plans offered that have it', async () => { - expect( await getWpcomWriteRefusal( site( 'Free' ), installPlugin, upgradePlans ) ).toBe( - "Not sent: this site's Free plan does not include install-plugins (WordPress.com plan features). Plans that include it: Personal, Business." - ); - expect( await getWpcomWriteRefusal( site( 'Free' ), globalStyles(), upgradePlans ) ).toContain( - 'does not include global-styles (' - ); - expect( await getWpcomWriteRefusal( site( 'Personal' ), globalStyles() ) ).toBeUndefined(); - } ); - - it( 'refuses custom CSS on Personal and allows it on Premium', async () => { - expect( - await getWpcomWriteRefusal( site( 'Personal' ), globalStyles( 'body{}' ), upgradePlans ) - ).toContain( - 'does not include custom-design (WordPress.com plan features). Plans that include it: Business.' - ); - expect( - await getWpcomWriteRefusal( site( 'Premium' ), globalStyles( 'body{}' ) ) - ).toBeUndefined(); - } ); - - it( 'gates plugin installs by plan', async () => { - expect( await getWpcomWriteRefusal( site( 'Free' ), installPlugin ) ).toContain( - 'install-plugins' - ); - expect( await getWpcomWriteRefusal( site( 'Personal' ), installPlugin ) ).toBeUndefined(); - } ); - - it( 'refuses admin changes to a user who does not manage the site', async () => { - const editor = site( 'Business', { manage_options: false, edit_posts: true } ); - expect( await getWpcomWriteRefusal( editor, installPlugin ) ).toContain( - 'A site administrator' - ); - expect( - await getWpcomWriteRefusal( editor, { - method: 'POST', - path: '/posts', - apiNamespace: 'wp/v2', - } ) - ).toBeUndefined(); - } ); - - it( 'never gates reads or content writes', async () => { - expect( - await getWpcomWriteRefusal( site( 'Free' ), { - method: 'GET', - path: '/plugins', - apiNamespace: '', - } ) - ).toBeUndefined(); - expect( - await getWpcomWriteRefusal( site( 'Free' ), { - method: 'POST', - path: '/pages/4', - apiNamespace: 'wp/v2', - } ) - ).toBeUndefined(); - } ); -} ); diff --git a/apps/cli/ai/tests/wpcom-request.test.ts b/apps/cli/ai/tests/wpcom-request.test.ts deleted file mode 100644 index def8abe68a..0000000000 --- a/apps/cli/ai/tests/wpcom-request.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'fs/promises'; -import os from 'os'; -import path from 'path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { wpcomRequestTool } from 'cli/ai/tools/wpcom-request'; - -const mocks = vi.hoisted( () => ( { - req: { - post: vi.fn(), - get: vi.fn(), - }, - readAuthToken: vi.fn(), - payloadsDir: '', -} ) ); - -vi.mock( '@studio/common/lib/wpcom-factory', () => ( { - default: vi.fn( () => ( { req: mocks.req } ) ), -} ) ); - -vi.mock( '@studio/common/lib/wpcom-xhr-request-factory', () => ( { - default: vi.fn(), -} ) ); - -vi.mock( '@studio/common/lib/shared-config', () => ( { - readAuthToken: mocks.readAuthToken, -} ) ); - -vi.mock( '@studio/common/lib/well-known-paths', () => ( { - getAiPayloadsPath: () => mocks.payloadsDir, -} ) ); - -describe( 'wpcom_request', () => { - beforeEach( async () => { - vi.resetAllMocks(); - mocks.payloadsDir = await mkdtemp( path.join( os.tmpdir(), 'studio-wpcom-request-' ) ); - mocks.req.post.mockResolvedValue( { ok: true } ); - mocks.readAuthToken.mockResolvedValue( { accessToken: 'token' } ); - } ); - - afterEach( async () => { - await rm( mocks.payloadsDir, { recursive: true, force: true } ); - } ); - - it( 'uses staged files, by relative or absolute path, for string fields and full JSON request bodies', async () => { - await writeFile( - path.join( mocks.payloadsDir, 'home.html' ), - '

Hello

' - ); - const bodyPath = path.join( mocks.payloadsDir, 'global-styles.json' ); - const globalStylesBody = { - styles: { - color: { - background: '#111111', - }, - }, - }; - await writeFile( bodyPath, JSON.stringify( globalStylesBody ) ); - - const result = await wpcomRequestTool.rawHandler( { - siteId: 123, - method: 'POST', - path: '/pages/4', - body: { status: 'publish' }, - bodyFiles: { content: 'home.html' }, - } ); - - expect( mocks.req.post ).toHaveBeenCalledWith( - '/sites/123/pages/4', - { apiNamespace: 'wp/v2' }, - { - status: 'publish', - content: '

Hello

', - } - ); - expect( result.content[ 0 ] ).toEqual( { type: 'text', text: '{"ok":true}' } ); - - mocks.req.post.mockClear(); - await wpcomRequestTool.rawHandler( { - siteId: 123, - method: 'POST', - path: '/global-styles/7', - bodyFile: bodyPath, - } ); - - expect( mocks.req.post ).toHaveBeenCalledWith( - '/sites/123/global-styles/7', - { apiNamespace: 'wp/v2' }, - globalStylesBody - ); - } ); - - it( "keeps the site plan's active features and drops the catalogue of other plans' features", async () => { - mocks.req.get.mockResolvedValue( { - ID: 123, - plan: { - product_slug: 'free_plan', - product_name_short: 'Free', - is_free: true, - features: { - active: [ 'free-blog', 'stats-basic' ], - available: { 'custom-design': [ 'value_bundle', 'business-bundle' ] }, - }, - }, - } ); - - const result = await wpcomRequestTool.rawHandler( { - siteId: 123, - method: 'GET', - path: '/', - apiNamespace: '', - } ); - - const site = JSON.parse( ( result.content[ 0 ] as { text: string } ).text ); - expect( site.plan.features ).toEqual( { active: [ 'free-blog', 'stats-basic' ] } ); - expect( site.plan.product_slug ).toBe( 'free_plan' ); - } ); - - it( 'needs a stored WordPress.com login', async () => { - mocks.readAuthToken.mockResolvedValue( undefined ); - await expect( - wpcomRequestTool.rawHandler( { siteId: 456, method: 'GET', path: '/posts' } ) - ).rejects.toThrow( 'Not logged in to WordPress.com' ); - } ); -} ); diff --git a/apps/cli/ai/tool-result-renderers.ts b/apps/cli/ai/tool-result-renderers.ts index 9b7ba5a912..5f6bf39919 100644 --- a/apps/cli/ai/tool-result-renderers.ts +++ b/apps/cli/ai/tool-result-renderers.ts @@ -1,4 +1,4 @@ -import { __, _n, sprintf } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { theme } from 'cli/ai/theme'; const COLLAPSE_THRESHOLD_LINES = 5; @@ -43,16 +43,6 @@ function parseJson( text: string ): unknown { } } -function stripHtmlTags( input: string ): string { - let previous: string; - let result = input; - do { - previous = result; - result = result.replace( /<[^>]*>/g, '' ); - } while ( result !== previous ); - return result; -} - function getDisplayValue( value: unknown ): string { if ( typeof value === 'string' ) { return value; @@ -60,36 +50,9 @@ function getDisplayValue( value: unknown ): string { if ( typeof value === 'number' ) { return String( value ); } - if ( value && typeof value === 'object' && 'rendered' in value ) { - const rendered = ( value as { rendered?: unknown } ).rendered; - return typeof rendered === 'string' ? stripHtmlTags( rendered ) : ''; - } return ''; } -function getResourceName( value: unknown ): string { - if ( ! value || typeof value !== 'object' || Array.isArray( value ) ) { - return ''; - } - const record = value as Record< string, unknown >; - return ( - getDisplayValue( record.title ) || - getDisplayValue( record.name ) || - getDisplayValue( record.slug ) || - getDisplayValue( record.ID ) || - getDisplayValue( record.id ) - ); -} - -function getCountSummary( count: number, noun: string ): string { - return sprintf( - /* translators: 1: number of items, 2: item type */ - _n( 'Returned %1$d %2$s', 'Returned %1$d %2$s', count ), - count, - noun - ); -} - // Head-biased collapsed preview: first lines plus a "%d more" hint. function headPreview( lines: string[], expanded: boolean ): string { if ( expanded || lines.length <= COLLAPSE_THRESHOLD_LINES ) { @@ -172,67 +135,6 @@ const renderSkillResult: ToolResultRenderer = ( { text }, expanded ) => { ); }; -function getWpcomSummaryLines( input: Record< string, unknown >, text: string ): string[] { - const parsed = parseJson( text ); - const method = typeof input.method === 'string' ? input.method : ''; - const path = typeof input.path === 'string' ? input.path : ''; - const target = [ method, path ].filter( Boolean ).join( ' ' ); - const withTarget = ( summary: string ) => - target ? sprintf( __( '%1$s: %2$s' ), target, summary ) : summary; - - if ( Array.isArray( parsed ) ) { - return [ withTarget( getCountSummary( parsed.length, __( 'items' ) ) ) ]; - } - - if ( parsed && typeof parsed === 'object' ) { - const record = parsed as Record< string, unknown >; - const arrayEntry = Object.entries( record ).find( ( [ , value ] ) => Array.isArray( value ) ); - if ( arrayEntry ) { - const [ key, value ] = arrayEntry as [ string, unknown[] ]; - const count = typeof record.found === 'number' ? record.found : value.length; - return [ withTarget( getCountSummary( count, key ) ) ]; - } - - const resourceName = getResourceName( record ); - if ( resourceName ) { - return [ - target - ? sprintf( __( '%1$s: returned %2$s' ), target, resourceName ) - : sprintf( __( 'Returned %s' ), resourceName ), - ]; - } - - const keys = Object.keys( record ).slice( 0, 4 ); - return [ - target ? sprintf( __( '%1$s: returned response' ), target ) : __( 'Returned response' ), - keys.length > 0 ? sprintf( __( 'Fields: %s' ), keys.join( ', ' ) ) : '', - ].filter( Boolean ); - } - - return [ firstNonEmptyLine( text ) || __( 'Request completed' ) ]; -} - -const renderWpcomResult: ToolResultRenderer = ( { input, text, isError }, expanded ) => { - if ( ! text ) { - return null; - } - if ( isError ) { - return summaryWithDetail( - [ firstNonEmptyLine( text ) || __( 'Request failed' ) ], - text, - __( 'Full API error hidden · ctrl+o to expand' ), - expanded, - { isError: true } - ); - } - return summaryWithDetail( - getWpcomSummaryLines( input, text ), - text, - __( 'Full API response hidden · ctrl+o to expand' ), - expanded - ); -}; - const renderSiteCreateResult: ToolResultRenderer = ( { text, isError }, expanded ) => { if ( ! text ) { return null; @@ -268,5 +170,4 @@ const renderSiteCreateResult: ToolResultRenderer = ( { text, isError }, expanded export const toolResultRenderers: Record< string, ToolResultRenderer > = { site_create: renderSiteCreateResult, Skill: renderSkillResult, - wpcom_request: renderWpcomResult, }; diff --git a/apps/cli/ai/tools/ask-user-question.ts b/apps/cli/ai/tools/ask-user-question.ts index c4dcb99c96..d925e81774 100644 --- a/apps/cli/ai/tools/ask-user-question.ts +++ b/apps/cli/ai/tools/ask-user-question.ts @@ -5,7 +5,7 @@ import type { AskUserQuestion } from 'cli/ai/types'; import type { TSchema } from 'typebox'; // Factory because the tool closes over `onAskUser` (the active AiChatUI -// instance), same pattern as `wpcom-request.ts` closing over token + siteId. +// instance). export function createAskUserQuestionTool( onAskUser: ( questions: AskUserQuestion[] ) => Promise< Record< string, string > > ): AgentTool< TSchema > { diff --git a/apps/cli/ai/tools/index.ts b/apps/cli/ai/tools/index.ts index 9e9af9007c..7983b9dd52 100644 --- a/apps/cli/ai/tools/index.ts +++ b/apps/cli/ai/tools/index.ts @@ -24,7 +24,6 @@ import { stopSiteTool } from './stop-site'; import { createTakeScreenshotTool, takeScreenshotTool } from './take-screenshot'; import { updatePreviewTool } from './update-preview'; import { runWpCliTool } from './wp-cli'; -import { wpcomRequestTool } from './wpcom-request'; import type { AnyStudioAgentTool, StudioToolResultDetails } from './define-tool'; import type { DesignTracksContext } from 'cli/ai/design-tracks'; @@ -51,7 +50,6 @@ export const studioToolDefinitions: AnyStudioAgentTool[] = [ auditPerformanceTool, auditSeoTool, listConnectedRemoteSitesTool, - wpcomRequestTool, pushSiteTool, pullSiteTool, importSiteTool, @@ -70,9 +68,6 @@ export interface CreateStudioToolsOptions { imageGeneration?: boolean; // False for models that cannot view images. Defaults to true. visionEnabled?: boolean; - // False leaves out wpcom_request, for hosts that don't accept a tool reaching any - // WordPress.com endpoint. Defaults to true. - wpcomRequest?: boolean; // Lets pick_design offer options to pick from. Defaults to false. canAskUser?: boolean; // The chat session the design tools record Tracks events for; absent for the MCP server. @@ -89,9 +84,6 @@ export function resolveStudioToolDefinitions( if ( candidate.name === generateImagesTool.name && ! options.imageGeneration ) { return []; } - if ( candidate.name === wpcomRequestTool.name && options.wpcomRequest === false ) { - return []; - } let tool = candidate; const userSeesCaptures = options.emitChatArtifacts === true || Boolean( options.imageLink ); if ( diff --git a/apps/cli/ai/tools/wpcom-request.ts b/apps/cli/ai/tools/wpcom-request.ts deleted file mode 100644 index af2a5d982f..0000000000 --- a/apps/cli/ai/tools/wpcom-request.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { readFile } from 'fs/promises'; -import path from 'path'; -import { readAuthToken } from '@studio/common/lib/shared-config'; -import { getAiPayloadsPath } from '@studio/common/lib/well-known-paths'; -import wpcomFactory from '@studio/common/lib/wpcom-factory'; -import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory'; -import { Type } from 'typebox'; -import { - getWpcomSiteCapabilities, - getWpcomUpgradePlans, - getWpcomWriteRefusal, -} from 'cli/ai/wpcom-capabilities'; -import { defineTool, REMOTE_OVERWRITE } from './define-tool'; -import { textResult } from './utils'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ApiResponse = any; - -const PAYLOADS_DIR = getAiPayloadsPath(); -const BODY_FILE_FIELD_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/; - -/** - * Strips oversized fields from API responses that can't be filtered via query params. - * - * Special case: the WP.com /sites/{id} endpoint returns a `plan` object whose - * `features.available` sub-field alone is 60K+ characters (what every other plan would - * add), which can push the tool result past the agent's output budget. The v1.1 API - * doesn't support sub-field filtering (e.g. `fields=plan.features.active`), so we can't - * solve this via query params. `features.active` stays: it is what this site's plan - * includes, which is what the agent works within. - * - * This is NOT a pattern to follow for other endpoints. For general large responses, - * the skill instructs the agent to use `_fields` (wp/v2) or `fields` (v1.1) - * query params to request only the properties it needs. - */ -function stripOversizedFields( result: ApiResponse ): ApiResponse { - if ( - result && - typeof result === 'object' && - ! Array.isArray( result ) && - result.plan?.features - ) { - return { - ...result, - plan: { - product_id: result.plan.product_id, - product_slug: result.plan.product_slug, - product_name_short: result.plan.product_name_short, - expired: result.plan.expired, - is_free: result.plan.is_free, - features: { active: result.plan.features.active }, - }, - }; - } - return result; -} - -function getErrorMessage( error: unknown ): string { - if ( error instanceof Error ) { - return error.message; - } - return String( error ); -} - -function hasOwnProperty( value: Record< string, unknown >, key: string ): boolean { - return Object.prototype.hasOwnProperty.call( value, key ); -} - -function isRecord( value: unknown ): value is Record< string, unknown > { - return Boolean( value && typeof value === 'object' && ! Array.isArray( value ) ); -} - -function validateBodyFileFieldName( key: string ): void { - if ( ! BODY_FILE_FIELD_NAME_PATTERN.test( key ) ) { - throw new Error( - `bodyFiles keys must be top-level REST body field names such as "content" or "excerpt", not filenames, nested paths, or JSON paths. Use the value as the file path, for example bodyFiles: { content: "${ PAYLOADS_DIR }/home.html" }.` - ); - } -} - -function resolveBodyFilePath( filePath: string ): string { - const root = getAiPayloadsPath(); - const resolvedPath = path.resolve( root, filePath ); - const relativePath = path.relative( root, resolvedPath ); - - if ( ! relativePath || relativePath.startsWith( '..' ) || path.isAbsolute( relativePath ) ) { - throw new Error( `bodyFile and bodyFiles paths must be files in ${ root }.` ); - } - - return resolvedPath; -} - -function validateSingleBodySource( - body: Record< string, unknown > | undefined, - bodyFile: string | undefined, - bodyFiles: Record< string, string > | undefined -): void { - if ( bodyFile && ( body || bodyFiles ) ) { - throw new Error( - 'Use only one request body source: body, bodyFile, or bodyFiles. bodyFile provides the entire JSON request body.' - ); - } -} - -async function readBodyFile( bodyFile: string ): Promise< Record< string, unknown > > { - const fileContents = await readFile( resolveBodyFilePath( bodyFile ), 'utf8' ); - let parsed: unknown; - try { - parsed = JSON.parse( fileContents ); - } catch { - throw new Error( 'bodyFile must contain valid JSON.' ); - } - if ( ! isRecord( parsed ) ) { - throw new Error( 'bodyFile JSON must be an object.' ); - } - return parsed; -} - -async function mergeBodyFiles( - body: Record< string, unknown > | undefined, - bodyFiles: Record< string, string > | undefined -): Promise< Record< string, unknown > > { - const mergedBody: Record< string, unknown > = { ...( body ?? {} ) }; - - if ( ! bodyFiles ) { - return mergedBody; - } - - for ( const [ key, filePath ] of Object.entries( bodyFiles ) ) { - validateBodyFileFieldName( key ); - if ( body && hasOwnProperty( body, key ) ) { - throw new Error( - `wpcom_request defines both body.${ key } and bodyFiles.${ key }. Put file-backed fields only in bodyFiles.` - ); - } - - mergedBody[ key ] = await readFile( resolveBodyFilePath( filePath ), 'utf8' ); - } - - return mergedBody; -} - -async function resolveRequestBody( - body: Record< string, unknown > | undefined, - bodyFile: string | undefined, - bodyFiles: Record< string, string > | undefined -): Promise< Record< string, unknown > > { - validateSingleBodySource( body, bodyFile, bodyFiles ); - if ( bodyFile ) { - return readBodyFile( bodyFile ); - } - return mergeBodyFiles( body, bodyFiles ); -} - -async function wpcomClient() { - const token = await readAuthToken(); - if ( ! token?.accessToken ) { - throw new Error( - 'Not logged in to WordPress.com. Ask the user to log in with `studio auth login`.' - ); - } - return wpcomFactory( token.accessToken, wpcomXhrRequest ); -} - -/** - * A generic WP.com REST API tool for managing the user's WordPress.com sites. - * Instead of hardcoding individual endpoints, this provides a single flexible tool - * that can call any WP.com REST API endpoint. The AI agent determines the correct - * endpoints based on its knowledge of the WordPress.com REST API. - */ -export const wpcomRequestTool = defineTool( - 'wpcom_request', - 'Makes a request to the WordPress REST API (wp/v2) or WordPress.com REST API (v1.1) for the WordPress.com site `siteId`. ' + - 'Defaults to the WordPress REST API (wp/v2). Use this to manage posts, pages, templates, template parts, ' + - 'media, plugins, themes, settings, and any other site resource. ' + - 'The path is relative to /sites/{siteId}/ — for example, pass "/posts" to call /wp/v2/sites/{siteId}/posts. ' + - 'For non-site endpoints, start the path with "!" (e.g., "!/me") to use an absolute path.', - { - siteId: Type.Number( { description: 'The WordPress.com site ID.' } ), - method: Type.Enum( [ 'GET', 'POST', 'PUT', 'DELETE' ], { - description: 'HTTP method for the request.', - } ), - path: Type.String( { - description: - 'API path relative to /sites/{siteId}/, e.g. "/posts", "/posts/123", "/templates", "/template-parts". ' + - 'Prefix with "!" to use an absolute path (e.g. "!/me").', - } ), - query: Type.Optional( - Type.Record( Type.String(), Type.Unknown(), { - description: - 'Query parameters as key-value pairs, e.g. { "per_page": 20, "status": "publish" }.', - } ) - ), - body: Type.Optional( - Type.Record( Type.String(), Type.Unknown(), { - description: 'Request body for POST/PUT requests as key-value pairs.', - } ) - ), - bodyFile: Type.Optional( - Type.String( { - description: `Optional full request body file for POST/PUT requests. The file must be a valid JSON object stored in ${ PAYLOADS_DIR } (pass its absolute path), and it becomes the entire REST body. Use this for endpoints such as global styles that expect nested JSON objects. Do not combine bodyFile with body or bodyFiles.`, - } ) - ), - bodyFiles: Type.Optional( - Type.Record( Type.String(), Type.String(), { - description: - `Optional file-backed string body fields for POST/PUT requests. Keys must be top-level REST body field names like "content"; do not use filenames, file extensions, nested fields, dots, slashes, or JSON paths as keys. Values must be absolute paths of files in ${ PAYLOADS_DIR }. ` + - `Example: { "content": "${ PAYLOADS_DIR }/home.html" }. Use this for large generated strings instead of inlining them in body.`, - } ) - ), - apiNamespace: Type.Optional( - Type.String( { - description: - 'API namespace. Defaults to "wp/v2" (WordPress REST API). ' + - 'Set to "wpcom/v2" for WordPress.com v2 endpoints, or omit/leave empty to fall back to WP.com REST API v1.1. ' + - 'Use wp/v2 for standard WordPress resources (posts, pages, templates, media, users, etc.). ' + - 'Use WP.com v1.1 (set apiNamespace to "") for WP.com-specific endpoints like /plugins, /themes/mine.', - } ) - ), - }, - async ( args ) => { - const apiNamespace = args.apiNamespace ?? 'wp/v2'; - const writes = args.method === 'POST' || args.method === 'PUT'; - if ( ! writes && ( args.bodyFile || args.bodyFiles ) ) { - throw new Error( 'bodyFile and bodyFiles can only be used with POST or PUT requests.' ); - } - const body = writes - ? await resolveRequestBody( args.body, args.bodyFile, args.bodyFiles ) - : undefined; - if ( args.method !== 'GET' && ! args.path.startsWith( '!' ) ) { - const capabilities = await getWpcomSiteCapabilities( args.siteId ); - const refusal = - capabilities && - ( await getWpcomWriteRefusal( - capabilities, - { - method: args.method, - path: args.path.startsWith( '/' ) ? args.path : `/${ args.path }`, - apiNamespace, - body, - }, - () => getWpcomUpgradePlans( args.siteId ) - ) ); - if ( refusal ) { - throw new Error( refusal ); - } - } - try { - let fullPath: string; - if ( args.path.startsWith( '!' ) ) { - fullPath = args.path.slice( 1 ); - } else { - const relativePath = args.path.startsWith( '/' ) ? args.path : `/${ args.path }`; - fullPath = `/sites/${ args.siteId }${ relativePath }`; - } - - // An empty string means "use WP.com REST API v1.1" (no apiNamespace → isRestAPI=true in wpcom-xhr-request). - const queryParams: Record< string, unknown > = { ...( args.query ?? {} ) }; - if ( apiNamespace ) { - queryParams.apiNamespace = apiNamespace; - } - - const wpcom = await wpcomClient(); - let result: ApiResponse; - switch ( args.method ) { - case 'GET': - result = await wpcom.req.get< ApiResponse >( fullPath, queryParams ); - break; - case 'POST': - result = await wpcom.req.post< ApiResponse >( fullPath, queryParams, body ); - break; - case 'PUT': - result = await wpcom.req.put< ApiResponse >( fullPath, queryParams, body ); - break; - case 'DELETE': - result = await wpcom.req.del< ApiResponse >( fullPath, queryParams ); - break; - } - - const compacted = stripOversizedFields( result ); - return textResult( JSON.stringify( compacted ) ); - } catch ( error ) { - throw new Error( - `WP.com API request failed (${ args.method } ${ args.path }): ${ getErrorMessage( error ) }` - ); - } - }, - { - annotations: REMOTE_OVERWRITE, - } -); diff --git a/apps/cli/ai/wpcom-capabilities.ts b/apps/cli/ai/wpcom-capabilities.ts deleted file mode 100644 index 1f8fdf93c9..0000000000 --- a/apps/cli/ai/wpcom-capabilities.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { readAuthToken } from '@studio/common/lib/shared-config'; -import wpcomFactory from '@studio/common/lib/wpcom-factory'; -import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory'; - -// What a WordPress.com site allows, read from the platform: the features its -// plan turns on and what the logged-in user's role may do there. The active -// site line states it, and wpcom_request refuses writes it does not allow. -export interface WpcomSiteCapabilities { - planName: string; - features: ReadonlySet< string >; - // Each feature the plan lacks, with the product slugs of the plans that have it. - plansWith: Readonly< Record< string, readonly string[] > >; - canManage: boolean; - canEdit: boolean; -} - -const cache = new Map< number, Promise< WpcomSiteCapabilities | undefined > >(); - -// Fetched once per site per process. Undefined when the platform cannot be -// asked (logged out, offline): writes then go through and the API decides. -export function getWpcomSiteCapabilities( - siteId: number -): Promise< WpcomSiteCapabilities | undefined > { - let pending = cache.get( siteId ); - if ( ! pending ) { - pending = fetchCapabilities( siteId ).catch( () => undefined ); - cache.set( siteId, pending ); - } - return pending; -} - -export function clearWpcomSiteCapabilitiesCache(): void { - cache.clear(); -} - -async function fetchCapabilities( siteId: number ): Promise< WpcomSiteCapabilities | undefined > { - const token = await readAuthToken(); - if ( ! token?.accessToken ) return undefined; - const wpcom = wpcomFactory( token.accessToken, wpcomXhrRequest ); - const site = await wpcom.req.get< { - plan?: { - product_name_short?: string; - features?: { active?: string[]; available?: Record< string, string[] > }; - }; - capabilities?: Record< string, boolean >; - } >( `/sites/${ siteId }`, { fields: 'ID,plan,capabilities' } ); - return parseWpcomSiteCapabilities( site ); -} - -// The plans this site can move to, by product slug, under the names its plan -// cards use: the platform's own offer, without the plans it no longer sells. -export async function getWpcomUpgradePlans( siteId: number ): Promise< Map< string, string > > { - const token = await readAuthToken(); - if ( ! token?.accessToken ) return new Map(); - const wpcom = wpcomFactory( token.accessToken, wpcomXhrRequest ); - const plans = await wpcom.req.get< Record< string, WpcomPlan > >( { - path: `/sites/${ siteId }/plans`, - apiNamespace: 'rest/v1.3', - } ); - return parseWpcomUpgradePlans( plans ); -} - -interface WpcomPlan { - product_slug?: string; - plan_card_name?: string; - plan_card_order?: number; - available_for_upgrade?: boolean; -} - -export function parseWpcomUpgradePlans( - plans: Record< string, WpcomPlan > -): Map< string, string > { - return new Map( - Object.values( plans ) - .filter( ( plan ) => plan.available_for_upgrade && plan.product_slug && plan.plan_card_name ) - .sort( ( a, b ) => ( a.plan_card_order ?? 0 ) - ( b.plan_card_order ?? 0 ) ) - .map( ( plan ) => [ plan.product_slug!, plan.plan_card_name! ] ) - ); -} - -export function parseWpcomSiteCapabilities( site: { - plan?: { - product_name_short?: string; - features?: { active?: string[]; available?: Record< string, string[] > }; - }; - capabilities?: Record< string, boolean >; -} ): WpcomSiteCapabilities | undefined { - if ( ! site.plan ) return undefined; - return { - planName: site.plan.product_name_short ?? 'unknown', - features: new Set( site.plan.features?.active ?? [] ), - plansWith: site.plan.features?.available ?? {}, - canManage: site.capabilities?.manage_options === true, - canEdit: site.capabilities?.edit_posts === true, - }; -} - -// "Personal plan; you manage this site". What the plan includes is stated when -// a write needs it: wpcom_request refuses it, naming the plans that do. -export function describeWpcomSiteCapabilities( capabilities: WpcomSiteCapabilities ): string { - const role = capabilities.canManage - ? 'you manage this site' - : capabilities.canEdit - ? 'you edit content only' - : 'you can only read this site'; - return `${ capabilities.planName } plan; ${ role }`; -} - -export interface WpcomWrite { - method: string; - path: string; - // '' is WordPress.com REST API v1.1. - apiNamespace: string; - body?: Record< string, unknown >; -} - -// What a write needs: plan features and whether it is a site-admin change. -function requirementsOf( write: WpcomWrite ): { features: string[]; admin: boolean } { - const path = write.path.replace( /^\/+/, '/' ).split( '?' )[ 0 ]; - if ( /^\/global-styles(\/|$)/.test( path ) ) { - const styles = write.body?.styles as Record< string, unknown > | undefined; - const css = typeof styles?.css === 'string' && styles.css.trim() !== ''; - return { - features: css ? [ 'global-styles', 'custom-design' ] : [ 'global-styles' ], - admin: true, - }; - } - if ( /^\/plugins(\/|$)/.test( path ) ) { - const installs = - /\/install$/.test( path ) || - path === '/plugins/new' || - ( write.apiNamespace === 'wp/v2' && path === '/plugins' ); - return { features: [ installs ? 'install-plugins' : 'manage-plugins' ], admin: true }; - } - if ( /^\/themes\/(upload|new)$/.test( path ) ) { - return { features: [ 'upload-themes' ], admin: true }; - } - if ( /^\/themes(\/|$)/.test( path ) || /^\/settings(\/|$)/.test( path ) ) { - return { features: [], admin: true }; - } - return { features: [], admin: false }; -} - -// Asks for the plans that would allow a write only when the site's plan does not. -export async function getWpcomWriteRefusal( - capabilities: WpcomSiteCapabilities, - write: WpcomWrite, - upgradePlans: () => Promise< ReadonlyMap< string, string > > = async () => new Map() -): Promise< string | undefined > { - if ( write.method === 'GET' ) return undefined; - const { features, admin } = requirementsOf( write ); - if ( admin && ! capabilities.canManage ) { - return `Not sent: ${ write.method } ${ write.path } changes site settings, and the user's role on this site does not manage it. A site administrator can make this change.`; - } - if ( ! admin && ! capabilities.canEdit && ! capabilities.canManage ) { - return `Not sent: the user's role on this site cannot change its content.`; - } - const missing = features.filter( ( feature ) => ! capabilities.features.has( feature ) ); - if ( missing.length === 0 ) return undefined; - const offered = await upgradePlans().catch( () => new Map< string, string >() ); - const plans = [ ...offered ] - .filter( ( [ slug ] ) => - missing.every( ( feature ) => capabilities.plansWith[ feature ]?.includes( slug ) ) - ) - .map( ( [ , name ] ) => name ); - const offer = plans.length - ? ` Plans that include ${ missing.length > 1 ? 'them' : 'it' }: ${ [ ...new Set( plans ) ].join( - ', ' - ) }.` - : ''; - return `Not sent: this site's ${ capabilities.planName } plan does not include ${ missing.join( - ' and ' - ) } (WordPress.com plan features).${ offer }`; -} diff --git a/packages/common/ai/tools.ts b/packages/common/ai/tools.ts index 99249fb3bc..5d0044b75d 100644 --- a/packages/common/ai/tools.ts +++ b/packages/common/ai/tools.ts @@ -321,7 +321,6 @@ export function getToolDisplayName( name: string, input?: Record< string, unknow need_for_speed: __( 'Audit performance' ), rank_me_up: __( 'Audit SEO' ), install_taxonomy_scripts: __( 'Install taxonomy scripts' ), - wpcom_request: __( 'WordPress.com API' ), AskUserQuestion: __( 'Ask user' ), Read: __( 'Read' ), Write: __( 'Write' ), @@ -418,11 +417,6 @@ export function getToolDetail( name: string, input?: Record< string, unknown > ) return typeof input.host === 'string' ? input.host : ''; case 'AskUserQuestion': return getAskUserDetail( input ); - case 'wpcom_request': { - const method = typeof input.method === 'string' ? input.method : ''; - const path = typeof input.path === 'string' ? input.path : ''; - return [ method, path ].filter( Boolean ).join( ' ' ); - } case 'wp_cli': return typeof input.command === 'string' ? `wp ${ input.command }` : ''; case 'scaffold_theme': diff --git a/packages/common/lib/well-known-paths.ts b/packages/common/lib/well-known-paths.ts index 7965b3d020..480cccb949 100644 --- a/packages/common/lib/well-known-paths.ts +++ b/packages/common/lib/well-known-paths.ts @@ -57,7 +57,3 @@ export function getServerFilesPath(): string { export function getMuPluginsPath(): string { return path.join( getConfigDirectory(), 'mu-plugins' ); } - -export function getAiPayloadsPath(): string { - return path.join( getConfigDirectory(), 'tmp', 'ai-payloads' ); -} diff --git a/plugins/wp-studio/.mcp.json b/plugins/wp-studio/.mcp.json index 4195a7f3e7..39541a7af2 100644 --- a/plugins/wp-studio/.mcp.json +++ b/plugins/wp-studio/.mcp.json @@ -16,6 +16,10 @@ "env": { "STUDIO_PLUGIN_ID": "wp-studio@studio" } + }, + "wordpress-com": { + "type": "http", + "url": "https://public-api.wordpress.com/wpcom/v2/mcp/v2" } } } diff --git a/plugins/wp-studio/README.md b/plugins/wp-studio/README.md index 2c443cb14a..3740207170 100644 --- a/plugins/wp-studio/README.md +++ b/plugins/wp-studio/README.md @@ -45,6 +45,12 @@ update would overwrite, and after an edit they report block validation and what instead of the file, such as a template saved in the Site Editor. Edits outside a WordPress site don't start Studio. +## WordPress.com sites + +The plugin also lists WordPress.com's own MCP server (`wordpress-com`), so the agent can read and +change your WordPress.com sites with the same tools and approval WordPress.com gives every MCP +client. Your client asks you to log in to WordPress.com the first time it connects. + ## The Studio CLI The plugin carries the Studio CLI of its own version. The copy in Anthropic's directory is too small diff --git a/plugins/wp-studio/skills/edit-manage-remote-site/SKILL.md b/plugins/wp-studio/skills/edit-manage-remote-site/SKILL.md deleted file mode 100644 index 038e28a312..0000000000 --- a/plugins/wp-studio/skills/edit-manage-remote-site/SKILL.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: edit-manage-remote-site -description: Edit and manage one of the user's live remote sites (WordPress.com, or Pressable through the WordPress.com endpoints) with wpcom_request — answer questions about it, or change its content, templates, styles, theme, and plugins within what its plan allows. Covers API namespaces and endpoints, response-size limits, and visual verification. ---- - -1. If you have not called the `studio_instructions` tool in this conversation yet, call it first and follow what it returns for the rest of the session. -2. Then call `studio_instructions` with `skill: "edit-manage-remote-site"` and follow the runbook it returns. - -If the `wordpress-studio` tools are missing, WordPress Studio did not start: ask the user to restart the app, and to reinstall the plugin if the tools are still missing. From a6df0f8b212dc694e4326170733c216f6f51f087 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 23 Oct 2026 21:37:11 -0400 Subject: [PATCH 034/199] Studio Code: Let the agent write the theme, and keep it carrying DESIGN.md The agent writes a block or child theme's files itself, from WordPress's own source and the themes on disk, instead of through scaffold_theme. What the scaffold settled about the design moves to the write: after a write to DESIGN.md or a theme.json, Studio writes DESIGN.md's colors, font families with their font files, font sizes, spacing steps and style fonts into the theme under DESIGN.md's names, through the site-design code the Design system tab uses, and says what it wrote. Themes WordPress updates are left alone. The plugin's hooks run the same step for Claude Code and Codex. --- .../hooks/tests/file-tool-hook.test.ts | 64 +- apps/cli/ai/runtimes/pi/preview-reload.ts | 9 +- apps/cli/ai/runtimes/pi/site-pin.ts | 1 - .../cli/ai/runtimes/pi/tests/site-pin.test.ts | 5 +- .../pi/tests/skill-tool-scope.test.ts | 16 +- apps/cli/ai/site-file-guards.ts | 60 +- apps/cli/ai/skills/build-site/SKILL.md | 8 +- apps/cli/ai/skills/visual-design/SKILL.md | 7 +- apps/cli/ai/tests/file-write-refusal.test.ts | 2 +- apps/cli/ai/tests/tools.test.ts | 433 +------------ apps/cli/ai/tools/index.ts | 2 - apps/cli/ai/tools/scaffold-theme.ts | 598 ------------------ packages/common/ai/tools.ts | 3 - packages/common/sites/site-design.ts | 97 ++- packages/design-md/index.ts | 10 +- plugins/wp-studio/.codex-plugin/plugin.json | 4 +- plugins/wp-studio/scripts/bootstrap-mcp.mjs | 2 +- plugins/wp-studio/skills/build-site/SKILL.md | 2 +- scripts/eval/promptfoo.config.yaml | 1 - 19 files changed, 222 insertions(+), 1102 deletions(-) delete mode 100644 apps/cli/ai/tools/scaffold-theme.ts diff --git a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts index 7fea080beb..39c114be59 100644 --- a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts +++ b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts @@ -1,7 +1,7 @@ -import { mkdirSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import path from 'path'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; -import { beforeEach, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { BlockValidationGate } from 'cli/ai/block-validation-gate'; import { getWrittenFiles, runFileToolHook } from 'cli/ai/runtimes/hooks'; import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; @@ -92,3 +92,63 @@ it( 'adds to a write the block validation and what WordPress renders instead of } ); expect( await post( '/elsewhere/src/index.ts' ) ).toBeUndefined(); } ); + +const DESIGN_MD = + '---\ncolors:\n primary: "#c2552b"\n background: "#fbf6ee"\ntypography:\n body:\n fontFamily: "Inter"\n fontWeight: 400\n---\n\n# Overview\n'; + +afterEach( () => vi.unstubAllGlobals() ); + +// DESIGN.md is the look; the theme built on it carries its tokens whichever +// of the two the agent writes. A theme WordPress updates is left alone. +it.each( [ + { written: 'wp-content/themes/bakery/theme.json', theme: 'bakery' }, + { written: 'DESIGN.md', theme: 'bakery' }, + { written: 'wp-content/themes/bakery-parent/theme.json', theme: undefined }, +] )( 'writes DESIGN.md into the theme after a write to $written', async ( { written, theme } ) => { + vi.stubGlobal( + 'fetch', + vi.fn( async ( url: string ) => + url.startsWith( 'https://fonts.googleapis.com/' ) + ? new Response( + "/* latin */\n@font-face {\n font-family: 'Inter';\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/inter.woff2) format('woff2');\n}\n" + ) + : new Response( new Uint8Array( [ 1 ] ) ) + ) + ); + writeFileSync( path.join( site.root, 'DESIGN.md' ), DESIGN_MD ); + const ownPalette = { settings: { color: { palette: [ { slug: 'ink', color: '#111' } ] } } }; + for ( const slug of [ 'bakery', 'bakery-parent' ] ) { + writeFileSync( + path.join( site.root, 'wp-content/themes', slug, 'theme.json' ), + JSON.stringify( { version: 3, ...ownPalette } ) + ); + } + + const output = await runFileToolHook( { + hook_event_name: 'PostToolUse', + tool_name: 'Write', + tool_input: { file_path: path.join( site.root, written ) }, + } ); + + for ( const slug of [ 'bakery', 'bakery-parent' ] ) { + const themeDir = path.join( site.root, 'wp-content/themes', slug ); + const themeJson = JSON.parse( readFileSync( path.join( themeDir, 'theme.json' ), 'utf8' ) ); + const palette = themeJson.settings.color.palette.map( + ( color: { slug: string } ) => color.slug + ); + if ( slug === theme ) { + expect( palette ).toEqual( [ 'ink', 'primary', 'background' ] ); + expect( themeJson.styles.typography.fontFamily ).toBe( 'var:preset|font-family|inter' ); + expect( themeJson.settings.typography.fontFamilies[ 0 ].fontFace ).toHaveLength( 1 ); + expect( existsSync( path.join( themeDir, 'assets/fonts/inter' ) ) ).toBe( true ); + } else { + expect( palette ).toEqual( [ 'ink' ] ); + } + } + expect( output?.hookSpecificOutput ).toEqual( + theme && { + hookEventName: 'PostToolUse', + additionalContext: `Note: Studio wrote DESIGN.md's tokens into wp-content/themes/${ theme }/theme.json under DESIGN.md's names: colors primary, background; font families inter; fonts of the styles body. DESIGN.md holds these values; change the look there.`, + } + ); +} ); diff --git a/apps/cli/ai/runtimes/pi/preview-reload.ts b/apps/cli/ai/runtimes/pi/preview-reload.ts index 6130c619f0..d5ca09cd7d 100644 --- a/apps/cli/ai/runtimes/pi/preview-reload.ts +++ b/apps/cli/ai/runtimes/pi/preview-reload.ts @@ -2,14 +2,7 @@ import type { AgentSessionEvent } from '@earendil-works/pi-coding-agent'; // Tools whose success can change what the site renders. The Studio preview // reloads once after each model turn in which one of them succeeded. -const PREVIEW_CHANGING_TOOLS = new Set( [ - 'Write', - 'Edit', - 'wp_cli', - 'scaffold_theme', - 'site_import', - 'site_pull', -] ); +const PREVIEW_CHANGING_TOOLS = new Set( [ 'Write', 'Edit', 'wp_cli', 'site_import', 'site_pull' ] ); // WP-CLI verbs that only read, in ` ` position (`post list`). const READ_ONLY_WP_CLI_VERBS = new Set( [ diff --git a/apps/cli/ai/runtimes/pi/site-pin.ts b/apps/cli/ai/runtimes/pi/site-pin.ts index c7f9379b04..bf7cb7ea12 100644 --- a/apps/cli/ai/runtimes/pi/site-pin.ts +++ b/apps/cli/ai/runtimes/pi/site-pin.ts @@ -15,7 +15,6 @@ import type { SiteInfo } from 'cli/ai/types'; // Tools that change the local site named by `nameOrPath`. const LOCAL_SITE_CHANGES = new Set( [ 'wp_cli', - 'scaffold_theme', 'site_import', 'site_pull', 'install_taxonomy_scripts', diff --git a/apps/cli/ai/runtimes/pi/tests/site-pin.test.ts b/apps/cli/ai/runtimes/pi/tests/site-pin.test.ts index cd32fc21f1..70cb614ec0 100644 --- a/apps/cli/ai/runtimes/pi/tests/site-pin.test.ts +++ b/apps/cli/ai/runtimes/pi/tests/site-pin.test.ts @@ -58,12 +58,11 @@ describe( 'SitePin', () => { it( 'pins the first changed site when none is active, and includes sites the turn creates', async () => { const pin = new SitePin( null ); - expect( await pin.confirmationFor( 'scaffold_theme', { nameOrPath: 'Shop' } ) ).toBeUndefined(); + expect( await pin.confirmationFor( 'site_import', { nameOrPath: 'Shop' } ) ).toBeUndefined(); await pin.created( 'New' ); expect( await pin.confirmationFor( 'Write', { path: '/sites/new/x.php' } ) ).toBeUndefined(); expect( - ( await pin.confirmationFor( 'scaffold_theme', { nameOrPath: 'Blog' } ) )?.confirmation - .question + ( await pin.confirmationFor( 'site_import', { nameOrPath: 'Blog' } ) )?.confirmation.question ).toBe( 'Also change "Blog"? This request is about "Shop".' ); } ); diff --git a/apps/cli/ai/runtimes/pi/tests/skill-tool-scope.test.ts b/apps/cli/ai/runtimes/pi/tests/skill-tool-scope.test.ts index dff7e22240..ab7b67b5ae 100644 --- a/apps/cli/ai/runtimes/pi/tests/skill-tool-scope.test.ts +++ b/apps/cli/ai/runtimes/pi/tests/skill-tool-scope.test.ts @@ -2,14 +2,7 @@ import { SessionManager } from '@earendil-works/pi-coding-agent'; import { describe, expect, it } from 'vitest'; import { SkillToolScope } from '../skill-tool-scope'; -const ALL = [ - 'Read', - 'wp_cli', - 'scaffold_theme', - 'pick_design', - 'install_taxonomy_scripts', - 'need_for_speed', -]; +const ALL = [ 'Read', 'wp_cli', 'pick_design', 'install_taxonomy_scripts', 'need_for_speed' ]; function sessionWithSkillCall( skill?: string ): SessionManager { const session = SessionManager.inMemory(); @@ -40,12 +33,7 @@ describe( 'SkillToolScope', () => { const scope = new SkillToolScope( sessionWithSkillCall() ); expect( scope.activeToolNames( ALL ) ).toEqual( [ 'Read', 'wp_cli' ] ); scope.unlock( 'visual-design' ); - expect( scope.activeToolNames( ALL ) ).toEqual( [ - 'Read', - 'wp_cli', - 'scaffold_theme', - 'pick_design', - ] ); + expect( scope.activeToolNames( ALL ) ).toEqual( [ 'Read', 'wp_cli', 'pick_design' ] ); } ); it( 'restores tools from skills loaded earlier in the conversation', () => { diff --git a/apps/cli/ai/site-file-guards.ts b/apps/cli/ai/site-file-guards.ts index ccc1b3cda1..21b3329444 100644 --- a/apps/cli/ai/site-file-guards.ts +++ b/apps/cli/ai/site-file-guards.ts @@ -10,6 +10,7 @@ */ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; import path from 'path'; +import { settleThemeDesign } from '@studio/common/sites/site-design'; import { savedCopyWriteNote } from 'cli/ai/saved-copies'; import { readWordPressSite } from 'cli/ai/site-database'; import { isUpdatableTheme } from 'cli/ai/themes'; @@ -147,7 +148,7 @@ export async function getSiteFileWriteRefusal( ) { reason = `${ relativePath } is this Studio site's copy of WordPress core, which a WordPress update replaces. Read it as reference. Make the change in wp-content/ (a theme, child theme, or plugin).`; } else if ( theme && ( await isUpdatableTheme( siteRoot, theme.slug ) ) ) { - reason = `${ theme.slug } is a theme WordPress updates, and an update overwrites changes to its files. Create a child theme with scaffold_theme (parentTheme: "${ theme.slug }") and make the change there.`; + reason = `${ theme.slug } is a theme WordPress updates, and an update overwrites changes to its files. Make the change in a child theme of it, whose style.css names \`Template: ${ theme.slug }\`.`; } if ( ! reason || ! ( await isStudioSite( siteRoot ) ) ) { return undefined; @@ -175,13 +176,64 @@ async function getSiteFileWriteNotes( siteFile: SiteFile ): Promise< string[] > return notes; } +// The theme a write to DESIGN.md or a theme.json leaves out of step with the +// site's design: the active theme for DESIGN.md, the theme a theme.json is in. +// Themes WordPress updates are left as they are. +async function themeOfDesignWrite( siteFile: SiteFile ): Promise< string | undefined > { + const { siteRoot, relativePath, theme } = siteFile; + const slug = + relativePath === 'DESIGN.md' + ? await readWordPressSite( siteRoot, ( site ) => site.option( 'stylesheet' ) ) + : theme?.file === 'theme.json' + ? theme.slug + : undefined; + return typeof slug === 'string' && ! ( await isUpdatableTheme( siteRoot, slug ) ) + ? slug + : undefined; +} + +/** + * Keeps a theme built on the site's DESIGN.md carrying its tokens, after a + * write to either, and says what it wrote. + */ +async function settleDesign( siteFile: SiteFile ): Promise< string[] > { + const slug = await themeOfDesignWrite( siteFile ); + if ( ! slug ) { + return []; + } + const { drift, fontsError } = await settleThemeDesign( siteFile.siteRoot, slug ); + if ( ! drift.length ) { + return []; + } + const kinds: Record< string, string > = { + color: 'colors', + 'font-family': 'font families', + 'font-size': 'font sizes', + spacing: 'spacing steps', + 'style-font': 'fonts of the styles', + }; + const written = Object.entries( kinds ).flatMap( ( [ kind, label ] ) => { + const slugs = drift.filter( ( entry ) => entry.kind === kind ).map( ( entry ) => entry.slug ); + return slugs.length ? [ `${ label } ${ slugs.join( ', ' ) }` ] : []; + } ); + return [ + `Studio wrote DESIGN.md's tokens into wp-content/themes/${ slug }/theme.json under DESIGN.md's names: ${ written.join( + '; ' + ) }.${ + fontsError ? ` The font files did not download (${ fontsError }).` : '' + } DESIGN.md holds these values; change the look there.`, + ]; +} + /** - * After a write to a site's file: registers a changed pattern, and returns - * what WordPress will render differently from the file. + * After a write to a site's file: registers a changed pattern, keeps the + * theme carrying DESIGN.md's tokens, and returns what WordPress will render + * differently from the file. */ export async function afterSiteFileWrite( siteFile: SiteFile ): Promise< string[] > { await clearThemePatternCache( siteFile ).catch( () => undefined ); - return getSiteFileWriteNotes( siteFile ).catch( () => [] ); + const design = await settleDesign( siteFile ).catch( () => [] ); + return [ ...design, ...( await getSiteFileWriteNotes( siteFile ).catch( () => [] ) ) ]; } /** diff --git a/apps/cli/ai/skills/build-site/SKILL.md b/apps/cli/ai/skills/build-site/SKILL.md index 032324ecca..9dbc45f90b 100644 --- a/apps/cli/ai/skills/build-site/SKILL.md +++ b/apps/cli/ai/skills/build-site/SKILL.md @@ -1,8 +1,8 @@ --- name: build-site -description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the design and plugins as a Site Spec, then scaffold the theme, provision the site, publish the content, and polish the result. Load it before picking or creating the site. +description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the design and plugins as a Site Spec, then build the theme, provision the site, publish the content, and polish the result. Load it before picking or creating the site. user-invokable: true -tools: scaffold_theme, pick_design, present_design_options, generate_images +tools: pick_design, present_design_options, generate_images --- # Build a Site @@ -54,7 +54,7 @@ Skipping means skipping the interactive questions only — each design step then ## 3. Theme files -Call `scaffold_theme` on its own: without `parentTheme` for a new theme, with it to restyle an installed theme as a child theme. With a DESIGN.md it fills theme.json and self-hosts the fonts. Then fill the scaffold one file at a time — templates, parts, style.css, patterns. +Write a block theme in `wp-content/themes/`, or a child theme to restyle an installed theme, one file at a time: style.css, theme.json, templates, parts, patterns. ## 4. Provision the site @@ -66,4 +66,4 @@ Write each page's content to a scratch file in the site's `tmp/`, then create or ## 6. Check the result -Take one `take_screenshot` of each page with `viewport: "all"`. List every real defect against DESIGN.md and the layout map, find each cause with inspect_design and the source that produces it, fix them in one batch, and re-check once. When the theme was scaffolded by Studio Code, pass its directory as `themeScreenshot` on the home page's last desktop capture. +Take one `take_screenshot` of each page with `viewport: "all"`. List every real defect against DESIGN.md and the layout map, find each cause with inspect_design and the source that produces it, fix them in one batch, and re-check once. When Studio Code wrote the theme, pass its directory as `themeScreenshot` on the home page's last desktop capture. diff --git a/apps/cli/ai/skills/visual-design/SKILL.md b/apps/cli/ai/skills/visual-design/SKILL.md index bbf4536e0a..4f29400b2e 100644 --- a/apps/cli/ai/skills/visual-design/SKILL.md +++ b/apps/cli/ai/skills/visual-design/SKILL.md @@ -1,7 +1,7 @@ --- name: visual-design description: Plan and execute high-quality visual direction for site creation, redesign, layout, typography, color, motion, and visual polish. -tools: scaffold_theme, pick_design +tools: pick_design --- # Visual Design @@ -22,7 +22,7 @@ The two catalogs at the end of this runbook list every layout concept and every 6. Build what was settled or picked — never a substitute. In a redesign, a side the user keeps counts as named in the brief. 7. Adapt both to the site: change the subject, the proportions, or the content that fills each slot of the concept so it belongs to this brand rather than to the catalog, and tune the direction's palette and type to the brand while keeping its relationships. Name the twist for each. "Simple" or "small" in a brief means fewer pages and less content, not a tamer concept or direction. 8. A **layout map** is the plan the build follows, written before any code: one line per section of the page, in page order, saying what the concept does to that section — which slot of the layout it fills, how it is positioned relative to the viewport and its neighbors, what it must not fall back to. Every section gets a line, including the header, the footer, and any form; a section the concept does not shape must say so explicitly and justify why. -9. Build the concept first, in the hero or first template, not as a finishing touch, and follow the entry's build and fallback notes: theme CSS and a small vanilla script only, editable content, and a mobile layout. `scaffold_theme` writes `DESIGN.md`'s colors, typography, spacing, and radii into `theme.json` under the same slugs, so edit `theme.json` only for what `DESIGN.md` leaves out, and follow its sections throughout — the look is a system that every section obeys, not a coat of paint on the hero. Shape language still serves the concept: a direction's rounded corners go on inner elements (buttons, cards, form fields), never on a section, row, or grid cell that touches the viewport edge — anything full-bleed stays square-cornered so it still meets the edge. +9. Build the concept first, in the hero or first template, not as a finishing touch, and follow the entry's build and fallback notes: theme CSS and a small vanilla script only, editable content, and a mobile layout. Follow `DESIGN.md`'s sections throughout — the look is a system that every section obeys, not a coat of paint on the hero. Shape language still serves the concept: a direction's rounded corners go on inner elements (buttons, cards, form fields), never on a section, row, or grid cell that touches the viewport edge — anything full-bleed stays square-cornered so it still meets the edge. ## DESIGN.md @@ -31,7 +31,6 @@ The look is written down once, as `DESIGN.md` at the site root, in the DESIGN.md - **Front matter**: `name` (the site's name), `description` (its tagline), `colors` (quoted hex values, each color once: `primary`, `background`, `text`, and any accents), `typography` (named styles such as `display`, `headline`, `body`, and `label`, each with a Google Fonts `fontFamily`, a `fontSize` in px or rem, `fontWeight`, `lineHeight`, and `letterSpacing` where it matters), `rounded` and `spacing` (named scales, such as `sm: 4px`), `components` (at least `button-primary` with `backgroundColor`, `textColor`, `rounded`, and `padding`), and `imagery` (the direction's photo treatment as CSS: `filter`, the value from its Imagery line, left out when it names none, and `overlay`, `multiply` or `screen`, only for a duotone, where a layer of `primary` sits over the photo). Component values can reference tokens, as in `"{colors.primary}"`. - **Sections**, in this order: Overview, Colors, Typography, Layout, Elevation & Depth, Shapes, Components, Do's and Don'ts, Imagery, Motion, Voice. They carry the direction's lines, tuned to the brand: Palette into Colors, Type into Typography, Surface into Elevation & Depth, Avoid into Do's and Don'ts, and Shapes, Imagery, and Motion into their own. Until the layout is picked, Layout holds only the content widths and spacing rhythm, and no section describes the page's structure — no grids, bands, or concept names; the layout step then adds the signature concept and how every page follows it. Voice is how the copy talks: its tone, and the words to use and to avoid. - **A draft**, for a design option, is the front matter and the Overview. It is rendered as a design-system board — the `display` and `body` type with the scale, the colors as a grid led by `primary`, the components, and the look image under the option's `imagery` treatment, framed in its `rounded` shape — so the user judges exactly the tokens. The picked draft becomes the file: tokens unchanged, every section written. -- **Keep it true**: `theme.json` follows its tokens under the same names — the `primary` color is the `primary` palette slug — and a later change to the look updates `DESIGN.md` in the same turn. ## Sneak peeks @@ -45,7 +44,7 @@ A sneak peek is a standalone HTML page that lets a layout concept be judged in t ## Craft - Build the concept with precision: distinctive type pairings, deliberate dominant colors and accents, intentional spacing, and a real mobile layout. Minimal is not unfinished; maximal needs enough detail to feel intended. -- Drive every color, font, and size from the theme's presets, which `scaffold_theme` writes from `DESIGN.md` under the same slugs. +- Drive every color, font, and size from the theme's presets. - Motion serves the concept: CSS first, elements visible by default with JavaScript adding the hidden starting state (the editor runs no theme JS), and a `prefers-reduced-motion` fallback. - Avoid generic AI output: purple-on-white gradients, conceptless hero/card/feature grids, default font stacks, the same formula on every site. diff --git a/apps/cli/ai/tests/file-write-refusal.test.ts b/apps/cli/ai/tests/file-write-refusal.test.ts index b48109a23c..ae1603278d 100644 --- a/apps/cli/ai/tests/file-write-refusal.test.ts +++ b/apps/cli/ai/tests/file-write-refusal.test.ts @@ -74,7 +74,7 @@ describe( 'getFileWriteRefusal', () => { ); for ( const updatable of [ 'twentytwentyfive', 'premium' ] ) { - expect( await refusal( updatable ) ).toContain( `parentTheme: "${ updatable }"` ); + expect( await refusal( updatable ) ).toContain( `Template: ${ updatable }` ); } // Checked, with no update source, or not checked yet: the user's own themes. for ( const own of [ 'client-theme', 'café', 'fresh' ] ) { diff --git a/apps/cli/ai/tests/tools.test.ts b/apps/cli/ai/tests/tools.test.ts index 55c5110494..0546fe50dc 100644 --- a/apps/cli/ai/tests/tools.test.ts +++ b/apps/cli/ai/tests/tools.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises'; +import { mkdtemp, readdir, readFile, rm } from 'fs/promises'; import os from 'os'; import path from 'path'; import { getConnectedWpcomSitesForLocalSite } from '@studio/common/lib/connected-sites'; @@ -241,7 +241,7 @@ describe( 'Studio AI MCP tools', () => { ); expect( annotations.site_list ).toEqual( REMOTE_READ ); expect( annotations.site_info ).toEqual( READ_ONLY ); - expect( annotations.scaffold_theme ).toEqual( LOCAL_CHANGE ); + expect( annotations.site_create ).toEqual( LOCAL_CHANGE ); for ( const name of [ 'site_delete', 'site_push', 'site_pull', 'wp_cli' ] ) { const { readOnlyHint, destructiveHint, openWorldHint } = annotations[ name ]; expect( readOnlyHint || ( ! destructiveHint && ! openWorldHint ) ).toBe( false ); @@ -1049,433 +1049,4 @@ describe( 'Studio AI MCP tools', () => { await rm( sitePath, { recursive: true, force: true } ); } } ); - - describe( 'scaffold_theme', () => { - let tempSiteRoot: string; - let scaffoldSite: typeof mockSite; - - beforeEach( async () => { - tempSiteRoot = await mkdtemp( path.join( os.tmpdir(), 'studio-scaffold-theme-' ) ); - await mkdir( path.join( tempSiteRoot, 'wp-content', 'themes' ), { recursive: true } ); - scaffoldSite = { ...mockSite, path: tempSiteRoot }; - vi.mocked( readCliConfig ).mockResolvedValue( { - sites: [ scaffoldSite ], - } as Awaited< ReturnType< typeof readCliConfig > > ); - vi.mocked( getSiteByFolder ).mockResolvedValue( scaffoldSite ); - } ); - - afterEach( async () => { - vi.unstubAllGlobals(); - await rm( tempSiteRoot, { recursive: true, force: true } ); - } ); - - it( 'is registered in the tool definitions', () => { - expect( studioToolDefinitions.map( ( tool ) => tool.name ) ).toContain( 'scaffold_theme' ); - } ); - - it( 'creates the expected files and directories under wp-content/themes/', async () => { - const result = await getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Acme Studio', - } as never ); - - const themeDir = path.join( tempSiteRoot, 'wp-content', 'themes', 'acme-studio' ); - const expectedFiles = [ - 'style.css', - 'theme.json', - 'functions.php', - 'templates/index.html', - 'templates/single.html', - 'templates/page.html', - 'templates/archive.html', - 'templates/404.html', - 'parts/header.html', - 'parts/footer.html', - ]; - for ( const rel of expectedFiles ) { - await expect( stat( path.join( themeDir, rel ) ) ).resolves.toBeDefined(); - } - - const fontsDir = await stat( path.join( themeDir, 'assets', 'fonts' ) ); - expect( fontsDir.isDirectory() ).toBe( true ); - const patternsDir = await stat( path.join( themeDir, 'patterns' ) ); - expect( patternsDir.isDirectory() ).toBe( true ); - - const styleCss = await readFile( path.join( themeDir, 'style.css' ), 'utf8' ); - expect( styleCss ).toContain( 'Theme Name: Acme Studio' ); - expect( styleCss ).toContain( 'Text Domain: acme-studio' ); - expect( styleCss ).toContain( 'scaffolded by Studio Code' ); - expect( styleCss ).not.toContain( 'Template:' ); - expect( styleCss ).toContain( '.wp-site-blocks > * + * {' ); - expect( styleCss ).toContain( 'margin-block-start: 0;' ); - expect( styleCss ).toContain( '.wp-site-blocks main {' ); - expect( styleCss ).toContain( '.wp-site-blocks main.is-flush {' ); - - const pageNoTitle = await readFile( - path.join( themeDir, 'templates', 'page-no-title.html' ), - 'utf8' - ); - expect( pageNoTitle ).toContain( '{"tagName":"main","className":"is-flush"}' ); - - for ( const template of await readdir( path.join( themeDir, 'templates' ) ) ) { - const markup = await readFile( path.join( themeDir, 'templates', template ), 'utf8' ); - expect( markup.match( //g ) ).toEqual( [ - '', - '', - ] ); - } - - const themeJson = JSON.parse( - await readFile( path.join( themeDir, 'theme.json' ), 'utf8' ) - ) as Record< string, unknown >; - expect( themeJson.version ).toBe( 3 ); - expect( ( themeJson.settings as Record< string, unknown > ).appearanceTools ).toBe( true ); - - const functionsPhp = await readFile( path.join( themeDir, 'functions.php' ), 'utf8' ); - expect( functionsPhp ).toContain( "'acme-studio-style'" ); - expect( functionsPhp ).toContain( "add_editor_style( 'style.css' )" ); - - expect( getTextContent( result ) ).toContain( - "Block theme 'Acme Studio' scaffolded at wp-content/themes/acme-studio/." - ); - expect( getTextContent( result ) ).toContain( 'wp theme activate acme-studio' ); - expect( getTextContent( result ) ).toContain( - `\n${ pageNoTitle }` - ); - } ); - - const writeNunitoDesign = () => - writeFile( - path.join( tempSiteRoot, 'DESIGN.md' ), - '---\ncolors:\n primary: "#e2231a"\ntypography:\n body:\n fontFamily: "Nunito, sans-serif"\n fontWeight: 400\n headline:\n fontFamily: "Nunito, sans-serif"\n fontWeight: 700\n---\n' - ); - - it( 'fills theme.json from DESIGN.md and downloads its fonts into the theme', async () => { - await writeNunitoDesign(); - const face = ( subset: string, weight: number, file: string ) => - `/* ${ subset } */\n@font-face {\n font-family: 'Nunito';\n font-style: normal;\n font-weight: ${ weight };\n src: url(https://fonts.gstatic.com/${ file }.woff2) format('woff2');\n unicode-range: U+${ subset };\n}\n`; - const fetchMock = vi.fn( async ( url: string ) => - url.startsWith( 'https://fonts.googleapis.com/' ) - ? new Response( - [ 400, 700 ] - .flatMap( ( weight ) => [ - face( 'latin-ext', weight, 'ext' ), - face( 'latin', weight, 'latin' ), - ] ) - .join( '' ) - ) - : new Response( new Uint8Array( [ 1, 2, 3 ] ) ) - ); - vi.stubGlobal( 'fetch', fetchMock ); - - const result = await getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Acme Studio', - activate: false, - } as never ); - - const themeDir = path.join( tempSiteRoot, 'wp-content', 'themes', 'acme-studio' ); - const themeJson = JSON.parse( await readFile( path.join( themeDir, 'theme.json' ), 'utf8' ) ); - expect( themeJson.settings.color.palette ).toEqual( [ - { slug: 'primary', color: '#e2231a', name: 'Primary' }, - ] ); - expect( themeJson.settings.layout ).toEqual( { contentSize: '1000px', wideSize: '1280px' } ); - expect( fetchMock ).toHaveBeenCalledWith( - 'https://fonts.googleapis.com/css2?family=Nunito:wght@400;700&display=swap', - expect.anything() - ); - expect( themeJson.settings.typography.fontFamilies[ 0 ].fontFace ).toEqual( - [ 'latin-ext', 'latin' ].map( ( subset ) => ( { - fontFamily: 'Nunito', - fontStyle: 'normal', - fontWeight: '400 700', - src: [ `file:./assets/fonts/nunito/nunito-400-700-${ subset }.woff2` ], - unicodeRange: `U+${ subset }`, - } ) ) - ); - await expect( - readFile( path.join( themeDir, 'assets/fonts/nunito/nunito-400-700-latin.woff2' ) ) - ).resolves.toEqual( Buffer.from( [ 1, 2, 3 ] ) ); - expect( getTextContent( result ) ).toContain( - 'theme.json carries the DESIGN.md tokens under the same names (1 color, 1 font family, 2 text styles, 0 spacing steps), with its fonts downloaded to assets/fonts and declared as fontFace.' - ); - } ); - - it( 'honors an explicit slug argument over the derived one', async () => { - await getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Acme Studio', - slug: 'custom-slug', - } as never ); - - await expect( - stat( path.join( tempSiteRoot, 'wp-content', 'themes', 'custom-slug' ) ) - ).resolves.toBeDefined(); - await expect( - stat( path.join( tempSiteRoot, 'wp-content', 'themes', 'acme-studio' ) ) - ).rejects.toThrow(); - } ); - - it( 'treats an empty parentTheme as no parent and scaffolds a blank theme', async () => { - const result = await getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Acme Studio', - parentTheme: '', - } as never ); - - expect( getTextContent( result ) ).not.toMatch( /Child theme/ ); - await expect( - stat( - path.join( - tempSiteRoot, - 'wp-content', - 'themes', - 'acme-studio', - 'templates', - 'index.html' - ) - ) - ).resolves.toBeDefined(); - } ); - - it( 'fails when the target theme directory already exists', async () => { - await mkdir( path.join( tempSiteRoot, 'wp-content', 'themes', 'acme-studio' ), { - recursive: true, - } ); - await writeFile( - path.join( tempSiteRoot, 'wp-content', 'themes', 'acme-studio', 'sentinel.txt' ), - 'preexisting' - ); - - await expect( - getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Acme Studio', - } as never ) - ).rejects.toThrow( /already exists/ ); - - // Pre-existing file is untouched. - const sentinel = await readFile( - path.join( tempSiteRoot, 'wp-content', 'themes', 'acme-studio', 'sentinel.txt' ), - 'utf8' - ); - expect( sentinel ).toBe( 'preexisting' ); - } ); - - it( 'fails when wp-content/themes is missing from the site', async () => { - await rm( path.join( tempSiteRoot, 'wp-content' ), { recursive: true, force: true } ); - - await expect( - getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Acme Studio', - } as never ) - ).rejects.toThrow( /wp-content\/themes directory not found/ ); - } ); - - it( 'rejects invalid explicit slugs', async () => { - await expect( - getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Acme Studio', - slug: 'Not Valid!', - } as never ) - ).rejects.toThrow( /slug must contain only/ ); - } ); - - it( 'rejects empty theme names', async () => { - await expect( - getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: ' ', - } as never ) - ).rejects.toThrow( /name must not be empty/ ); - } ); - - it( 'activates the theme by default when the site is running', async () => { - vi.mocked( isServerRunning ).mockResolvedValue( { - name: scaffoldSite.id, - pmId: 1, - status: 'online', - pid: 1234, - } ); - vi.mocked( runWpCliCommand ).mockResolvedValue( - mockWpCliResponse( { stdout: "Success: Switched to 'Acme Studio' theme." } ) as never - ); - - const result = await getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Acme Studio', - } as never ); - - expect( runWpCliCommand ).toHaveBeenCalledWith( scaffoldSite, [ - 'theme', - 'activate', - 'acme-studio', - ] ); - expect( getTextContent( result ) ).toContain( - "Activated: Success: Switched to 'Acme Studio' theme." - ); - expect( getTextContent( result ) ).not.toContain( 'Activation skipped' ); - } ); - - it( 'skips activation when activate is false', async () => { - vi.mocked( isServerRunning ).mockResolvedValue( { - name: scaffoldSite.id, - pmId: 1, - status: 'online', - pid: 1234, - } ); - - const result = await getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Acme Studio', - activate: false, - } as never ); - - expect( runWpCliCommand ).not.toHaveBeenCalled(); - expect( getTextContent( result ) ).toContain( - 'Activate with: wp theme activate acme-studio' - ); - } ); - - it( 'reports activation skipped when the site is not running', async () => { - vi.mocked( isServerRunning ).mockResolvedValue( undefined ); - - const result = await getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Acme Studio', - } as never ); - - expect( runWpCliCommand ).not.toHaveBeenCalled(); - expect( getTextContent( result ) ).toContain( 'Activation skipped:' ); - expect( getTextContent( result ) ).toContain( 'Site is not running' ); - expect( getTextContent( result ) ).toContain( - 'Activate manually with: wp theme activate acme-studio' - ); - } ); - - it( 'reports activation failure when WP-CLI returns a non-zero exit code', async () => { - vi.mocked( isServerRunning ).mockResolvedValue( { - name: scaffoldSite.id, - pmId: 1, - status: 'online', - pid: 1234, - } ); - vi.mocked( runWpCliCommand ).mockResolvedValue( - mockWpCliResponse( { stderr: 'Error: stylesheet missing.', exitCode: 1 } ) as never - ); - - const result = await getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Acme Studio', - } as never ); - - expect( getTextContent( result ) ).toContain( - 'Activation skipped: WP-CLI exited with code 1' - ); - expect( getTextContent( result ) ).toContain( 'Error: stylesheet missing.' ); - } ); - - describe( 'child themes (parentTheme)', () => { - async function installParentTheme( slug: string, styleCss?: string ) { - const parentDir = path.join( tempSiteRoot, 'wp-content', 'themes', slug ); - await mkdir( parentDir, { recursive: true } ); - await writeFile( - path.join( parentDir, 'style.css' ), - styleCss ?? `/*\nTheme Name: ${ slug }\n*/\n`, - 'utf8' - ); - } - - it( 'scaffolds a minimal child theme when parentTheme is given', async () => { - await installParentTheme( 'ollie' ); - - const result = await getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Ollie Child', - parentTheme: 'ollie', - } as never ); - - const themeDir = path.join( tempSiteRoot, 'wp-content', 'themes', 'ollie-child' ); - - const styleCss = await readFile( path.join( themeDir, 'style.css' ), 'utf8' ); - expect( styleCss ).toContain( 'Theme Name: Ollie Child' ); - expect( styleCss ).toContain( 'Template: ollie' ); - expect( styleCss ).toContain( 'scaffolded by Studio Code' ); - expect( styleCss ).not.toContain( '.wp-site-blocks' ); - - const themeJson = JSON.parse( - await readFile( path.join( themeDir, 'theme.json' ), 'utf8' ) - ) as Record< string, unknown >; - expect( themeJson.version ).toBe( 3 ); - expect( themeJson.settings ).toBeUndefined(); - - const functionsPhp = await readFile( path.join( themeDir, 'functions.php' ), 'utf8' ); - expect( functionsPhp ).toContain( - "'ollie-parent-style',\n\t\tget_template_directory_uri() . '/style.css'" - ); - expect( functionsPhp ).toContain( - "'ollie-child-style',\n\t\tget_stylesheet_directory_uri() . '/style.css',\n\t\tarray( 'ollie-parent-style' )" - ); - expect( functionsPhp ).not.toContain( 'get_parent_theme_file_uri' ); - - await expect( stat( path.join( themeDir, 'templates' ) ) ).rejects.toThrow(); - await expect( stat( path.join( themeDir, 'parts' ) ) ).rejects.toThrow(); - - expect( getTextContent( result ) ).toContain( - "Child theme 'Ollie Child' of 'ollie' scaffolded at wp-content/themes/ollie-child/." - ); - expect( getTextContent( result ) ).toContain( "inherit from 'ollie'" ); - } ); - - it( 'fails when the parent theme is not installed', async () => { - await expect( - getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Ollie Child', - parentTheme: 'ollie', - } as never ) - ).rejects.toThrow( /Parent theme 'ollie' is not installed/ ); - } ); - - it( 'fails when the parent is itself a child theme', async () => { - await installParentTheme( - 'ollie-child', - '/*\nTheme Name: Ollie Child\nTemplate: ollie\n*/\n' - ); - - await expect( - getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Grandchild', - parentTheme: 'ollie-child', - } as never ) - ).rejects.toThrow( /grandchild themes.*parentTheme: 'ollie'/ ); - } ); - - it( 'fails when parentTheme equals the child slug', async () => { - await installParentTheme( 'ollie' ); - - await expect( - getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Ollie', - slug: 'ollie', - parentTheme: 'ollie', - } as never ) - ).rejects.toThrow( /must be different from the child theme slug/ ); - } ); - - it( 'rejects invalid parent theme slugs', async () => { - await expect( - getTool( 'scaffold_theme' ).rawHandler( { - nameOrPath: scaffoldSite.name, - name: 'Ollie Child', - parentTheme: 'Not Valid!', - } as never ) - ).rejects.toThrow( /Parent theme slug must contain only/ ); - } ); - } ); - } ); } ); diff --git a/apps/cli/ai/tools/index.ts b/apps/cli/ai/tools/index.ts index 7983b9dd52..a975b2d1be 100644 --- a/apps/cli/ai/tools/index.ts +++ b/apps/cli/ai/tools/index.ts @@ -17,7 +17,6 @@ import { createPickDesignTool, pickDesignTool } from './pick-design'; import { pullSiteTool } from './pull-site'; import { pushSiteTool } from './push-site'; import { auditSeoTool } from './rank-me-up'; -import { scaffoldThemeTool } from './scaffold-theme'; import { getSiteInfoTool } from './site-info'; import { startSiteTool } from './start-site'; import { stopSiteTool } from './stop-site'; @@ -41,7 +40,6 @@ export const studioToolDefinitions: AnyStudioAgentTool[] = [ updatePreviewTool, deletePreviewTool, runWpCliTool, - scaffoldThemeTool, pickDesignTool, takeScreenshotTool, inspectDesignTool, diff --git a/apps/cli/ai/tools/scaffold-theme.ts b/apps/cli/ai/tools/scaffold-theme.ts deleted file mode 100644 index 551da619c3..0000000000 --- a/apps/cli/ai/tools/scaffold-theme.ts +++ /dev/null @@ -1,598 +0,0 @@ -import { mkdir, readFile, stat, writeFile } from 'fs/promises'; -import path from 'path'; -import { downloadThemeFonts } from '@studio/common/lib/theme-fonts'; -import { parseDesignMd, ThemeJson, themeJsonFromDesign } from '@studio/design-md'; -import { Type } from 'typebox'; -import { SiteData } from 'cli/lib/cli-config/core'; -import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; -import { isServerRunning } from 'cli/lib/wordpress-server-manager'; -import { defineTool, LOCAL_CHANGE } from './define-tool'; -import { resolveSite, runWpCli, textResult } from './utils'; - -async function activateTheme( - site: SiteData, - slug: string -): Promise< { ok: boolean; message: string } > { - try { - await connectToDaemon(); - try { - const running = await isServerRunning( site.id ); - if ( ! running ) { - return { - ok: false, - message: `Site is not running. Start it (site_start) then run \`wp theme activate ${ slug }\`.`, - }; - } - const stdout = await runWpCli( site, [ 'theme', 'activate', slug ] ); - return { ok: true, message: stdout || `Activated theme '${ slug }'.` }; - } finally { - await disconnectFromDaemon(); - } - } catch ( error ) { - return { - ok: false, - message: error instanceof Error ? error.message : String( error ), - }; - } -} - -function deriveSlug( input: string ): string { - return input - .toLowerCase() - .replace( /[^a-z0-9]+/g, '-' ) - .replace( /^-+|-+$/g, '' ); -} - -async function pathExists( p: string ): Promise< boolean > { - try { - await stat( p ); - return true; - } catch { - return false; - } -} - -function renderStyleCss( name: string, slug: string ): string { - return `/* -Theme Name: ${ name } -Description: A custom block theme scaffolded by Studio Code. -Requires at least: 6.7 -Tested up to: 6.9 -Requires PHP: 7.2 -Version: 0.1.0 -License: GNU General Public License v2 or later -License URI: http://www.gnu.org/licenses/gpl-2.0.html -Text Domain: ${ slug } -Tags: full-site-editing, block-patterns, block-styles, wide-blocks, accessibility-ready, style-variations -*/ - -/* WordPress inserts a margin-block-start equal to the block gap (24px unless - theme.json sets styles.spacing.blockGap) between the template's top-level - sections (header part, main, footer part) — a gap that exists even when no - markup asks for it. Zero it so sections butt edge-to-edge and own their - vertical rhythm via padding; this does not affect block gaps inside nested - layouts. */ -.wp-site-blocks > * + * { - margin-block-start: 0; -} - -/* With that gap gone, main carries its own vertical padding so templates this - theme does not author (WooCommerce shop, product, cart…) still clear the - header and footer. A descendant selector, not a child one: WooCommerce wraps - the single-product main in an extra group. Templates built from full-bleed - sections opt out with is-flush and let the sections own the rhythm. */ -.wp-site-blocks main { - padding-block: var(--wp--preset--spacing--60) var(--wp--preset--spacing--70); -} -.wp-site-blocks main.is-flush { - padding-block: 0; -} -`; -} - -function renderChildStyleCss( name: string, slug: string, parentSlug: string ): string { - return `/* -Theme Name: ${ name } -Description: A child theme of ${ parentSlug }, scaffolded by Studio Code. -Template: ${ parentSlug } -Requires at least: 6.7 -Tested up to: 6.9 -Requires PHP: 7.2 -Version: 0.1.0 -License: GNU General Public License v2 or later -License URI: http://www.gnu.org/licenses/gpl-2.0.html -Text Domain: ${ slug } -Tags: full-site-editing, block-patterns, block-styles, wide-blocks, accessibility-ready, style-variations -*/ -`; -} - -function baseThemeJson(): ThemeJson { - return { - $schema: 'https://schemas.wp.org/wp/6.7/theme.json', - version: 3, - settings: { - appearanceTools: true, - // Without these, WordPress omits the max-width declaration from the - // constrained-layout rules entirely, so `layout: constrained` stops - // constraining anything and every block runs the full viewport width. - layout: { - contentSize: '1000px', - wideSize: '1280px', - }, - // Puts the horizontal padding below on `.has-global-padding` (every - // constrained block) rather than on `.wp-site-blocks`, so section - // backgrounds still reach the viewport edge while their content does not. - useRootPaddingAwareAlignments: true, - }, - styles: { - spacing: { - padding: { - top: '0px', - right: 'clamp(1.25rem, 5vw, 3rem)', - bottom: '0px', - left: 'clamp(1.25rem, 5vw, 3rem)', - }, - }, - }, - // `page.html` renders the post title as the page's h1. A designed page whose - // hero carries its own heading needs a way to opt out of that title without - // removing it from `page.html`, which every ordinary page still wants. - customTemplates: [ - { - name: 'page-no-title', - title: 'Page (no title)', - postTypes: [ 'page' ], - }, - ], - }; -} - -function childThemeJson(): ThemeJson { - return { - $schema: 'https://schemas.wp.org/wp/6.7/theme.json', - version: 3, - }; -} - -function renderThemeJson( data: ThemeJson ): string { - return JSON.stringify( data, null, '\t' ) + '\n'; -} - -function renderFunctionsPhp( name: string, slug: string ): string { - return `get( 'Version' ) - ); -} ); - -add_action( 'after_setup_theme', function () { - add_editor_style( 'style.css' ); -} ); -`; -} - -function renderChildFunctionsPhp( name: string, slug: string, parentSlug: string ): string { - return `get( 'Version' ) - ); - wp_enqueue_style( - '${ slug }-style', - get_stylesheet_directory_uri() . '/style.css', - array( '${ parentSlug }-parent-style' ), - wp_get_theme()->get( 'Version' ) - ); -} ); - -add_action( 'after_setup_theme', function () { - add_editor_style( 'style.css' ); -} ); -`; -} - -const TEMPLATE_INDEX = ` - - -
- -
- -
- - - - - - - - - - - - - - -

No posts were found.

- - -
- -
- -
- - - -`; - -const TEMPLATE_SINGLE = ` - - -
- -
- - - -
- - - - - -
- -
- -
- - - -`; - -const TEMPLATE_PAGE = ` - - -
- -
- -
- - - -
- - - -`; - -const TEMPLATE_PAGE_NO_TITLE = ` - - -
- -
- - - -`; - -const TEMPLATE_ARCHIVE = ` - - -
- -
- - - - -
- - - - - - - - - - - -
- -
- -
- - - -`; - -const TEMPLATE_404 = ` - - -
- -
- -

Page not found

- - - -

The page you were looking for doesn't exist. Try a search instead.

- - - -
- -
- - - -`; - -const PART_HEADER = ` -
- -
- - -
- -
- -`; - -const PART_FOOTER = ` -
- -
- -

©

- - -
- -
- -`; - -export const scaffoldThemeTool = defineTool( - 'scaffold_theme', - 'Scaffolds a minimal block theme into the given site at wp-content/themes// and activates it by default. ' + - 'Drops in style.css (theme header, a reset zeroing the default block gap between top-level template sections, and default vertical padding on main that full-bleed templates opt out of with the is-flush class), ' + - 'theme.json (appearanceTools, a content/wide layout width, and root-padding-aware horizontal padding so content never touches the viewport edge), ' + - 'functions.php (frontend + editor style enqueue), default templates (index, single, page, archive, 404), ' + - 'a registered page-no-title template to assign to designed pages whose content carries its own heading, ' + - 'header/footer parts, and empty assets/fonts and patterns directories. ' + - 'The result holds the content of every file it wrote, so edit them without reading them first. ' + - 'When the site has a DESIGN.md, theme.json is filled from its tokens — palette, font families and sizes, spacing, rounded, and root, heading, link and button styles under the same names — and its Google Fonts are downloaded to assets/fonts and declared in theme.json. ' + - 'Use when the user wants to start a new custom theme — the agent fills in design-specific content afterwards. ' + - 'Pass parentTheme to scaffold a child theme of an installed theme instead — required when customizing a third-party theme, whose files must never be edited directly. ' + - 'Block themes only; does not support classic (PHP template) themes. ' + - 'Fails if the target theme directory already exists. ' + - 'When the site is not running or activation fails, the scaffold still succeeds and the result reports the manual activation command.', - { - nameOrPath: Type.String( { - description: 'The site name or filesystem path of the site to scaffold the theme into.', - } ), - name: Type.String( { - description: - 'Display name of the theme (e.g. "Acme Studio"). Used in style.css Theme Name header.', - } ), - slug: Type.Optional( - Type.String( { - description: - 'Optional theme slug (lowercase letters, digits, dashes). Used as the directory name and text domain. Derived from the name when omitted.', - } ) - ), - parentTheme: Type.Optional( - Type.String( { - description: - "Slug of an installed theme to use as the parent (e.g. 'ollie'). When set, scaffolds a CHILD theme instead of a blank theme — templates, parts, patterns, theme.json settings, and styles inherit from the parent. Use to customize an installed third-party theme instead of editing its files (a theme update would wipe direct edits). The parent must already exist under wp-content/themes/.", - } ) - ), - activate: Type.Optional( - Type.Boolean( { - description: - 'Whether to activate the theme after scaffolding via WP-CLI (`theme activate `). Defaults to true. Set to false to leave the theme inactive.', - } ) - ), - }, - async ( args ) => { - try { - const site = await resolveSite( args.nameOrPath ); - - const trimmedName = args.name.trim(); - if ( ! trimmedName ) { - throw new Error( 'Theme name must not be empty.' ); - } - - const slug = ( args.slug ?? deriveSlug( trimmedName ) ).trim(); - if ( ! slug || ! /^[a-z0-9][a-z0-9-]*$/.test( slug ) ) { - throw new Error( - 'Theme slug must contain only lowercase letters, digits and dashes, and start with a letter or digit.' - ); - } - - const themesDir = path.join( site.path, 'wp-content', 'themes' ); - if ( ! ( await pathExists( themesDir ) ) ) { - throw new Error( `wp-content/themes directory not found in site: ${ themesDir }` ); - } - - // An empty parentTheme means "no parent" — models tend to send "" for - // optional fields, and rejecting it as a bad slug nudges them into naming - // the active theme as parent when they wanted a blank scaffold. - const parentSlug = args.parentTheme?.trim() || undefined; - if ( parentSlug !== undefined ) { - if ( ! parentSlug || ! /^[a-z0-9][a-z0-9-]*$/.test( parentSlug ) ) { - throw new Error( - 'Parent theme slug must contain only lowercase letters, digits and dashes, and start with a letter or digit.' - ); - } - if ( parentSlug === slug ) { - throw new Error( - 'parentTheme must be different from the child theme slug. Pick a distinct slug for the child theme.' - ); - } - const parentStyleCssPath = path.join( themesDir, parentSlug, 'style.css' ); - if ( ! ( await pathExists( parentStyleCssPath ) ) ) { - throw new Error( - `Parent theme '${ parentSlug }' is not installed at wp-content/themes/${ parentSlug }/.` - ); - } - const grandparentMatch = ( await readFile( parentStyleCssPath, 'utf8' ) ).match( - /^[ \t/*#@]*Template:[ \t]*(\S+)/im - ); - if ( grandparentMatch ) { - throw new Error( - `'${ parentSlug }' is itself a child theme of '${ grandparentMatch[ 1 ] }' — WordPress does not support grandchild themes. Use parentTheme: '${ grandparentMatch[ 1 ] }' instead.` - ); - } - } - - const themeDir = path.join( themesDir, slug ); - if ( await pathExists( themeDir ) ) { - throw new Error( - `A theme already exists at wp-content/themes/${ slug }. Choose a different slug or remove the existing directory first.` - ); - } - - const baseJson = parentSlug !== undefined ? childThemeJson() : baseThemeJson(); - const designPath = path.join( site.path, 'DESIGN.md' ); - const tokens = ( await pathExists( designPath ) ) - ? parseDesignMd( await readFile( designPath, 'utf8' ) ) - : undefined; - let design = tokens && themeJsonFromDesign( tokens, baseJson ); - let fontsError: string | undefined; - if ( tokens && design?.fontsUrl ) { - try { - design = themeJsonFromDesign( - tokens, - baseJson, - await downloadThemeFonts( design.fontsUrl, themeDir ) - ); - } catch ( error ) { - fontsError = error instanceof Error ? error.message : String( error ); - } - } - const themeJson = design?.themeJson ?? baseJson; - - let files: Array< [ string, string ] >; - if ( parentSlug !== undefined ) { - // A block child theme inherits templates, parts, patterns, theme.json - // settings, and styles from the parent by path resolution — the child - // only needs the three root files until the agent adds overrides. - await mkdir( themeDir, { recursive: true } ); - files = [ - [ 'style.css', renderChildStyleCss( trimmedName, slug, parentSlug ) ], - [ 'theme.json', renderThemeJson( themeJson ) ], - [ 'functions.php', renderChildFunctionsPhp( trimmedName, slug, parentSlug ) ], - ]; - } else { - await mkdir( path.join( themeDir, 'templates' ), { recursive: true } ); - await mkdir( path.join( themeDir, 'parts' ), { recursive: true } ); - await mkdir( path.join( themeDir, 'assets', 'fonts' ), { recursive: true } ); - await mkdir( path.join( themeDir, 'patterns' ), { recursive: true } ); - - files = [ - [ 'style.css', renderStyleCss( trimmedName, slug ) ], - [ 'theme.json', renderThemeJson( themeJson ) ], - [ 'functions.php', renderFunctionsPhp( trimmedName, slug ) ], - [ path.join( 'templates', 'index.html' ), TEMPLATE_INDEX ], - [ path.join( 'templates', 'single.html' ), TEMPLATE_SINGLE ], - [ path.join( 'templates', 'page.html' ), TEMPLATE_PAGE ], - [ path.join( 'templates', 'page-no-title.html' ), TEMPLATE_PAGE_NO_TITLE ], - [ path.join( 'templates', 'archive.html' ), TEMPLATE_ARCHIVE ], - [ path.join( 'templates', '404.html' ), TEMPLATE_404 ], - [ path.join( 'parts', 'header.html' ), PART_HEADER ], - [ path.join( 'parts', 'footer.html' ), PART_FOOTER ], - ]; - } - - for ( const [ relPath, content ] of files ) { - await writeFile( path.join( themeDir, relPath ), content, 'utf8' ); - } - - const shouldActivate = args.activate ?? true; - const activation = shouldActivate ? await activateTheme( site, slug ) : null; - - const summaryLines = - parentSlug !== undefined - ? [ - `Child theme '${ trimmedName }' of '${ parentSlug }' scaffolded at wp-content/themes/${ slug }/.`, - `Templates, parts, patterns, theme.json settings, and styles inherit from '${ parentSlug }'.`, - 'Override by creating files at the same relative path inside the child theme; put CSS and theme.json changes in the child, never in the parent.', - ] - : [ `Block theme '${ trimmedName }' scaffolded at wp-content/themes/${ slug }/.` ]; - - if ( design ) { - summaryLines.push( - `theme.json carries the DESIGN.md tokens under the same names (${ design.summary })${ - design.fontsUrl && ! fontsError - ? ', with its fonts downloaded to assets/fonts and declared as fontFace' - : '' - }. Edit it only for what DESIGN.md does not cover.` - ); - } - if ( fontsError ) { - summaryLines.push( - `Downloading the fonts failed (${ fontsError }): theme.json declares the font families without font files.` - ); - } - - if ( ! activation ) { - summaryLines.push( `Activate with: wp theme activate ${ slug }` ); - } else if ( activation.ok ) { - summaryLines.push( `Activated: ${ activation.message }` ); - } else { - summaryLines.push( - `Activation skipped: ${ activation.message }`, - `Activate manually with: wp theme activate ${ slug }` - ); - } - - summaryLines.push( - '', - 'The files it wrote, in full: Edit or overwrite them without reading them first.', - ...files.map( - ( [ relPath, content ] ) => `\n\n${ content }` - ) - ); - - return textResult( summaryLines.join( '\n' ) ); - } catch ( error ) { - throw new Error( - `Failed to scaffold theme: ${ error instanceof Error ? error.message : String( error ) }` - ); - } - }, - { - annotations: LOCAL_CHANGE, - } -); diff --git a/packages/common/ai/tools.ts b/packages/common/ai/tools.ts index 5d0044b75d..ebb1b91245 100644 --- a/packages/common/ai/tools.ts +++ b/packages/common/ai/tools.ts @@ -310,7 +310,6 @@ export function getToolDisplayName( name: string, input?: Record< string, unknow preview_delete: __( 'Delete preview' ), refresh_browser: __( 'Refresh preview' ), site_connected_remote_sites: __( 'List connected remote sites' ), - scaffold_theme: __( 'Scaffold theme' ), pick_design: __( 'Pick design' ), present_design_options: __( 'Present design options' ), inspect_design: __( 'Inspect design' ), @@ -419,8 +418,6 @@ export function getToolDetail( name: string, input?: Record< string, unknown > ) return getAskUserDetail( input ); case 'wp_cli': return typeof input.command === 'string' ? `wp ${ input.command }` : ''; - case 'scaffold_theme': - return typeof input.name === 'string' ? input.name : ''; case 'present_design_options': return ( Array.isArray( input.options ) ? input.options : [] ) .map( ( option ) => diff --git a/packages/common/sites/site-design.ts b/packages/common/sites/site-design.ts index fb55dfd2f5..63cb2f31dd 100644 --- a/packages/common/sites/site-design.ts +++ b/packages/common/sites/site-design.ts @@ -8,7 +8,9 @@ import { googleFontsUrl, parseDesignMd, typographyStyles, + type DesignDrift, type DesignFix, + type DesignTokens, } from '@studio/design-md'; import { z } from 'zod'; import { downloadThemeFonts } from '../lib/theme-fonts'; @@ -107,25 +109,8 @@ export async function fixSiteDesign( const toTheme = fixed( 'theme' ); if ( toTheme.length ) { - const themeFamilies = ( - ( current.themeJson.settings as { typography?: { fontFamilies?: { name?: string }[] } } ) - ?.typography?.fontFamilies ?? [] - ).map( ( family ) => family.name ); - const families = toTheme - .filter( - ( entry ) => - entry.kind === 'font-family' || - ( entry.kind === 'style-font' && ! themeFamilies.includes( entry.design ) ) - ) - .map( ( entry ) => entry.design ); - const fontsUrl = googleFontsUrl( - typographyStyles( tokens ) - .map( ( [ , style ] ) => style ) - .filter( ( style ) => families.includes( fontFamilyName( style.fontFamily ) ) ) - ); - const fontFaces = fontsUrl ? await downloadThemeFonts( fontsUrl, files.themeDir ) : {}; - const themeJson = applyDesignToThemeJson( tokens, current.themeJson, toTheme, fontFaces ); - await writeFile( files.themeJsonPath, JSON.stringify( themeJson, null, '\t' ) + '\n' ); + const fontFaces = await downloadDriftFonts( files, tokens, current.themeJson, toTheme ); + await writeDriftToTheme( files, tokens, current.themeJson, toTheme, fontFaces ); } const toDesign = fixed( 'design' ); @@ -135,3 +120,77 @@ export async function fixSiteDesign( return readSiteDesignFiles( files ); } + +// The font files of the families each drift writes to theme.json, downloaded +// into the theme and keyed by family. +async function downloadDriftFonts( + files: SiteDesignFiles, + tokens: DesignTokens, + themeJson: Record< string, unknown >, + drift: DesignDrift[] +): Promise< Record< string, object[] > > { + const themeFamilies = ( + ( themeJson.settings as { typography?: { fontFamilies?: { name?: string }[] } } )?.typography + ?.fontFamilies ?? [] + ).map( ( family ) => family.name ); + const families = drift + .filter( + ( entry ) => + entry.kind === 'font-family' || + ( entry.kind === 'style-font' && ! themeFamilies.includes( entry.design ) ) + ) + .map( ( entry ) => entry.design ); + const fontsUrl = googleFontsUrl( + typographyStyles( tokens ) + .map( ( [ , style ] ) => style ) + .filter( ( style ) => families.includes( fontFamilyName( style.fontFamily ) ) ) + ); + return fontsUrl ? downloadThemeFonts( fontsUrl, files.themeDir ) : {}; +} + +async function writeDriftToTheme( + files: SiteDesignFiles, + tokens: DesignTokens, + themeJson: Record< string, unknown >, + drift: DesignDrift[], + fontFaces: Record< string, object[] > +): Promise< void > { + const written = applyDesignToThemeJson( tokens, themeJson, drift, fontFaces ); + await writeFile( files.themeJsonPath, JSON.stringify( written, null, '\t' ) + '\n' ); +} + +export interface SettledThemeDesign { + drift: DesignDrift[]; + fontsError?: string; +} + +/** + * Makes a theme built on the site's DESIGN.md carry its tokens: every color, + * font family (with its font files), font size and spacing step under DESIGN.md's + * name, and its body, heading and button fonts. Resolves the drifts it settled, + * none when the site has no DESIGN.md or the theme no theme.json. + */ +export async function settleThemeDesign( + sitePath: string, + themeSlug: string +): Promise< SettledThemeDesign > { + const files = await locateSiteDesign( sitePath, async () => themeSlug ); + const current = files && ( await readSiteDesignFiles( files ) ); + if ( ! files || ! current ) { + return { drift: [] }; + } + const tokens = parseDesignMd( current.design ); + const drift = designDrift( tokens, current.themeJson, true ); + if ( ! drift.length ) { + return { drift }; + } + let fontsError: string | undefined; + const fontFaces = await downloadDriftFonts( files, tokens, current.themeJson, drift ).catch( + ( error: unknown ) => { + fontsError = error instanceof Error ? error.message : String( error ); + return {}; + } + ); + await writeDriftToTheme( files, tokens, current.themeJson, drift, fontFaces ); + return { drift, fontsError }; +} diff --git a/packages/design-md/index.ts b/packages/design-md/index.ts index f555351492..f595132b44 100644 --- a/packages/design-md/index.ts +++ b/packages/design-md/index.ts @@ -435,16 +435,20 @@ function setIn( tree: Tree | undefined, [ key, ...rest ]: string[], value: unkno * or sets to a different value, and every body, heading or button style that uses * another font. A theme.json that uses fewer than half of DESIGN.md's palette slugs * wasn't generated from it (e.g. the default theme before the design is built), so - * it has no drift. + * it has no drift, unless `built` says the theme is built on DESIGN.md. */ -export function designDrift( tokens: DesignTokens, themeJson: ThemeJson ): DesignDrift[] { +export function designDrift( + tokens: DesignTokens, + themeJson: ThemeJson, + built = false +): DesignDrift[] { const expected = themeJsonFromDesign( tokens, {} )?.themeJson; const palette = presets( themeJson.settings, 'color', 'palette' ); const colors = presets( expected?.settings, 'color', 'palette' ); const shared = colors.filter( ( color ) => palette.some( ( candidate ) => candidate.slug === color.slug ) ); - if ( shared.length * 2 < colors.length ) { + if ( ! built && shared.length * 2 < colors.length ) { return []; } const normalize = ( kind: DesignDrift[ 'kind' ], value: unknown ) => diff --git a/plugins/wp-studio/.codex-plugin/plugin.json b/plugins/wp-studio/.codex-plugin/plugin.json index f5147b7b06..ca82bdcbb5 100644 --- a/plugins/wp-studio/.codex-plugin/plugin.json +++ b/plugins/wp-studio/.codex-plugin/plugin.json @@ -62,8 +62,8 @@ { "description": "Core workflow: build a new local WordPress site from a request. Setup: none; the plugin comes with the WordPress Studio CLI. When Codex shows look and layout options, pick any. Passes when the site runs and shows a bakery home page in the chosen style.", "prompt": "Build a one-page WordPress site for Crumb & Co, a neighborhood bakery.", - "tools_triggered": "studio_instructions, site_create, pick_design, present_design_options, scaffold_theme, wp_cli, validate_blocks, take_screenshot, inspect_design", - "expected_behavior": "Creates a local site named \"Crumb & Co\" with site_create, asks the user to choose a look and a layout with present_design_options, builds a block theme with scaffold_theme and a home page about the bakery, checks it with take_screenshot, and replies with the site URL (http://localhost:) and its WP Admin link. Opening the URL shows the bakery home page in the chosen style." + "tools_triggered": "studio_instructions, site_create, pick_design, present_design_options, wp_cli, take_screenshot, inspect_design", + "expected_behavior": "Creates a local site named \"Crumb & Co\" with site_create, asks the user to choose a look and a layout with present_design_options, builds a block theme and a home page about the bakery, checks it with take_screenshot, and replies with the site URL (http://localhost:) and its WP Admin link. Opening the URL shows the bakery home page in the chosen style." }, { "description": "Setup: run the cases in order in one Codex workspace; the first builds the Crumb & Co site the others use. Edit an existing local site: add a page with given content. Passes when the new page is published and shows the hours.", diff --git a/plugins/wp-studio/scripts/bootstrap-mcp.mjs b/plugins/wp-studio/scripts/bootstrap-mcp.mjs index 694d087cf4..b4d5540bc7 100644 --- a/plugins/wp-studio/scripts/bootstrap-mcp.mjs +++ b/plugins/wp-studio/scripts/bootstrap-mcp.mjs @@ -314,7 +314,7 @@ async function standIn() { // A hook starts the Studio CLI only for a call that may write a WordPress // install's files; the CLI decides. Without a CLI yet, the call goes ahead. -const MAY_TOUCH_WORDPRESS = /wp-content[\\/]|wp-includes[\\/]|wp-admin[\\/]|\.php\b/; +const MAY_TOUCH_WORDPRESS = /wp-content[\\/]|wp-includes[\\/]|wp-admin[\\/]|\.php\b|DESIGN\.md/; async function runHook() { let input = ''; for await ( const chunk of process.stdin ) { diff --git a/plugins/wp-studio/skills/build-site/SKILL.md b/plugins/wp-studio/skills/build-site/SKILL.md index 10b49e02fa..e481eab253 100644 --- a/plugins/wp-studio/skills/build-site/SKILL.md +++ b/plugins/wp-studio/skills/build-site/SKILL.md @@ -1,6 +1,6 @@ --- name: build-site -description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the design and plugins as a Site Spec, then scaffold the theme, provision the site, publish the content, and polish the result. Load it before picking or creating the site. +description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the design and plugins as a Site Spec, then build the theme, provision the site, publish the content, and polish the result. Load it before picking or creating the site. --- 1. If you have not called the `studio_instructions` tool in this conversation yet, call it first and follow what it returns for the rest of the session. diff --git a/scripts/eval/promptfoo.config.yaml b/scripts/eval/promptfoo.config.yaml index 4c95faca60..58e7ebdfef 100644 --- a/scripts/eval/promptfoo.config.yaml +++ b/scripts/eval/promptfoo.config.yaml @@ -659,7 +659,6 @@ tests: skills: [{ name: build-site }] stopAt: - tool: site_create - - tool: scaffold_theme - tool: Write - tool: Edit - tool: generate_images From 2a81dbff0147794e37b5675dfd0f125b7bb08f0b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 23 Oct 2026 21:59:42 -0400 Subject: [PATCH 035/199] Studio Code: Write DESIGN.md only into a theme built on it, with its font files The first smoke run of the theme step found two gaps. Writing DESIGN.md on a new site wrote its tokens into Twenty Twenty-Five: a site that has not checked for updates yet does not know the theme is updatable. A write to DESIGN.md now reaches the active theme only once it uses DESIGN.md's names, the rule the Design system tab already uses; a theme.json write still builds its theme on DESIGN.md. And a theme that declared DESIGN.md's font family itself, without font files, kept loading none: in a theme built on DESIGN.md, a family with no font files now drifts, so Studio downloads them. --- .../hooks/tests/file-tool-hook.test.ts | 106 ++++++++++-------- apps/cli/ai/site-file-guards.ts | 22 ++-- packages/common/sites/site-design.ts | 10 +- packages/design-md/index.ts | 8 +- 4 files changed, 86 insertions(+), 60 deletions(-) diff --git a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts index 39c114be59..c2f7c3c657 100644 --- a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts +++ b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts @@ -98,57 +98,71 @@ const DESIGN_MD = afterEach( () => vi.unstubAllGlobals() ); -// DESIGN.md is the look; the theme built on it carries its tokens whichever -// of the two the agent writes. A theme WordPress updates is left alone. +// DESIGN.md is the look. Writing a theme.json builds that theme on it, font +// files included; writing DESIGN.md reaches the active theme once it uses +// DESIGN.md's names. A theme WordPress updates is left alone. +const INTER = { slug: 'inter', name: 'Inter', fontFamily: 'Inter, sans-serif' }; +const NOTE = 'colors primary, background; font families inter; fonts of the styles body'; it.each( [ - { written: 'wp-content/themes/bakery/theme.json', theme: 'bakery' }, - { written: 'DESIGN.md', theme: 'bakery' }, - { written: 'wp-content/themes/bakery-parent/theme.json', theme: undefined }, -] )( 'writes DESIGN.md into the theme after a write to $written', async ( { written, theme } ) => { - vi.stubGlobal( - 'fetch', - vi.fn( async ( url: string ) => - url.startsWith( 'https://fonts.googleapis.com/' ) - ? new Response( - "/* latin */\n@font-face {\n font-family: 'Inter';\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/inter.woff2) format('woff2');\n}\n" - ) - : new Response( new Uint8Array( [ 1 ] ) ) - ) - ); - writeFileSync( path.join( site.root, 'DESIGN.md' ), DESIGN_MD ); - const ownPalette = { settings: { color: { palette: [ { slug: 'ink', color: '#111' } ] } } }; - for ( const slug of [ 'bakery', 'bakery-parent' ] ) { - writeFileSync( - path.join( site.root, 'wp-content/themes', slug, 'theme.json' ), - JSON.stringify( { version: 3, ...ownPalette } ) + { written: 'bakery/theme.json', palette: [ 'ink' ], families: [ INTER ], wrote: NOTE }, + { written: 'DESIGN.md', palette: [ 'primary' ], families: [], wrote: NOTE }, + { written: 'DESIGN.md', palette: [ 'ink' ], families: [], wrote: undefined }, + { written: 'bakery-parent/theme.json', palette: [ 'ink' ], families: [], wrote: undefined }, +] )( + 'writes DESIGN.md into the theme after a write to $written over $palette', + async ( { written, palette, families, wrote } ) => { + vi.stubGlobal( + 'fetch', + vi.fn( async ( url: string ) => + url.startsWith( 'https://fonts.googleapis.com/' ) + ? new Response( + "/* latin */\n@font-face {\n font-family: 'Inter';\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/inter.woff2) format('woff2');\n}\n" + ) + : new Response( new Uint8Array( [ 1 ] ) ) + ) ); - } + writeFileSync( path.join( site.root, 'DESIGN.md' ), DESIGN_MD ); + const themes = path.join( site.root, 'wp-content/themes' ); + const start = { + version: 3, + settings: { + color: { palette: palette.map( ( slug ) => ( { slug, color: '#111' } ) ) }, + typography: { fontFamilies: families }, + }, + }; + for ( const slug of [ 'bakery', 'bakery-parent' ] ) { + writeFileSync( path.join( themes, slug, 'theme.json' ), JSON.stringify( start ) ); + } - const output = await runFileToolHook( { - hook_event_name: 'PostToolUse', - tool_name: 'Write', - tool_input: { file_path: path.join( site.root, written ) }, - } ); + const output = await runFileToolHook( { + hook_event_name: 'PostToolUse', + tool_name: 'Write', + tool_input: { + file_path: path.join( written === 'DESIGN.md' ? site.root : themes, written ), + }, + } ); - for ( const slug of [ 'bakery', 'bakery-parent' ] ) { - const themeDir = path.join( site.root, 'wp-content/themes', slug ); - const themeJson = JSON.parse( readFileSync( path.join( themeDir, 'theme.json' ), 'utf8' ) ); - const palette = themeJson.settings.color.palette.map( - ( color: { slug: string } ) => color.slug - ); - if ( slug === theme ) { - expect( palette ).toEqual( [ 'ink', 'primary', 'background' ] ); + const theme = wrote && ( written === 'DESIGN.md' ? 'bakery' : written.split( '/' )[ 0 ] ); + for ( const slug of [ 'bakery', 'bakery-parent' ] ) { + const themeJson = JSON.parse( + readFileSync( path.join( themes, slug, 'theme.json' ), 'utf8' ) + ); + if ( slug !== theme ) { + expect( themeJson ).toEqual( start ); + continue; + } + expect( + themeJson.settings.color.palette.map( ( color: { slug: string } ) => color.slug ) + ).toEqual( [ ...new Set( [ ...palette, 'primary', 'background' ] ) ] ); expect( themeJson.styles.typography.fontFamily ).toBe( 'var:preset|font-family|inter' ); expect( themeJson.settings.typography.fontFamilies[ 0 ].fontFace ).toHaveLength( 1 ); - expect( existsSync( path.join( themeDir, 'assets/fonts/inter' ) ) ).toBe( true ); - } else { - expect( palette ).toEqual( [ 'ink' ] ); + expect( existsSync( path.join( themes, slug, 'assets/fonts/inter' ) ) ).toBe( true ); } + expect( output?.hookSpecificOutput ).toEqual( + wrote && { + hookEventName: 'PostToolUse', + additionalContext: `Note: Studio wrote DESIGN.md's tokens into wp-content/themes/${ theme }/theme.json under DESIGN.md's names: ${ wrote }. DESIGN.md holds these values; change the look there.`, + } + ); } - expect( output?.hookSpecificOutput ).toEqual( - theme && { - hookEventName: 'PostToolUse', - additionalContext: `Note: Studio wrote DESIGN.md's tokens into wp-content/themes/${ theme }/theme.json under DESIGN.md's names: colors primary, background; font families inter; fonts of the styles body. DESIGN.md holds these values; change the look there.`, - } - ); -} ); +); diff --git a/apps/cli/ai/site-file-guards.ts b/apps/cli/ai/site-file-guards.ts index 21b3329444..f75202fc66 100644 --- a/apps/cli/ai/site-file-guards.ts +++ b/apps/cli/ai/site-file-guards.ts @@ -177,18 +177,23 @@ async function getSiteFileWriteNotes( siteFile: SiteFile ): Promise< string[] > } // The theme a write to DESIGN.md or a theme.json leaves out of step with the -// site's design: the active theme for DESIGN.md, the theme a theme.json is in. -// Themes WordPress updates are left as they are. -async function themeOfDesignWrite( siteFile: SiteFile ): Promise< string | undefined > { +// site's design. Writing a theme.json builds that theme on DESIGN.md; writing +// DESIGN.md reaches the active theme only once it uses DESIGN.md's names, so a +// default theme the design was never built into stays as it is. Themes +// WordPress updates are left as they are. +async function themeOfDesignWrite( + siteFile: SiteFile +): Promise< { slug: string; built: boolean } | undefined > { const { siteRoot, relativePath, theme } = siteFile; + const built = theme?.file === 'theme.json'; const slug = relativePath === 'DESIGN.md' ? await readWordPressSite( siteRoot, ( site ) => site.option( 'stylesheet' ) ) - : theme?.file === 'theme.json' + : built ? theme.slug : undefined; return typeof slug === 'string' && ! ( await isUpdatableTheme( siteRoot, slug ) ) - ? slug + ? { slug, built } : undefined; } @@ -197,11 +202,12 @@ async function themeOfDesignWrite( siteFile: SiteFile ): Promise< string | undef * write to either, and says what it wrote. */ async function settleDesign( siteFile: SiteFile ): Promise< string[] > { - const slug = await themeOfDesignWrite( siteFile ); - if ( ! slug ) { + const target = await themeOfDesignWrite( siteFile ); + if ( ! target ) { return []; } - const { drift, fontsError } = await settleThemeDesign( siteFile.siteRoot, slug ); + const { slug, built } = target; + const { drift, fontsError } = await settleThemeDesign( siteFile.siteRoot, slug, built ); if ( ! drift.length ) { return []; } diff --git a/packages/common/sites/site-design.ts b/packages/common/sites/site-design.ts index 63cb2f31dd..cda29493b9 100644 --- a/packages/common/sites/site-design.ts +++ b/packages/common/sites/site-design.ts @@ -167,12 +167,14 @@ export interface SettledThemeDesign { /** * Makes a theme built on the site's DESIGN.md carry its tokens: every color, * font family (with its font files), font size and spacing step under DESIGN.md's - * name, and its body, heading and button fonts. Resolves the drifts it settled, - * none when the site has no DESIGN.md or the theme no theme.json. + * name, and its body, heading and button fonts. Unless `built`, only a theme that + * already uses DESIGN.md's names counts as built on it. Resolves the drifts it + * settled, none when the site has no DESIGN.md or the theme no theme.json. */ export async function settleThemeDesign( sitePath: string, - themeSlug: string + themeSlug: string, + built: boolean ): Promise< SettledThemeDesign > { const files = await locateSiteDesign( sitePath, async () => themeSlug ); const current = files && ( await readSiteDesignFiles( files ) ); @@ -180,7 +182,7 @@ export async function settleThemeDesign( return { drift: [] }; } const tokens = parseDesignMd( current.design ); - const drift = designDrift( tokens, current.themeJson, true ); + const drift = designDrift( tokens, current.themeJson, built ); if ( ! drift.length ) { return { drift }; } diff --git a/packages/design-md/index.ts b/packages/design-md/index.ts index f595132b44..9b8c637769 100644 --- a/packages/design-md/index.ts +++ b/packages/design-md/index.ts @@ -435,7 +435,8 @@ function setIn( tree: Tree | undefined, [ key, ...rest ]: string[], value: unkno * or sets to a different value, and every body, heading or button style that uses * another font. A theme.json that uses fewer than half of DESIGN.md's palette slugs * wasn't generated from it (e.g. the default theme before the design is built), so - * it has no drift, unless `built` says the theme is built on DESIGN.md. + * it has no drift, unless `built` says the theme is built on DESIGN.md; a theme + * built on it also drifts on each font family that loads no font files. */ export function designDrift( tokens: DesignTokens, @@ -468,7 +469,10 @@ export function designDrift( : kind === 'font-family' ? fontFamilyName( match[ field ] ) : String( match[ field ] ); - return theme !== undefined && normalize( kind, design ) === normalize( kind, theme ) + const filesMissing = built && kind === 'font-family' && ! match?.fontFace; + return theme !== undefined && + normalize( kind, design ) === normalize( kind, theme ) && + ! filesMissing ? [] : [ { kind, slug: String( preset.slug ), design, theme } ]; } ); From 21d8ac14f777c7c94be31d2db31da937feda60c1 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 23 Oct 2026 22:03:53 -0400 Subject: [PATCH 036/199] Studio Code: Run real wp in the agent's shell `wp` in Studio Code's Bash is Studio's WP-CLI for the site the command runs in (the PATH shim from #5166), so the rule that refused `wp` in the shell, added because a bare `wp` reached the system binary, has nothing left to guard. The wp_cli tool leaves Studio Code; `studio mcp` keeps it for clients without a shell. The checks that only wp_cli had leave with it, as the shell already runs without them: the irreversible WP-CLI and other-site confirmations (Studio's own tools keep theirs), and the refusal of post content from a file with open validation problems. Block markup is still validated as it is written, and a shell call waits for that validation and reports it, as wp_cli did. The preview reloads after a successful shell call. --- apps/cli/ai/block-validation-gate.ts | 81 +---------------- apps/cli/ai/runtimes/pi/confirmation.ts | 23 ----- apps/cli/ai/runtimes/pi/index.ts | 51 +++++------ apps/cli/ai/runtimes/pi/preview-reload.ts | 44 ++------- apps/cli/ai/runtimes/pi/site-pin.ts | 5 -- apps/cli/ai/runtimes/pi/studio-wp-shim.ts | 51 +++++++++++ .../ai/runtimes/pi/tests/confirmation.test.ts | 25 ------ .../runtimes/pi/tests/preview-reload.test.ts | 42 +++------ .../cli/ai/runtimes/pi/tests/site-pin.test.ts | 18 +--- .../ai/runtimes/pi/tests/tool-safety.test.ts | 33 +------ apps/cli/ai/runtimes/pi/tool-safety.ts | 14 --- apps/cli/ai/skills/annotate/SKILL.md | 4 +- apps/cli/ai/skills/build-site/SKILL.md | 2 +- apps/cli/ai/skills/imagery/SKILL.md | 2 +- apps/cli/ai/skills/need-for-speed/SKILL.md | 2 +- .../ai/skills/plugin-recommendations/SKILL.md | 2 +- apps/cli/ai/skills/rank-me-up/SKILL.md | 14 +-- apps/cli/ai/skills/taxonomist/SKILL.md | 75 +++++++--------- .../ai/tests/block-validation-gate.test.ts | 46 ---------- apps/cli/ai/tests/pi-runtime.test.ts | 15 ++-- apps/cli/ai/tests/studio-wp-shim.test.ts | 90 +++++++++++++++++++ apps/cli/ai/tools/index.ts | 6 ++ apps/cli/ai/tools/install-taxonomy-scripts.ts | 2 +- apps/cli/ai/ui.ts | 1 - apps/cli/commands/wp.ts | 10 ++- apps/cli/lib/cli-config/sites.ts | 15 ++++ apps/cli/lib/cli-config/tests/sites.test.ts | 41 +++++++++ apps/cli/lib/tests/wp-cli-options.test.ts | 21 +++++ apps/cli/lib/wp-cli-options.ts | 9 ++ .../skills/wordpress-studio/SKILL.md | 2 +- 30 files changed, 343 insertions(+), 403 deletions(-) create mode 100644 apps/cli/ai/runtimes/pi/studio-wp-shim.ts create mode 100644 apps/cli/ai/tests/studio-wp-shim.test.ts create mode 100644 apps/cli/lib/cli-config/tests/sites.test.ts create mode 100644 apps/cli/lib/tests/wp-cli-options.test.ts create mode 100644 apps/cli/lib/wp-cli-options.ts diff --git a/apps/cli/ai/block-validation-gate.ts b/apps/cli/ai/block-validation-gate.ts index 00e563bb86..769ccab6c2 100644 --- a/apps/cli/ai/block-validation-gate.ts +++ b/apps/cli/ai/block-validation-gate.ts @@ -4,17 +4,16 @@ * * Writing block markup (a theme template or part, or a page scratch file in the * site's tmp/) queues that file. Validation starts in the background at once, - * and the tools that put content on the site or render it (wp_cli, - * take_screenshot, inspect_design) wait for it first. A fix the block editor + * and what puts content on the site or renders it (a shell command, which + * runs WP-CLI, take_screenshot, inspect_design) waits for it first. A fix the block editor * can make losslessly is applied to the file silently; the model hears only * about what it has to change itself. */ import { createHash } from 'crypto'; -import { readFile, stat, writeFile } from 'fs/promises'; +import { readFile, writeFile } from 'fs/promises'; import path from 'path'; import { validateHtmlBlockPolicy } from 'cli/ai/block-content-policy'; import { validateBlocks, type ValidationReportBase } from 'cli/ai/block-validator'; -import { splitCommandArgs } from 'cli/ai/wp-cli-args'; import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config/sites'; const SHARED_THEME_MARKUP = @@ -22,7 +21,6 @@ const SHARED_THEME_MARKUP = const THEME_MARKUP = /[\\/]wp-content[\\/]themes[\\/][^\\/]+[\\/](templates|parts)[\\/][^\\/]+\.html$/; const SCRATCH_MARKUP = /[\\/]tmp[\\/][^\\/]+\.html$/; -const POST_CONTENT = '--post_content='; /** Whether a file holds block markup the gate validates. */ export function isBlockMarkupFile( filePath: string ): boolean { @@ -88,28 +86,6 @@ function attributePaths( html: string ): Set< string > { return paths; } -function isJsonContent( content: string ): boolean { - return /^\s*{/.test( content ); -} - -/** - * Post content that opens as JSON — global styles, navigation data — must - * parse: WordPress drops content it cannot decode without saying so. - */ -function jsonContentProblem( content: string, source: string ): string | undefined { - if ( ! isJsonContent( content ) ) { - return undefined; - } - try { - JSON.parse( content ); - return undefined; - } catch ( error ) { - return `${ source } is not valid JSON, and WordPress ignores content it cannot decode: ${ - error instanceof Error ? error.message : String( error ) - }`; - } -} - function oneLine( html: string, length: number ): string { const flat = html.replace( /\s+/g, ' ' ).trim(); return flat.length > length ? `${ flat.slice( 0, length ) }…` : flat; @@ -282,59 +258,13 @@ export class BlockValidationGate { return { summaries, problems }; } - /** - * Revalidates every file: activating a theme or plugin changes which blocks - * the editor recognizes, so earlier validations no longer hold. - */ - invalidate(): void { - for ( const [ filePath, tracked ] of this.files ) { - tracked.result = this.validate( filePath, tracked.siteRoot ); - tracked.reported = undefined; - } - } - - /** - * The problems that keep a WP-CLI command's post content off the site: - * invalid blocks in every file `post create` or `post update` reads, and - * post content that opens as JSON but does not parse. - */ - async checkCommand( command: string, siteRoot: string ): Promise< string[] > { - const args = splitCommandArgs( command ); - if ( args[ 0 ] !== 'post' || ! [ 'create', 'update' ].includes( args[ 1 ] ) ) { - return []; - } - const problems: string[] = []; - for ( const arg of args.slice( 2 ).filter( ( value ) => ! value.startsWith( '-' ) ) ) { - const filePath = path.resolve( siteRoot, arg ); - if ( ! ( await stat( filePath ).catch( () => undefined ) )?.isFile() ) { - continue; - } - const content = await readFile( filePath, 'utf8' ); - const problem = isJsonContent( content ) - ? jsonContentProblem( content, arg ) - : ( await this.current( filePath, siteRoot, true ) )?.problem; - problems.push( ...( problem ? [ problem ] : [] ) ); - } - const inline = args - .find( ( value ) => value.startsWith( POST_CONTENT ) ) - ?.slice( POST_CONTENT.length ); - if ( inline !== undefined ) { - const problem = inline.includes( '

Hi

'"; - const [ problem ] = await gate.checkCommand( command, site ); - expect( problem ).toContain( 'post create tmp/.html' ); - expect( await gate.checkCommand( "post update 5 --post_content='Plain text'", site ) ).toEqual( - [] - ); } ); it( 'reports a file it could not validate', async () => { diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index 76e97924e5..cad3bb576b 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -272,7 +272,8 @@ describe( 'pi runtime', () => { it( 'activates the tools a skill brings once the skill is loaded', async () => { await runRuntime( { prompt: 'hello', session: newSession(), env: WPCOM_ENV } ); const session = mocks.createdSessions[ 0 ] as unknown as FakeSession; - expect( session.activeToolNames ).toContain( 'wp_cli' ); + expect( session.activeToolNames ).toContain( 'Bash' ); + expect( session.activeToolNames ).not.toContain( 'wp_cli' ); expect( session.activeToolNames ).not.toContain( 'install_taxonomy_scripts' ); await getCreatedTool( 'Skill' ).execute( 't', { name: 'taxonomist' } ); expect( session.activeToolNames ).toContain( 'install_taxonomy_scripts' ); @@ -508,11 +509,10 @@ describe( 'pi runtime', () => { [ { type: 'toolCall', - id: 'wp-cli-call-1', - name: 'wp_cli', + id: 'bash-call-1', + name: 'Bash', arguments: { - nameOrPath: 'bakery', - command: 'post update 4 --post_content="

partial', + command: 'wp post update 4 --post_content="

partial', }, index: 0, }, @@ -530,9 +530,8 @@ describe( 'pi runtime', () => { } ); await expect( - getCreatedTool( 'wp_cli' ).execute( 'wp-cli-call-1', { - nameOrPath: 'bakery', - command: 'post update 4 --post_content="

partial', + getCreatedTool( 'Bash' ).execute( 'bash-call-1', { + command: 'wp post update 4 --post_content="

partial', } ) ).rejects.toThrow( /hit the model output limit/ ); } ); diff --git a/apps/cli/ai/tests/studio-wp-shim.test.ts b/apps/cli/ai/tests/studio-wp-shim.test.ts new file mode 100644 index 0000000000..2a010efb93 --- /dev/null +++ b/apps/cli/ai/tests/studio-wp-shim.test.ts @@ -0,0 +1,90 @@ +import { execFileSync, spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + ensureStudioWpShimDirectory, + getStudioWpShimScript, + prependToPath, +} from 'cli/ai/runtimes/pi/studio-wp-shim'; + +describe.skipIf( process.platform === 'win32' )( 'studio wp shim', () => { + let root: string; + + beforeEach( () => { + root = fs.mkdtempSync( path.join( os.tmpdir(), "studio-wp-shim-it's-" ) ); + } ); + + afterEach( () => { + fs.rmSync( root, { recursive: true, force: true } ); + } ); + + it( 'makes `wp` in the Bash tool run `studio wp` with the same arguments', () => { + // Stands in for the CLI entry: prints the arguments it receives. + const cliPath = path.join( root, 'main.mjs' ); + fs.writeFileSync( cliPath, 'console.log( JSON.stringify( process.argv.slice( 2 ) ) );' ); + const directory = ensureStudioWpShimDirectory( + getStudioWpShimScript( process.execPath, cliPath ), + path.join( root, 'agent-bin' ) + ); + const { env } = prependToPath( directory )( { + command: '', + cwd: root, + env: { PATH: '/usr/bin:/bin' }, + } ); + + const output = execFileSync( + '/bin/sh', + [ '-c', `wp post create --post_title="Ember & Oak" page.html` ], + { env, encoding: 'utf8' } + ); + + expect( JSON.parse( output ) ).toEqual( [ + 'wp', + 'post', + 'create', + '--post_title=Ember & Oak', + 'page.html', + ] ); + } ); + + it( 'passes stdin, inline environment variables and exit code through', () => { + // Stands in for the CLI entry: echoes stdin and an environment variable, then exits 3. + const cliPath = path.join( root, 'main.mjs' ); + fs.writeFileSync( + cliPath, + [ + "import fs from 'fs';", + 'process.stdout.write( `${ process.env.TAXONOMIST_MODE }:${ fs.readFileSync( 0, "utf8" ) }` );', + 'process.exit( 3 );', + ].join( '\n' ) + ); + const directory = ensureStudioWpShimDirectory( + getStudioWpShimScript( process.execPath, cliPath ), + path.join( root, 'agent-bin' ) + ); + const { env } = prependToPath( directory )( { + command: '', + cwd: root, + env: { PATH: '/usr/bin:/bin' }, + } ); + + const result = spawnSync( + '/bin/sh', + [ '-c', 'TAXONOMIST_MODE=apply wp eval-file taxonomist.php' ], + { env, input: 'post content', encoding: 'utf8' } + ); + + expect( result.stdout ).toBe( 'apply:post content' ); + expect( result.status ).toBe( 3 ); + } ); + + it( 'reuses the shim directory for the same script', () => { + const script = getStudioWpShimScript( '/node', '/cli/main.mjs' ); + const agentBin = path.join( root, 'agent-bin' ); + expect( ensureStudioWpShimDirectory( script, agentBin ) ).toBe( + ensureStudioWpShimDirectory( script, agentBin ) + ); + } ); +} ); diff --git a/apps/cli/ai/tools/index.ts b/apps/cli/ai/tools/index.ts index 7983b9dd52..cc04262c84 100644 --- a/apps/cli/ai/tools/index.ts +++ b/apps/cli/ai/tools/index.ts @@ -75,6 +75,9 @@ export interface CreateStudioToolsOptions { // How a local image is linked in the agent's reply so the host shows it to the user. // Set by hosts without Studio's chat UI, which shows images as chat artifacts instead. imageLink?: ( file: string ) => string; + // The agent has a shell whose `wp` runs Studio's WP-CLI (Studio Code's Bash + // tool), so wp_cli would be a second way to do the same thing. + shellRunsStudioWp?: boolean; } export function resolveStudioToolDefinitions( @@ -84,6 +87,9 @@ export function resolveStudioToolDefinitions( if ( candidate.name === generateImagesTool.name && ! options.imageGeneration ) { return []; } + if ( candidate.name === runWpCliTool.name && options.shellRunsStudioWp ) { + return []; + } let tool = candidate; const userSeesCaptures = options.emitChatArtifacts === true || Boolean( options.imageLink ); if ( diff --git a/apps/cli/ai/tools/install-taxonomy-scripts.ts b/apps/cli/ai/tools/install-taxonomy-scripts.ts index 79f25fa0ff..b7419dce73 100644 --- a/apps/cli/ai/tools/install-taxonomy-scripts.ts +++ b/apps/cli/ai/tools/install-taxonomy-scripts.ts @@ -9,7 +9,7 @@ const TAXONOMIST_SCRIPTS_DIR = 'tmp/taxonomist'; export const installTaxonomyScriptsTool = defineTool( 'install_taxonomy_scripts', - 'Copies the Taxonomist PHP scripts into a site so they can be run via wp_cli eval-file. ' + + 'Copies the Taxonomist PHP scripts into a site so they can be run with `wp eval-file`. ' + 'Call this once before running any Taxonomist eval-file commands.', { nameOrPath: Type.String( { description: 'The site name or file system path to the site' } ), diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index 910903f193..a4ffef70a9 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -903,7 +903,6 @@ export class AiChatUI implements AiOutputAdapter { } break; } - case 'wp_cli': case 'preview_create': case 'preview_list': case 'preview_update': { diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index ea29b62ba7..f16f44d493 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -1,9 +1,10 @@ import { __ } from '@wordpress/i18n'; import { ArgumentsCamelCase } from 'yargs'; import yargsParser from 'yargs-parser'; -import { getSiteByFolder } from 'cli/lib/cli-config/sites'; +import { getSiteContainingFolder } from 'cli/lib/cli-config/sites'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; import { validatePhpVersion } from 'cli/lib/utils'; +import { getUnsupportedWpCliOptionMessage } from 'cli/lib/wp-cli-options'; import { Logger, LoggerError } from 'cli/logger'; import { GlobalOptions } from 'cli/types'; @@ -14,7 +15,7 @@ export async function runCommand( args: string[], options: { phpVersion?: string } = {} ): Promise< void > { - const site = await getSiteByFolder( siteFolder ); + const site = await getSiteContainingFolder( siteFolder ); const phpVersion = validatePhpVersion( options.phpVersion ?? site.phpVersion ); // Connect the PHP child directly to the terminal for piped/interactive stdin, live @@ -57,6 +58,11 @@ export async function commandHandler( argv: ArgumentsCamelCase< GlobalOptions > ); } + const unsupportedOptionMessage = getUnsupportedWpCliOptionMessage( wpCliArgv ); + if ( unsupportedOptionMessage ) { + throw new LoggerError( unsupportedOptionMessage ); + } + const phpVersion = parsedWpCliArgs[ 'php-version' ] !== undefined ? String( parsedWpCliArgs[ 'php-version' ] ) diff --git a/apps/cli/lib/cli-config/sites.ts b/apps/cli/lib/cli-config/sites.ts index 05e5bac6b1..eed615540f 100644 --- a/apps/cli/lib/cli-config/sites.ts +++ b/apps/cli/lib/cli-config/sites.ts @@ -1,3 +1,4 @@ +import path from 'path'; import { arePathsEqual, isWordPressDirectory } from '@studio/common/lib/fs-utils'; import { __ } from '@wordpress/i18n'; import { LoggerError } from 'cli/logger'; @@ -26,6 +27,20 @@ export async function getSiteByFolder( siteFolder: string ): Promise< SiteData > return site; } +// Like WP-CLI, finds the site from any directory inside it, not only its root. +export async function getSiteContainingFolder( folder: string ): Promise< SiteData > { + const config = await readCliConfig(); + for ( let directory = path.resolve( folder ); ; directory = path.dirname( directory ) ) { + const site = config.sites.find( ( candidate ) => arePathsEqual( candidate.path, directory ) ); + if ( site ) { + return site; + } + if ( path.dirname( directory ) === directory ) { + return getSiteByFolder( folder ); + } + } +} + export async function findSiteByFolder( siteFolder: string ): Promise< SiteData | undefined > { const config = await readCliConfig(); return config.sites.find( ( site ) => arePathsEqual( site.path, siteFolder ) ); diff --git a/apps/cli/lib/cli-config/tests/sites.test.ts b/apps/cli/lib/cli-config/tests/sites.test.ts new file mode 100644 index 0000000000..2164023ead --- /dev/null +++ b/apps/cli/lib/cli-config/tests/sites.test.ts @@ -0,0 +1,41 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readCliConfig } from 'cli/lib/cli-config/core'; +import { getSiteContainingFolder } from 'cli/lib/cli-config/sites'; + +vi.mock( 'cli/lib/cli-config/core', () => ( { + readCliConfig: vi.fn(), +} ) ); + +describe( 'getSiteContainingFolder', () => { + let root: string; + let sitePath: string; + + beforeEach( () => { + root = fs.realpathSync( fs.mkdtempSync( path.join( os.tmpdir(), 'studio-sites-' ) ) ); + sitePath = path.join( root, 'my-site' ); + fs.mkdirSync( path.join( sitePath, 'wp-content', 'themes', 'demo' ), { recursive: true } ); + vi.mocked( readCliConfig ).mockResolvedValue( { + sites: [ { id: '1', name: 'My Site', path: sitePath } ], + } as unknown as Awaited< ReturnType< typeof readCliConfig > > ); + } ); + + afterEach( () => { + fs.rmSync( root, { recursive: true, force: true } ); + } ); + + it( 'finds the site from its root', async () => { + expect( ( await getSiteContainingFolder( sitePath ) ).path ).toBe( sitePath ); + } ); + + it( 'finds the site from a directory inside it', async () => { + const theme = path.join( sitePath, 'wp-content', 'themes', 'demo' ); + expect( ( await getSiteContainingFolder( theme ) ).path ).toBe( sitePath ); + } ); + + it( 'reports a directory outside every site', async () => { + await expect( getSiteContainingFolder( root ) ).rejects.toThrow( 'not added to Studio' ); + } ); +} ); diff --git a/apps/cli/lib/tests/wp-cli-options.test.ts b/apps/cli/lib/tests/wp-cli-options.test.ts new file mode 100644 index 0000000000..8c804da8a6 --- /dev/null +++ b/apps/cli/lib/tests/wp-cli-options.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { getUnsupportedWpCliOptionMessage } from 'cli/lib/wp-cli-options'; + +describe( 'getUnsupportedWpCliOptionMessage', () => { + it( 'names an option written with a typographic dash', () => { + expect( getUnsupportedWpCliOptionMessage( [ 'media', 'import', 'a.png', '–porcelain' ] ) ).toBe( + 'Unsupported WP-CLI option "–porcelain": use ASCII hyphens, for example "--porcelain", not a typographic dash.' + ); + } ); + + it( 'accepts ASCII options and dashes inside values', () => { + expect( + getUnsupportedWpCliOptionMessage( [ + 'post', + 'create', + '--post_title=Ember – Oak', + '--porcelain', + ] ) + ).toBeNull(); + } ); +} ); diff --git a/apps/cli/lib/wp-cli-options.ts b/apps/cli/lib/wp-cli-options.ts new file mode 100644 index 0000000000..e4f3434866 --- /dev/null +++ b/apps/cli/lib/wp-cli-options.ts @@ -0,0 +1,9 @@ +// LLMs sometimes emit en/em dashes (`‐porcelain`, `–color`); WP-CLI silently +// ignores them, so reject up-front and let the caller retry with ASCII hyphens. +export function getUnsupportedWpCliOptionMessage( args: string[] ): string | null { + const unsupportedOption = args.find( ( arg ) => /^[‐-―]\S+/.test( arg ) ); + if ( ! unsupportedOption ) { + return null; + } + return `Unsupported WP-CLI option "${ unsupportedOption }": use ASCII hyphens, for example "--porcelain", not a typographic dash.`; +} diff --git a/plugins/wp-studio/skills/wordpress-studio/SKILL.md b/plugins/wp-studio/skills/wordpress-studio/SKILL.md index 0abdb250cf..a6741b5799 100644 --- a/plugins/wp-studio/skills/wordpress-studio/SKILL.md +++ b/plugins/wp-studio/skills/wordpress-studio/SKILL.md @@ -8,6 +8,6 @@ description: Build, design, redesign, and manage websites with WordPress Studio WordPress Studio runs WordPress sites locally and gives you its site-building tools through the `wordpress-studio` MCP server. 1. Call the `studio_instructions` tool before your first site task and follow what it returns for the rest of the session: it is the Studio workflow, including when to load each Studio runbook with its `skill` argument. -2. Manage sites only with the Studio tools (`site_create`, `site_start`, `wp_cli`, …); never run `wp` or start servers from the shell. Edit the site's theme and plugin files with your own file tools. +2. Manage sites only with the Studio tools (`site_create`, `site_start`, …); never start servers from the shell. Run a runbook's `wp` commands with the `wp_cli` tool, or as `studio wp` in the site's folder: both run on the site's own PHP and database, which a plain `wp` does not. Edit the site's theme and plugin files with your own file tools. If the `wordpress-studio` tools are missing, WordPress Studio did not start: ask the user to restart the app, and to reinstall the plugin if the tools are still missing. From 658d2a8ed2629a6a3bc0ceaaf191d5b8fdda4433 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 23 Oct 2026 22:20:42 -0400 Subject: [PATCH 037/199] Studio Code: Keep the look in theme.json, where WordPress reads it theme.json is the theme's design system: the Site Editor, Global Styles and style variations read it. The agent now writes the look there and nowhere else. A look option is the theme.json it would become, rendered as the design board, and the picked one is saved as a file to copy into the theme. After a theme.json write, Studio downloads from Google Fonts the files of any font family it declares without them, in the weights its styles use, and declares them as its fontFace; a family Google Fonts lacks is reported. This replaces copying DESIGN.md into the theme, and the Active site no longer names a DESIGN.md. The app's Design system tab and its annotations are unchanged. --- apps/cli/ai/design-board.ts | 105 ++++++++++++++++- .../hooks/tests/file-tool-hook.test.ts | 111 ++++++++++-------- apps/cli/ai/site-file-guards.ts | 76 ++++-------- apps/cli/ai/site-selection.ts | 8 +- apps/cli/ai/skills/build-site/SKILL.md | 17 ++- apps/cli/ai/skills/edit-manage-site/SKILL.md | 4 +- apps/cli/ai/skills/imagery/SKILL.md | 2 +- apps/cli/ai/skills/visual-design/SKILL.md | 18 +-- .../ai/tests/present-design-options.test.ts | 54 +++++++++ apps/cli/ai/tests/system-prompt.test.ts | 21 ---- apps/cli/ai/tools/present-design-options.ts | 40 ++++++- packages/common/lib/theme-fonts.ts | 74 +++++++++++- packages/common/sites/site-design.ts | 99 +++------------- packages/design-md/index.ts | 16 +-- plugins/wp-studio/scripts/bootstrap-mcp.mjs | 2 +- scripts/eval/README.md | 2 +- scripts/eval/outcome.mjs | 18 +-- scripts/eval/outcome.test.mjs | 11 +- scripts/eval/promptfoo.config.yaml | 9 +- 19 files changed, 401 insertions(+), 286 deletions(-) diff --git a/apps/cli/ai/design-board.ts b/apps/cli/ai/design-board.ts index f473350d74..ff6fd0d302 100644 --- a/apps/cli/ai/design-board.ts +++ b/apps/cli/ai/design-board.ts @@ -2,8 +2,8 @@ import { designSheet, fontFamilyName as fontFamily, googleFontsUrl, - parseDesignMd, - Style, + type DesignTokens, + type Style, } from '@studio/design-md'; const escapeHtml = ( value: unknown ) => @@ -30,8 +30,105 @@ function fontLinks( styles: Style[] ): string { return url ? `` : ''; } -export function renderDesignBoard( design: string, image?: string ): string { - const tokens = parseDesignMd( design ); +type Tree = Record< string, unknown >; +type Preset = Record< string, string >; + +const at = ( tree: unknown, ...keys: string[] ): unknown => + keys.reduce< unknown >( ( node, key ) => ( node as Tree | undefined )?.[ key ], tree ); + +const typography = ( tree: unknown, ...keys: string[] ) => + ( at( tree, ...keys, 'typography' ) ?? {} ) as Tree; + +// A theme.json padding object as CSS shorthand. +const padding = ( value: unknown ) => + value && typeof value === 'object' + ? [ 'top', 'right', 'bottom', 'left' ] + .map( ( side ) => ( value as Tree )[ side ] ?? 0 ) + .join( ' ' ) + : value; + +/** + * Reads a theme.json as the design tokens the board shows: its palette led by + * the background, text and button colors, the type of its headings, body and + * buttons, and its button. + */ +function themeJsonTokens( themeJson: Tree ): DesignTokens { + const presets = ( group: string, list: string ) => + ( at( themeJson, 'settings', group, list ) ?? [] ) as Preset[]; + const lists: Record< string, [ Preset[], string ] > = { + color: [ presets( 'color', 'palette' ), 'color' ], + 'font-family': [ presets( 'typography', 'fontFamilies' ), 'fontFamily' ], + 'font-size': [ presets( 'typography', 'fontSizes' ), 'size' ], + }; + // A preset reference, either way theme.json writes one, as its value. + const value = ( raw: unknown ) => { + const match = String( raw ?? '' ).match( + /^var(?::preset\|([\w-]+)\|([\w-]+)|\(--wp--preset--([a-z-]+?)--([\w-]+)\))$/ + ); + if ( ! match ) { + return raw; + } + const [ list, field ] = lists[ match[ 1 ] ?? match[ 3 ] ] ?? [ [], '' ]; + const slug = match[ 2 ] ?? match[ 4 ]; + return list.find( ( preset ) => preset.slug === slug )?.[ field ] ?? raw; + }; + const style = ( tree: Tree ): Style => + Object.fromEntries( + [ 'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing' ] + .filter( ( key ) => tree[ key ] !== undefined ) + .map( ( key ) => [ key, value( tree[ key ] ) ] ) + ) as Style; + const styles = themeJson.styles; + const body = style( typography( styles ) ); + const heading = { ...body, ...style( typography( styles, 'elements', 'heading' ) ) }; + const button = at( styles, 'elements', 'button' ); + const background = value( at( styles, 'color', 'background' ) ); + const text = value( at( styles, 'color', 'text' ) ); + const buttonBackground = value( at( button, 'color', 'background' ) ); + const palette = lists.color[ 0 ].filter( ( preset ) => /^#[0-9a-f]{3,8}$/i.test( preset.color ) ); + const lead = palette.find( ( preset ) => preset.color !== background && preset.color !== text ) + ?.color; + const radius = value( at( button, 'border', 'radius' ) ); + return { + colors: Object.fromEntries( + [ + [ 'primary', lead ], + [ 'background', background ], + [ 'text', text ], + ...palette.map( ( preset ) => [ preset.slug, preset.color ] ), + ].filter( ( [ , color ] ) => typeof color === 'string' && color.startsWith( '#' ) ) + ), + typography: { + display: { ...heading, ...style( typography( styles, 'elements', 'h1' ) ) }, + headline: { ...heading, ...style( typography( styles, 'elements', 'h2' ) ) }, + body, + label: { ...body, ...style( typography( button ) ) }, + }, + rounded: radius === undefined ? {} : { sm: radius, md: radius }, + components: { + 'button-primary': { + ...( typeof buttonBackground === 'string' && { backgroundColor: buttonBackground } ), + ...( at( button, 'color', 'text' ) !== undefined && { + textColor: value( at( button, 'color', 'text' ) ), + } ), + ...( radius !== undefined && { rounded: radius } ), + ...( at( button, 'spacing', 'padding' ) !== undefined && { + padding: padding( at( button, 'spacing', 'padding' ) ), + } ), + }, + }, + } as DesignTokens; +} + +/** Renders a look's theme.json as a design-system board. */ +export function renderDesignBoard( themeJson: string, image?: string ): string { + let parsed: Tree; + try { + parsed = JSON.parse( themeJson ); + } catch ( error ) { + throw new Error( `The look's theme.json is not valid JSON: ${ ( error as Error ).message }` ); + } + const tokens = themeJsonTokens( parsed ); const { palette, background, diff --git a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts index c2f7c3c657..b13d3a1110 100644 --- a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts +++ b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts @@ -93,76 +93,85 @@ it( 'adds to a write the block validation and what WordPress renders instead of expect( await post( '/elsewhere/src/index.ts' ) ).toBeUndefined(); } ); -const DESIGN_MD = - '---\ncolors:\n primary: "#c2552b"\n background: "#fbf6ee"\ntypography:\n body:\n fontFamily: "Inter"\n fontWeight: 400\n---\n\n# Overview\n'; - afterEach( () => vi.unstubAllGlobals() ); -// DESIGN.md is the look. Writing a theme.json builds that theme on it, font -// files included; writing DESIGN.md reaches the active theme once it uses -// DESIGN.md's names. A theme WordPress updates is left alone. -const INTER = { slug: 'inter', name: 'Inter', fontFamily: 'Inter, sans-serif' }; -const NOTE = 'colors primary, background; font families inter; fonts of the styles body'; +// A theme.json font family without font files gets them from Google Fonts, in +// the weights the theme uses. A theme WordPress updates is left alone. +const FACE = { fontFamily: 'Inter', src: [ 'file:./assets/fonts/inter.woff2' ] }; it.each( [ - { written: 'bakery/theme.json', palette: [ 'ink' ], families: [ INTER ], wrote: NOTE }, - { written: 'DESIGN.md', palette: [ 'primary' ], families: [], wrote: NOTE }, - { written: 'DESIGN.md', palette: [ 'ink' ], families: [], wrote: undefined }, - { written: 'bakery-parent/theme.json', palette: [ 'ink' ], families: [], wrote: undefined }, + { + theme: 'bakery', + family: 'Inter', + fontFace: undefined, + note: 'Studio downloaded the font files of Inter from Google Fonts into wp-content/themes/bakery/assets/fonts and declared them as their fontFace in theme.json.', + }, + { + theme: 'bakery', + family: 'Nope Sans', + fontFace: undefined, + note: 'Nope Sans has no font files in wp-content/themes/bakery/theme.json, and Google Fonts did not provide them (https://fonts.googleapis.com/css2?family=Nope+Sans:wght@400;600&display=swap responded with HTTP 400).', + }, + { theme: 'bakery', family: 'Inter', fontFace: [ FACE ], note: undefined }, + { theme: 'bakery-parent', family: 'Inter', fontFace: undefined, note: undefined }, ] )( - 'writes DESIGN.md into the theme after a write to $written over $palette', - async ( { written, palette, families, wrote } ) => { - vi.stubGlobal( - 'fetch', - vi.fn( async ( url: string ) => - url.startsWith( 'https://fonts.googleapis.com/' ) - ? new Response( - "/* latin */\n@font-face {\n font-family: 'Inter';\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/inter.woff2) format('woff2');\n}\n" - ) - : new Response( new Uint8Array( [ 1 ] ) ) - ) + 'self-hosts $family in $theme after a theme.json write, with font files: $fontFace', + async ( { theme, family, fontFace, note } ) => { + const fetch = vi.fn( async ( url: string ) => + url.includes( 'Nope' ) + ? new Response( '', { status: 400 } ) + : url.startsWith( 'https://fonts.googleapis.com/' ) + ? new Response( + "/* latin */\n@font-face {\n font-family: 'Inter';\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/inter.woff2) format('woff2');\n}\n" + ) + : new Response( new Uint8Array( [ 1 ] ) ) ); - writeFileSync( path.join( site.root, 'DESIGN.md' ), DESIGN_MD ); - const themes = path.join( site.root, 'wp-content/themes' ); + vi.stubGlobal( 'fetch', fetch ); + const themeDir = path.join( site.root, 'wp-content/themes', theme ); const start = { version: 3, settings: { - color: { palette: palette.map( ( slug ) => ( { slug, color: '#111' } ) ) }, - typography: { fontFamilies: families }, + typography: { + fontFamilies: [ + { slug: 'body', name: family, fontFamily: `"${ family }", sans-serif`, fontFace }, + ], + }, + }, + styles: { + typography: { fontFamily: 'var:preset|font-family|body' }, + elements: { h1: { typography: { fontWeight: '600' } } }, }, }; - for ( const slug of [ 'bakery', 'bakery-parent' ] ) { - writeFileSync( path.join( themes, slug, 'theme.json' ), JSON.stringify( start ) ); - } + writeFileSync( path.join( themeDir, 'theme.json' ), JSON.stringify( start ) ); const output = await runFileToolHook( { hook_event_name: 'PostToolUse', tool_name: 'Write', - tool_input: { - file_path: path.join( written === 'DESIGN.md' ? site.root : themes, written ), - }, + tool_input: { file_path: path.join( themeDir, 'theme.json' ) }, } ); - const theme = wrote && ( written === 'DESIGN.md' ? 'bakery' : written.split( '/' )[ 0 ] ); - for ( const slug of [ 'bakery', 'bakery-parent' ] ) { - const themeJson = JSON.parse( - readFileSync( path.join( themes, slug, 'theme.json' ), 'utf8' ) + const themeJson = JSON.parse( readFileSync( path.join( themeDir, 'theme.json' ), 'utf8' ) ); + const hosted = note?.startsWith( 'Studio downloaded' ); + expect( themeJson.settings.typography.fontFamilies[ 0 ].fontFace ).toEqual( + hosted + ? [ + { + fontFamily: 'Inter', + fontStyle: 'normal', + fontWeight: '400', + src: [ 'file:./assets/fonts/inter/inter-400-latin.woff2' ], + }, + ] + : fontFace + ); + expect( existsSync( path.join( themeDir, 'assets/fonts/inter' ) ) ).toBe( !! hosted ); + if ( hosted ) { + expect( fetch ).toHaveBeenCalledWith( + 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap', + expect.anything() ); - if ( slug !== theme ) { - expect( themeJson ).toEqual( start ); - continue; - } - expect( - themeJson.settings.color.palette.map( ( color: { slug: string } ) => color.slug ) - ).toEqual( [ ...new Set( [ ...palette, 'primary', 'background' ] ) ] ); - expect( themeJson.styles.typography.fontFamily ).toBe( 'var:preset|font-family|inter' ); - expect( themeJson.settings.typography.fontFamilies[ 0 ].fontFace ).toHaveLength( 1 ); - expect( existsSync( path.join( themes, slug, 'assets/fonts/inter' ) ) ).toBe( true ); } expect( output?.hookSpecificOutput ).toEqual( - wrote && { - hookEventName: 'PostToolUse', - additionalContext: `Note: Studio wrote DESIGN.md's tokens into wp-content/themes/${ theme }/theme.json under DESIGN.md's names: ${ wrote }. DESIGN.md holds these values; change the look there.`, - } + note && { hookEventName: 'PostToolUse', additionalContext: `Note: ${ note }` } ); } ); diff --git a/apps/cli/ai/site-file-guards.ts b/apps/cli/ai/site-file-guards.ts index f75202fc66..f9b2a4726c 100644 --- a/apps/cli/ai/site-file-guards.ts +++ b/apps/cli/ai/site-file-guards.ts @@ -10,7 +10,7 @@ */ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; import path from 'path'; -import { settleThemeDesign } from '@studio/common/sites/site-design'; +import { selfHostThemeJsonFonts } from '@studio/common/lib/theme-fonts'; import { savedCopyWriteNote } from 'cli/ai/saved-copies'; import { readWordPressSite } from 'cli/ai/site-database'; import { isUpdatableTheme } from 'cli/ai/themes'; @@ -176,70 +176,40 @@ async function getSiteFileWriteNotes( siteFile: SiteFile ): Promise< string[] > return notes; } -// The theme a write to DESIGN.md or a theme.json leaves out of step with the -// site's design. Writing a theme.json builds that theme on DESIGN.md; writing -// DESIGN.md reaches the active theme only once it uses DESIGN.md's names, so a -// default theme the design was never built into stays as it is. Themes -// WordPress updates are left as they are. -async function themeOfDesignWrite( - siteFile: SiteFile -): Promise< { slug: string; built: boolean } | undefined > { - const { siteRoot, relativePath, theme } = siteFile; - const built = theme?.file === 'theme.json'; - const slug = - relativePath === 'DESIGN.md' - ? await readWordPressSite( siteRoot, ( site ) => site.option( 'stylesheet' ) ) - : built - ? theme.slug - : undefined; - return typeof slug === 'string' && ! ( await isUpdatableTheme( siteRoot, slug ) ) - ? { slug, built } - : undefined; -} - /** - * Keeps a theme built on the site's DESIGN.md carrying its tokens, after a - * write to either, and says what it wrote. + * Self-hosts the fonts a theme.json just written declares without font files, + * and says what it downloaded. Themes WordPress updates are left as they are. */ -async function settleDesign( siteFile: SiteFile ): Promise< string[] > { - const target = await themeOfDesignWrite( siteFile ); - if ( ! target ) { - return []; - } - const { slug, built } = target; - const { drift, fontsError } = await settleThemeDesign( siteFile.siteRoot, slug, built ); - if ( ! drift.length ) { +async function selfHostFonts( siteFile: SiteFile ): Promise< string[] > { + const { siteRoot, theme } = siteFile; + if ( theme?.file !== 'theme.json' || ( await isUpdatableTheme( siteRoot, theme.slug ) ) ) { return []; } - const kinds: Record< string, string > = { - color: 'colors', - 'font-family': 'font families', - 'font-size': 'font sizes', - spacing: 'spacing steps', - 'style-font': 'fonts of the styles', - }; - const written = Object.entries( kinds ).flatMap( ( [ kind, label ] ) => { - const slugs = drift.filter( ( entry ) => entry.kind === kind ).map( ( entry ) => entry.slug ); - return slugs.length ? [ `${ label } ${ slugs.join( ', ' ) }` ] : []; - } ); + const { hosted, failed } = await selfHostThemeJsonFonts( theme.dir ); + const themePath = `wp-content/themes/${ theme.slug }`; return [ - `Studio wrote DESIGN.md's tokens into wp-content/themes/${ slug }/theme.json under DESIGN.md's names: ${ written.join( - '; ' - ) }.${ - fontsError ? ` The font files did not download (${ fontsError }).` : '' - } DESIGN.md holds these values; change the look there.`, + ...( hosted.length + ? [ + `Studio downloaded the font files of ${ hosted.join( + ', ' + ) } from Google Fonts into ${ themePath }/assets/fonts and declared them as their fontFace in theme.json.`, + ] + : [] ), + ...failed.map( + ( { family, error } ) => + `${ family } has no font files in ${ themePath }/theme.json, and Google Fonts did not provide them (${ error }).` + ), ]; } /** - * After a write to a site's file: registers a changed pattern, keeps the - * theme carrying DESIGN.md's tokens, and returns what WordPress will render - * differently from the file. + * After a write to a site's file: registers a changed pattern, self-hosts the + * theme's fonts, and returns what WordPress will render differently from the file. */ export async function afterSiteFileWrite( siteFile: SiteFile ): Promise< string[] > { await clearThemePatternCache( siteFile ).catch( () => undefined ); - const design = await settleDesign( siteFile ).catch( () => [] ); - return [ ...design, ...( await getSiteFileWriteNotes( siteFile ).catch( () => [] ) ) ]; + const fonts = await selfHostFonts( siteFile ).catch( () => [] ); + return [ ...fonts, ...( await getSiteFileWriteNotes( siteFile ).catch( () => [] ) ) ]; } /** diff --git a/apps/cli/ai/site-selection.ts b/apps/cli/ai/site-selection.ts index 4953f79220..0c85f66c01 100644 --- a/apps/cli/ai/site-selection.ts +++ b/apps/cli/ai/site-selection.ts @@ -1,5 +1,3 @@ -import { existsSync } from 'node:fs'; -import path from 'node:path'; import type { SiteInfo } from 'cli/ai/types'; type LocalSiteSelectedCallback = ( site: SiteInfo ) => void | Promise< void >; @@ -20,9 +18,5 @@ export function formatActiveSite( site: SiteInfo ): string { if ( site.remote && site.url ) { return `"${ site.name }" (ID: ${ site.wpcomSiteId }) at ${ site.url } (WordPress.com)`; } - const designPath = path.join( site.path, 'DESIGN.md' ); - const design = existsSync( designPath ) ? `, design system: ${ designPath }` : ''; - return `"${ site.name }" at ${ site.path }${ - site.running ? ' (running)' : ' (stopped)' - }${ design }`; + return `"${ site.name }" at ${ site.path }${ site.running ? ' (running)' : ' (stopped)' }`; } diff --git a/apps/cli/ai/skills/build-site/SKILL.md b/apps/cli/ai/skills/build-site/SKILL.md index 9dbc45f90b..eaad3cae23 100644 --- a/apps/cli/ai/skills/build-site/SKILL.md +++ b/apps/cli/ai/skills/build-site/SKILL.md @@ -28,33 +28,32 @@ A new site's spec needs nothing from the site itself: do not search its files or ### The design -Load the `visual-design` skill first — the catalogs, the DESIGN.md format, and the sneak-peek rules live there — and follow its "Concept and Direction" runbook. The design is settled in two steps, the look and then the layout, each with one `pick_design` call, repeated only when the user asks for other options: +Load the `visual-design` skill first — the catalogs, the look as theme.json, and the sneak-peek rules live there — and follow its "Concept and Direction" runbook. The design is settled in two steps, the look and then the layout, each with one `pick_design` call, repeated only when the user asks for other options: - Pass `options: 4` so the user picks, or `options: 1` when there is nothing to pick — the user asked to be surprised or to skip the questions, or the brief names that step's catalog entry. Neither a page count ("one page") nor adjectives ("elegant", "warm") pick an entry: ask, and apply them to the options you choose. For a reference site, look at it once with `take_screenshot` (`display: false`) and choose its closest entries, with `options` set to their number: two when `present_design_options` is available, otherwise one. - With one entry, use it without asking. With more, ask once, one option per entry in the order returned, labelled with the entry's name, without describing the options in prose first. A typed answer ("2 but darker") is a preference to apply to the closest option; asking for other options, as a choice or in their own words, means drawing that step again and asking the same way. -1. **The look**: `catalog: "directions"`, asking "Which look should I build?". Skip this step when the Active site section names a design system and the user did not ask for a new look. - - If `present_design_options` is available: when you can make images (`generate_images`, or an image tool of your own), load the `imagery` skill and make the look image first. Pass each option's `DESIGN.md` draft (see the `visual-design` skill) as its `preview`, with that image and a one-line description of the feel. +1. **The look**: `catalog: "directions"`, asking "Which look should I build?". Skip this step when the site keeps its own theme and the user did not ask for a new look. + - If `present_design_options` is available: when you can make images (`generate_images`, or an image tool of your own), load the `imagery` skill and make the look image first. Pass each option's theme.json draft (see the `visual-design` skill) as its `preview`, with that image and a one-line description of the feel. - Otherwise use `AskUserQuestion`, each option with a one-line description of the look. - - Write the picked look to `DESIGN.md` at the site root: the draft's front matter exactly as the user saw it, then every section. Then, when you can make images (`generate_images`, or an image tool of your own), load the `imagery` skill and make the site's image set in that look so the layout previews and the build have it. + - Then, when you can make images (`generate_images`, or an image tool of your own), load the `imagery` skill and make the site's image set in that look so the layout previews and the build have it. 2. **The layout**: `catalog: "layouts"`, asking "Which layout should I build?". - If `present_design_options` is available: pass one sneak peek per option, in the picked look and with the site's image set (see the `visual-design` skill), as its `preview`, with a one-line description of the layout. - Otherwise use `AskUserQuestion`, each option with a one-line description of how its first screen would look. - - Add the picked layout to the Layout section of `DESIGN.md`, then build it. ### The Site Spec -Before step 3, state the plan in your text output as a short **Site Spec** summary. The summary MUST include a **Concept** line — the signature layout concept settled by `pick_design` or picked by the user, as `Concept: — `, the entry's name verbatim so the user can find it, then the twist — followed by a **Layout map** (as the `visual-design` skill defines it: one line per section of the page saying what the concept does to it), a **Direction** line — the artistic direction behind `DESIGN.md`, as `Direction: — ` — and a **Functionality & plugins** line. A side designed from the brief rather than the catalog is stated as `Concept: — ` or `Direction: — `. For the plugins line: review the requested features, load the `plugin-recommendations` skill, and list the specific plugins the site needs — e.g. WooCommerce for selling products, Jetpack Forms for a contact form, Jetpack Newsletter for email signups, Sensei LMS for courses, Crowdsignal for polls/surveys — or "None — core blocks only" when nothing beyond static content is required. Install the listed plugins in step 4; do not silently hand-build static markup for a feature a plugin should provide. +Before step 3, state the plan in your text output as a short **Site Spec** summary. The summary MUST include a **Concept** line — the signature layout concept settled by `pick_design` or picked by the user, as `Concept: — `, the entry's name verbatim so the user can find it, then the twist — followed by a **Layout map** (as the `visual-design` skill defines it: one line per section of the page saying what the concept does to it), a **Direction** line — the artistic direction behind the theme.json, as `Direction: — ` — and a **Functionality & plugins** line. A side designed from the brief rather than the catalog is stated as `Concept: — ` or `Direction: — `. For the plugins line: review the requested features, load the `plugin-recommendations` skill, and list the specific plugins the site needs — e.g. WooCommerce for selling products, Jetpack Forms for a contact form, Jetpack Newsletter for email signups, Sensei LMS for courses, Crowdsignal for polls/surveys — or "None — core blocks only" when nothing beyond static content is required. Install the listed plugins in step 4; do not silently hand-build static markup for a feature a plugin should provide. ### When to Skip the Questions -Skipping means skipping the interactive questions only — each design step then returns a single entry, `DESIGN.md` is still written, and you still produce the Site Spec summary (including the Functionality & plugins line) before building. Do NOT ask questions if: +Skipping means skipping the interactive questions only — each design step then returns a single entry, and you still produce the Site Spec summary (including the Functionality & plugins line) before building. Do NOT ask questions if: - The user says "just build something" or "surprise me". Pick a bold creative direction yourself and proceed. - The user explicitly asks to skip the setup or says they don't want questions. ## 3. Theme files -Write a block theme in `wp-content/themes/`, or a child theme to restyle an installed theme, one file at a time: style.css, theme.json, templates, parts, patterns. +Write a block theme in `wp-content/themes/`, or a child theme to restyle an installed theme, one file at a time: style.css, theme.json, templates, parts, patterns. Its theme.json is the picked look's: copy the file `present_design_options` named, or write the one settled look. ## 4. Provision the site @@ -66,4 +65,4 @@ Write each page's content to a scratch file in the site's `tmp/`, then create or ## 6. Check the result -Take one `take_screenshot` of each page with `viewport: "all"`. List every real defect against DESIGN.md and the layout map, find each cause with inspect_design and the source that produces it, fix them in one batch, and re-check once. When Studio Code wrote the theme, pass its directory as `themeScreenshot` on the home page's last desktop capture. +Take one `take_screenshot` of each page with `viewport: "all"`. List every real defect against the look and the layout map, find each cause with inspect_design and the source that produces it, fix them in one batch, and re-check once. When Studio Code wrote the theme, pass its directory as `themeScreenshot` on the home page's last desktop capture. diff --git a/apps/cli/ai/skills/edit-manage-site/SKILL.md b/apps/cli/ai/skills/edit-manage-site/SKILL.md index 7e18a3f4a0..09a08b3518 100644 --- a/apps/cli/ai/skills/edit-manage-site/SKILL.md +++ b/apps/cli/ai/skills/edit-manage-site/SKILL.md @@ -10,11 +10,11 @@ Use this skill for work on an existing local Studio site. A new site, a redesign ## 1. Settle the site -Start the site with site_start if it is not running. When the Active site section names a design system (DESIGN.md), read it before any design or content work — pages, posts, patterns, images, or copy — and follow it. A change to the look updates DESIGN.md in the same turn. +Start the site with site_start if it is not running. ## 2. Make the change -Load the other skills the change calls for. Follow the site's existing look — its DESIGN.md and active theme — rather than redesigning it, and update DESIGN.md when the look changes. +Load the other skills the change calls for. Follow the site's existing look — the active theme's theme.json presets and styles — rather than redesigning it; a change to the look is a change to theme.json. ## 3. Check and report diff --git a/apps/cli/ai/skills/imagery/SKILL.md b/apps/cli/ai/skills/imagery/SKILL.md index 2cbf2ade2d..0cf2388d8c 100644 --- a/apps/cli/ai/skills/imagery/SKILL.md +++ b/apps/cli/ai/skills/imagery/SKILL.md @@ -9,7 +9,7 @@ tools: generate_images ## Images for the design steps - **The look image**: before the look options, one image of the site's first-screen scene in `landscape` at `/wp-content/uploads/.png`, with a neutral grade in `pageContext` and no `imageGrade`. Pass its path as each look's `image`. A direction that rejects photography passes no image. -- **The site's set**: once `DESIGN.md` is written, one call with 3–4 images in the picked look (its Imagery section as `imageGrade`): the first-screen scene plus distinct supporting subjects, each in the aspect ratio of its likely slot. The sneak peeks and the build use this set first; generate more only for slots it cannot fill, with the same `imageGrade`. +- **The site's set**: once the look is picked, one call with 3–4 images in it (the direction's Imagery line as `imageGrade`): the first-screen scene plus distinct supporting subjects, each in the aspect ratio of its likely slot. The sneak peeks and the build use this set first; generate more only for slots it cannot fill, with the same `imageGrade`. ## Placing images diff --git a/apps/cli/ai/skills/visual-design/SKILL.md b/apps/cli/ai/skills/visual-design/SKILL.md index 4f29400b2e..db3be149e0 100644 --- a/apps/cli/ai/skills/visual-design/SKILL.md +++ b/apps/cli/ai/skills/visual-design/SKILL.md @@ -10,7 +10,7 @@ Use this skill before creating or redesigning a site, landing page, homepage, la ## Concept and Direction -Every site gets one artistic direction and one signature concept, both chosen before any code is written: the direction first, because it becomes the site's `DESIGN.md`, then the concept, chosen to suit it. The concept is the one structural idea a visitor remembers — a cover made of four tiles, a site laid out sideways, a page read like a newspaper — and it is about layout only: the shape of the page and how sections relate to the viewport. The direction is everything the concept leaves open — color, type, surfaces, shapes, how imagery is treated, how things move — and any direction must be able to dress any concept. A Swiss typographic Checkerboard and a Neo-brutalist Checkerboard share a grid and nothing else. +Every site gets one artistic direction and one signature concept, both chosen before any code is written: the direction first, because it becomes the theme's theme.json, then the concept, chosen to suit it. The concept is the one structural idea a visitor remembers — a cover made of four tiles, a site laid out sideways, a page read like a newspaper — and it is about layout only: the shape of the page and how sections relate to the viewport. The direction is everything the concept leaves open — color, type, surfaces, shapes, how imagery is treated, how things move — and any direction must be able to dress any concept. A Swiss typographic Checkerboard and a Neo-brutalist Checkerboard share a grid and nothing else. The two catalogs at the end of this runbook list every layout concept and every artistic direction by name and description; their notes only come back from `pick_design` for the entries it settles. The user's words always win over the catalog, on both sides: @@ -22,23 +22,23 @@ The two catalogs at the end of this runbook list every layout concept and every 6. Build what was settled or picked — never a substitute. In a redesign, a side the user keeps counts as named in the brief. 7. Adapt both to the site: change the subject, the proportions, or the content that fills each slot of the concept so it belongs to this brand rather than to the catalog, and tune the direction's palette and type to the brand while keeping its relationships. Name the twist for each. "Simple" or "small" in a brief means fewer pages and less content, not a tamer concept or direction. 8. A **layout map** is the plan the build follows, written before any code: one line per section of the page, in page order, saying what the concept does to that section — which slot of the layout it fills, how it is positioned relative to the viewport and its neighbors, what it must not fall back to. Every section gets a line, including the header, the footer, and any form; a section the concept does not shape must say so explicitly and justify why. -9. Build the concept first, in the hero or first template, not as a finishing touch, and follow the entry's build and fallback notes: theme CSS and a small vanilla script only, editable content, and a mobile layout. Follow `DESIGN.md`'s sections throughout — the look is a system that every section obeys, not a coat of paint on the hero. Shape language still serves the concept: a direction's rounded corners go on inner elements (buttons, cards, form fields), never on a section, row, or grid cell that touches the viewport edge — anything full-bleed stays square-cornered so it still meets the edge. +9. Build the concept first, in the hero or first template, not as a finishing touch, and follow the entry's build and fallback notes: theme CSS and a small vanilla script only, editable content, and a mobile layout. Drive every section from theme.json — the look is a system that every section obeys, not a coat of paint on the hero. Shape language still serves the concept: a direction's rounded corners go on inner elements (buttons, cards, form fields), never on a section, row, or grid cell that touches the viewport edge — anything full-bleed stays square-cornered so it still meets the edge. -## DESIGN.md +## The look as theme.json -The look is written down once, as `DESIGN.md` at the site root, in the DESIGN.md format: YAML front matter with the design tokens, then Markdown sections that explain how to use them. Every later change to the site reads it, so it must stand on its own. +The look is written once, as the theme's `theme.json` (version 3) — the file WordPress, the Site Editor, and Global Styles read — so every later change finds it there. -- **Front matter**: `name` (the site's name), `description` (its tagline), `colors` (quoted hex values, each color once: `primary`, `background`, `text`, and any accents), `typography` (named styles such as `display`, `headline`, `body`, and `label`, each with a Google Fonts `fontFamily`, a `fontSize` in px or rem, `fontWeight`, `lineHeight`, and `letterSpacing` where it matters), `rounded` and `spacing` (named scales, such as `sm: 4px`), `components` (at least `button-primary` with `backgroundColor`, `textColor`, `rounded`, and `padding`), and `imagery` (the direction's photo treatment as CSS: `filter`, the value from its Imagery line, left out when it names none, and `overlay`, `multiply` or `screen`, only for a duotone, where a layer of `primary` sits over the photo). Component values can reference tokens, as in `"{colors.primary}"`. -- **Sections**, in this order: Overview, Colors, Typography, Layout, Elevation & Depth, Shapes, Components, Do's and Don'ts, Imagery, Motion, Voice. They carry the direction's lines, tuned to the brand: Palette into Colors, Type into Typography, Surface into Elevation & Depth, Avoid into Do's and Don'ts, and Shapes, Imagery, and Motion into their own. Until the layout is picked, Layout holds only the content widths and spacing rhythm, and no section describes the page's structure — no grids, bands, or concept names; the layout step then adds the signature concept and how every page follows it. Voice is how the copy talks: its tone, and the words to use and to avoid. -- **A draft**, for a design option, is the front matter and the Overview. It is rendered as a design-system board — the `display` and `body` type with the scale, the colors as a grid led by `primary`, the components, and the look image under the option's `imagery` treatment, framed in its `rounded` shape — so the user judges exactly the tokens. The picked draft becomes the file: tokens unchanged, every section written. +- **settings**: `color.palette` (hex values, each color once), `typography.fontFamilies` (Google Fonts families, each with `slug`, `name`, and `fontFamily` with a fallback stack; Studio downloads the font files into the theme when theme.json is written), `typography.fontSizes`, `spacing.spacingSizes`, and `layout` content and wide sizes. +- **styles**: the root `color` and `typography` from those presets, and `elements` for `heading` (with `h1` and `h2` sizes), `link`, and `button` (colors, `border.radius`, `spacing.padding`, `typography`). The direction's Shapes become radii and borders here; its Surface, Imagery, and Motion, which theme.json has no place for, go in the theme's CSS. +- **A draft**, for a design option, is that whole theme.json. It is rendered as a design-system board — the heading and body type, the palette led by the ground and text, the button and link, and the look image — so the user judges exactly the tokens. The picked draft becomes the theme's theme.json unchanged. ## Sneak peeks A sneak peek is a standalone HTML page that lets a layout concept be judged in the picked look, at a glance, before anything is built. It is a taste of the layout, not a page to reuse: never port it into the theme. - **A full frame**: the header or masthead and the hero as the concept shapes them, then the next section. The frame is 1200×900 and the page must reach its bottom edge, so make `body` a `min-height: 100vh` flex column and give the last section `flex: 1` and a background — nothing ends inside the frame, whatever the copy length. Grids need enough rows to pass 900px: four square columns of 300px need three rows. -- **The look, exactly**: `DESIGN.md`'s colors and fonts drive every color and font, and its surfaces and shapes show in the hero, so only the layout differs between options. -- **Real content**: the site's real name and plausible copy, never lorem ipsum. Image slots take the site's image set (see the `imagery` skill), a different image per slot with the first-screen scene in the dominant one, never the same image twice in a frame, each under `DESIGN.md`'s `imagery` treatment; a slot the set cannot fill takes a solid color shape in a palette color. +- **The look, exactly**: the picked theme.json's colors and fonts drive every color and font, and the direction's surfaces and shapes show in the hero, so only the layout differs between options. +- **Real content**: the site's real name and plausible copy, never lorem ipsum. Image slots take the site's image set (see the `imagery` skill), a different image per slot with the first-screen scene in the dominant one, never the same image twice in a frame, each under the direction's Imagery treatment; a slot the set cannot fill takes a solid color shape in a palette color. - **Small and self-contained**: inline CSS, under ~120 lines, no scripts; a Google Fonts `` with a fallback stack is fine. ## Craft diff --git a/apps/cli/ai/tests/present-design-options.test.ts b/apps/cli/ai/tests/present-design-options.test.ts index ce2af4acd0..fff88d28c0 100644 --- a/apps/cli/ai/tests/present-design-options.test.ts +++ b/apps/cli/ai/tests/present-design-options.test.ts @@ -1,5 +1,7 @@ +import { readFileSync } from 'node:fs'; import os from 'node:os'; import { describe, expect, it, vi } from 'vitest'; +import { renderDesignBoard } from 'cli/ai/design-board'; import { createPresentDesignOptionsTool } from 'cli/ai/tools/present-design-options'; vi.mock( 'cli/ai/screenshot-storage', () => ( { @@ -52,4 +54,56 @@ describe( 'present_design_options', () => { ], } ); } ); + + // A look is its theme.json: the board shows its tokens, and the picked one + // is handed over as a file to become the theme's theme.json. + it( 'renders a look from its theme.json and hands over the picked one as a file', async () => { + const look = ( ground: string ) => + JSON.stringify( { + version: 3, + settings: { + color: { + palette: [ + { slug: 'ground', color: ground }, + { slug: 'ink', color: '#1b1b1b' }, + { slug: 'brick', color: '#b5432a' }, + ], + }, + typography: { + fontFamilies: [ + { slug: 'display', name: 'Fraunces', fontFamily: '"Fraunces", serif' }, + ], + }, + }, + styles: { + color: { background: 'var:preset|color|ground', text: 'var(--wp--preset--color--ink)' }, + typography: { fontFamily: 'var:preset|font-family|display' }, + elements: { + button: { + color: { background: 'var:preset|color|brick' }, + border: { radius: '999px' }, + }, + }, + }, + } ); + const board = renderDesignBoard( look( '#f6efe4' ) ); + expect( board ).toContain( 'background:#f6efe4;color:#1b1b1b' ); + expect( board ).toContain( 'family=Fraunces' ); + expect( board ).toContain( '.primary{background-color:#b5432a' ); + expect( board ).toContain( 'border-radius:999px' ); + + const result = await createPresentDesignOptionsTool( { + onAskUser: async () => ( { 'Which look should I build?': 'Night' } ), + } ).rawHandler( { + catalog: 'directions', + question: 'Which look should I build?', + options: [ + { label: 'Day', description: 'Warm paper.', preview: look( '#f6efe4' ) }, + { label: 'Night', description: 'Ink ground.', preview: look( '#101010' ) }, + ], + } ); + const file = textOf( result ).match( /theme\.json: (\S+)$/ )?.[ 1 ]; + expect( textOf( result ) ).toContain( 'The user picked option 2: Night.' ); + expect( readFileSync( file!, 'utf8' ) ).toBe( look( '#101010' ) ); + } ); } ); diff --git a/apps/cli/ai/tests/system-prompt.test.ts b/apps/cli/ai/tests/system-prompt.test.ts index 391f139ecd..1a586f04b0 100644 --- a/apps/cli/ai/tests/system-prompt.test.ts +++ b/apps/cli/ai/tests/system-prompt.test.ts @@ -1,6 +1,3 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; import { describe, expect, it } from 'vitest'; import { getSkillScopedToolNames, loadSkills } from '../skills'; import { buildSystemPrompt, PROXY_IDENTITY } from '../system-prompt'; @@ -82,24 +79,6 @@ describe( 'buildSystemPrompt', () => { ); } ); - it( "names the active site's design system when it has a DESIGN.md", () => { - const sitePath = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-active-site-' ) ); - try { - fs.writeFileSync( path.join( sitePath, 'DESIGN.md' ), '# Design' ); - const prompt = buildSystemPrompt( { - activeSite: { name: 'Designed', path: sitePath, running: false }, - } ); - expect( prompt ).toContain( - `"Designed" at ${ sitePath } (stopped), design system: ${ path.join( - sitePath, - 'DESIGN.md' - ) }` - ); - } finally { - fs.rmSync( sitePath, { recursive: true, force: true } ); - } - } ); - it( 'appends the user standing instructions when set', () => { const prompt = buildSystemPrompt( { userInstructions: 'Always answer in French.' } ); expect( prompt ).toContain( "## The user's standing instructions" ); diff --git a/apps/cli/ai/tools/present-design-options.ts b/apps/cli/ai/tools/present-design-options.ts index 86f76651b2..73d42ce7dd 100644 --- a/apps/cli/ai/tools/present-design-options.ts +++ b/apps/cli/ai/tools/present-design-options.ts @@ -79,7 +79,13 @@ function escapeHtml( text: string ): string { // shows, or as one numbered grid image for it to show and ask about. async function handOverOptions( question: string, - options: { label: string; description: string; image: string; buffer: Buffer }[], + options: { + label: string; + description: string; + image: string; + buffer: Buffer; + themeJson?: string; + }[], imageLink: ( file: string ) => string, picker: boolean ): Promise< ToolResult > { @@ -111,7 +117,18 @@ async function handOverOptions( viewportType: 'design-options', format: 'jpeg', } ); - const nextStep = `Continue with what they pick. If they pick "${ OTHER_OPTIONS }", draw that step again with pick_design; if they describe their own, follow it.`; + const looks = options.flatMap( ( option, index ) => + option.themeJson ? [ `${ index + 1 }. ${ option.label }: ${ option.themeJson }` ] : [] + ); + const nextStep = [ + `Continue with what they pick. If they pick "${ OTHER_OPTIONS }", draw that step again with pick_design; if they describe their own, follow it.`, + ...( looks.length + ? [ + "Each look's theme.json, to copy into the theme as its theme.json once picked:", + ...looks, + ] + : [] ), + ].join( '\n' ); const gridImage = { type: 'image' as const, data: grid.buffer.toString( 'base64' ), @@ -186,7 +203,7 @@ export function createPresentDesignOptionsTool( { : picker ? 'shows them to the user under this call as a clickable picker' : 'returns them as one numbered grid image for you to show and ask about, since the user does not see tool results' - }. Pass one option per drawn entry (2–4), in the order pick_design returned them, each with a \`preview\`: for a look, the option's DESIGN.md draft, rendered as a design board with its generated \`image\` if it has one; for a layout, a complete standalone HTML sneak peek — inline CSS, no scripts, optionally a Google Fonts link with a fallback stack; images referenced by absolute path under the site are inlined, otherwise use solid color shapes, never web URLs. Each is rendered in a ${ + }. Pass one option per drawn entry (2–4), in the order pick_design returned them, each with a \`preview\`: for a look, the option's theme.json (see the visual-design skill), rendered as a design board with its generated \`image\` if it has one, and saved as a file for the picked one to become the theme's theme.json; for a layout, a complete standalone HTML sneak peek — inline CSS, no scripts, optionally a Google Fonts link with a fallback stack; images referenced by absolute path under the site are inlined, otherwise use solid color shapes, never web URLs. Each is rendered in a ${ PREVIEW_VIEWPORT.width }×${ PREVIEW_VIEWPORT.height @@ -211,7 +228,7 @@ export function createPresentDesignOptionsTool( { } ), preview: Type.String( { description: - 'A DESIGN.md draft (front matter and Overview) for a look, or a complete HTML sneak peek for a layout.', + "The look's theme.json for a look, or a complete HTML sneak peek for a layout.", } ), image: Type.Optional( Type.String( { @@ -243,7 +260,7 @@ export function createPresentDesignOptionsTool( { const htmlPath = path.join( directory, `preview-${ index + 1 }-${ slug }.html` ); let capture; try { - const isDesignBoard = option.preview.trimStart().startsWith( '---' ); + const isDesignBoard = args.catalog === 'directions'; const html = isDesignBoard ? renderDesignBoard( option.preview, option.image ) : option.preview; @@ -276,11 +293,17 @@ export function createPresentDesignOptionsTool( { viewportType: `preview-${ index + 1 }`, format: previewFormat, } ); + let themeJson: string | undefined; + if ( args.catalog === 'directions' ) { + themeJson = path.join( directory, `look-${ index + 1 }-${ slug }.theme.json` ); + await writeFile( themeJson, option.preview ); + } return { label: option.label, description: option.description, image: file.path, buffer: capture.buffer, + themeJson, }; } ) ); @@ -313,10 +336,15 @@ export function createPresentDesignOptionsTool( { if ( ! answer ) { return textResult( 'The user did not answer.' ); } + const themeJson = options[ picked ]?.themeJson; return textResult( picked === -1 ? `The user answered: ${ answer }` - : `The user picked option ${ picked + 1 }: ${ answer }` + : `The user picked option ${ picked + 1 }: ${ answer }${ + themeJson + ? `. Its theme.json, to copy into the theme as its theme.json: ${ themeJson }` + : '' + }` ); }, { settlesPendingWork: true, annotations: LOCAL_CHANGE } diff --git a/packages/common/lib/theme-fonts.ts b/packages/common/lib/theme-fonts.ts index 74ea2570a7..2793d9b11f 100644 --- a/packages/common/lib/theme-fonts.ts +++ b/packages/common/lib/theme-fonts.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; /** A theme.json `fontFace` entry. */ @@ -127,3 +127,75 @@ export async function downloadThemeFonts( } return fonts; } + +interface FontFamilyPreset { + slug?: string; + name?: string; + fontFamily?: string; + fontFace?: unknown[]; +} + +export interface SelfHostedFonts { + /** Families whose font files were downloaded and declared. */ + hosted: string[]; + /** Families Google Fonts had no files for, with why. */ + failed: { family: string; error: string }[]; +} + +// Every weight a theme.json's styles set, and 400. Styles inherit their font +// family, so a weight set on its own can still be a weight of any family. +function weightsOf( styles: unknown ): string[] { + const weights = new Set< string >( [ '400' ] ); + const walk = ( node: unknown ) => { + if ( ! node || typeof node !== 'object' ) { + return; + } + const { fontWeight } = node as { fontWeight?: unknown }; + if ( /^\d{3}$/.test( String( fontWeight ) ) ) { + weights.add( String( fontWeight ) ); + } + Object.values( node ).forEach( walk ); + }; + walk( styles ); + return [ ...weights ].sort(); +} + +/** + * Self-hosts a theme's fonts: each font family preset in its theme.json that + * declares no font files gets them from Google Fonts, in the weights its styles + * use, downloaded into assets/fonts and declared as its `fontFace`. + */ +export async function selfHostThemeJsonFonts( themeDir: string ): Promise< SelfHostedFonts > { + const themeJsonPath = path.join( themeDir, 'theme.json' ); + const themeJson = JSON.parse( await readFile( themeJsonPath, 'utf8' ) ); + const families: FontFamilyPreset[] = themeJson?.settings?.typography?.fontFamilies ?? []; + const result: SelfHostedFonts = { hosted: [], failed: [] }; + for ( const family of families ) { + const name = + family.name ?? + family.fontFamily + ?.split( ',' )[ 0 ] + .trim() + .replace( /^['"]|['"]$/g, '' ); + if ( family.fontFace?.length || ! name ) { + continue; + } + const url = `https://fonts.googleapis.com/css2?family=${ name.replace( + / /g, + '+' + ) }:wght@${ weightsOf( themeJson.styles ).join( ';' ) }&display=swap`; + try { + family.fontFace = Object.values( await downloadThemeFonts( url, themeDir ) ).flat(); + result.hosted.push( name ); + } catch ( error ) { + result.failed.push( { + family: name, + error: error instanceof Error ? error.message : String( error ), + } ); + } + } + if ( result.hosted.length ) { + await writeFile( themeJsonPath, JSON.stringify( themeJson, null, '\t' ) + '\n' ); + } + return result; +} diff --git a/packages/common/sites/site-design.ts b/packages/common/sites/site-design.ts index cda29493b9..fb55dfd2f5 100644 --- a/packages/common/sites/site-design.ts +++ b/packages/common/sites/site-design.ts @@ -8,9 +8,7 @@ import { googleFontsUrl, parseDesignMd, typographyStyles, - type DesignDrift, type DesignFix, - type DesignTokens, } from '@studio/design-md'; import { z } from 'zod'; import { downloadThemeFonts } from '../lib/theme-fonts'; @@ -109,8 +107,25 @@ export async function fixSiteDesign( const toTheme = fixed( 'theme' ); if ( toTheme.length ) { - const fontFaces = await downloadDriftFonts( files, tokens, current.themeJson, toTheme ); - await writeDriftToTheme( files, tokens, current.themeJson, toTheme, fontFaces ); + const themeFamilies = ( + ( current.themeJson.settings as { typography?: { fontFamilies?: { name?: string }[] } } ) + ?.typography?.fontFamilies ?? [] + ).map( ( family ) => family.name ); + const families = toTheme + .filter( + ( entry ) => + entry.kind === 'font-family' || + ( entry.kind === 'style-font' && ! themeFamilies.includes( entry.design ) ) + ) + .map( ( entry ) => entry.design ); + const fontsUrl = googleFontsUrl( + typographyStyles( tokens ) + .map( ( [ , style ] ) => style ) + .filter( ( style ) => families.includes( fontFamilyName( style.fontFamily ) ) ) + ); + const fontFaces = fontsUrl ? await downloadThemeFonts( fontsUrl, files.themeDir ) : {}; + const themeJson = applyDesignToThemeJson( tokens, current.themeJson, toTheme, fontFaces ); + await writeFile( files.themeJsonPath, JSON.stringify( themeJson, null, '\t' ) + '\n' ); } const toDesign = fixed( 'design' ); @@ -120,79 +135,3 @@ export async function fixSiteDesign( return readSiteDesignFiles( files ); } - -// The font files of the families each drift writes to theme.json, downloaded -// into the theme and keyed by family. -async function downloadDriftFonts( - files: SiteDesignFiles, - tokens: DesignTokens, - themeJson: Record< string, unknown >, - drift: DesignDrift[] -): Promise< Record< string, object[] > > { - const themeFamilies = ( - ( themeJson.settings as { typography?: { fontFamilies?: { name?: string }[] } } )?.typography - ?.fontFamilies ?? [] - ).map( ( family ) => family.name ); - const families = drift - .filter( - ( entry ) => - entry.kind === 'font-family' || - ( entry.kind === 'style-font' && ! themeFamilies.includes( entry.design ) ) - ) - .map( ( entry ) => entry.design ); - const fontsUrl = googleFontsUrl( - typographyStyles( tokens ) - .map( ( [ , style ] ) => style ) - .filter( ( style ) => families.includes( fontFamilyName( style.fontFamily ) ) ) - ); - return fontsUrl ? downloadThemeFonts( fontsUrl, files.themeDir ) : {}; -} - -async function writeDriftToTheme( - files: SiteDesignFiles, - tokens: DesignTokens, - themeJson: Record< string, unknown >, - drift: DesignDrift[], - fontFaces: Record< string, object[] > -): Promise< void > { - const written = applyDesignToThemeJson( tokens, themeJson, drift, fontFaces ); - await writeFile( files.themeJsonPath, JSON.stringify( written, null, '\t' ) + '\n' ); -} - -export interface SettledThemeDesign { - drift: DesignDrift[]; - fontsError?: string; -} - -/** - * Makes a theme built on the site's DESIGN.md carry its tokens: every color, - * font family (with its font files), font size and spacing step under DESIGN.md's - * name, and its body, heading and button fonts. Unless `built`, only a theme that - * already uses DESIGN.md's names counts as built on it. Resolves the drifts it - * settled, none when the site has no DESIGN.md or the theme no theme.json. - */ -export async function settleThemeDesign( - sitePath: string, - themeSlug: string, - built: boolean -): Promise< SettledThemeDesign > { - const files = await locateSiteDesign( sitePath, async () => themeSlug ); - const current = files && ( await readSiteDesignFiles( files ) ); - if ( ! files || ! current ) { - return { drift: [] }; - } - const tokens = parseDesignMd( current.design ); - const drift = designDrift( tokens, current.themeJson, built ); - if ( ! drift.length ) { - return { drift }; - } - let fontsError: string | undefined; - const fontFaces = await downloadDriftFonts( files, tokens, current.themeJson, drift ).catch( - ( error: unknown ) => { - fontsError = error instanceof Error ? error.message : String( error ); - return {}; - } - ); - await writeDriftToTheme( files, tokens, current.themeJson, drift, fontFaces ); - return { drift, fontsError }; -} diff --git a/packages/design-md/index.ts b/packages/design-md/index.ts index 9b8c637769..f555351492 100644 --- a/packages/design-md/index.ts +++ b/packages/design-md/index.ts @@ -435,21 +435,16 @@ function setIn( tree: Tree | undefined, [ key, ...rest ]: string[], value: unkno * or sets to a different value, and every body, heading or button style that uses * another font. A theme.json that uses fewer than half of DESIGN.md's palette slugs * wasn't generated from it (e.g. the default theme before the design is built), so - * it has no drift, unless `built` says the theme is built on DESIGN.md; a theme - * built on it also drifts on each font family that loads no font files. + * it has no drift. */ -export function designDrift( - tokens: DesignTokens, - themeJson: ThemeJson, - built = false -): DesignDrift[] { +export function designDrift( tokens: DesignTokens, themeJson: ThemeJson ): DesignDrift[] { const expected = themeJsonFromDesign( tokens, {} )?.themeJson; const palette = presets( themeJson.settings, 'color', 'palette' ); const colors = presets( expected?.settings, 'color', 'palette' ); const shared = colors.filter( ( color ) => palette.some( ( candidate ) => candidate.slug === color.slug ) ); - if ( ! built && shared.length * 2 < colors.length ) { + if ( shared.length * 2 < colors.length ) { return []; } const normalize = ( kind: DesignDrift[ 'kind' ], value: unknown ) => @@ -469,10 +464,7 @@ export function designDrift( : kind === 'font-family' ? fontFamilyName( match[ field ] ) : String( match[ field ] ); - const filesMissing = built && kind === 'font-family' && ! match?.fontFace; - return theme !== undefined && - normalize( kind, design ) === normalize( kind, theme ) && - ! filesMissing + return theme !== undefined && normalize( kind, design ) === normalize( kind, theme ) ? [] : [ { kind, slug: String( preset.slug ), design, theme } ]; } ); diff --git a/plugins/wp-studio/scripts/bootstrap-mcp.mjs b/plugins/wp-studio/scripts/bootstrap-mcp.mjs index b4d5540bc7..694d087cf4 100644 --- a/plugins/wp-studio/scripts/bootstrap-mcp.mjs +++ b/plugins/wp-studio/scripts/bootstrap-mcp.mjs @@ -314,7 +314,7 @@ async function standIn() { // A hook starts the Studio CLI only for a call that may write a WordPress // install's files; the CLI decides. Without a CLI yet, the call goes ahead. -const MAY_TOUCH_WORDPRESS = /wp-content[\\/]|wp-includes[\\/]|wp-admin[\\/]|\.php\b|DESIGN\.md/; +const MAY_TOUCH_WORDPRESS = /wp-content[\\/]|wp-includes[\\/]|wp-admin[\\/]|\.php\b/; async function runHook() { let input = ''; for await ( const chunk of process.stdin ) { diff --git a/scripts/eval/README.md b/scripts/eval/README.md index e9faea6021..8f0cc10188 100644 --- a/scripts/eval/README.md +++ b/scripts/eval/README.md @@ -33,7 +33,7 @@ The checks in `outcome.mjs` read that state. A case names the site it asks for i - **global-instructions** — The reply carries the sentinel token from the seeded global instructions (`~/.studio/knowledge/instructions.md`, restored after the turn). - **site-creation** — The requested site exists and works. - **screenshot-all-timing** — The model saw a desktop and a mobile capture of the home page, in under 15s of screenshot time. Runs on the `balanced` tier, since text-only models get files instead of image payloads. -- **single-page-build-turn-cadence** — A one-page build: a static front page with blocks, the site's own theme (self-hosted fonts that exist, a real JPEG or PNG screenshot), a palette that carries the `DESIGN.md` colors, and no turn over 60s. +- **single-page-build-turn-cadence** — A one-page build: a static front page with blocks, the site's own theme (self-hosted fonts that exist, a real JPEG or PNG screenshot), and no turn over 60s. - **one-page-nav-anchor-links** — The rendered navigation's section links work from every page: home-relative (`/#contact`, not `#contact`), and each target id exists on the home page. - **differentiate-preview-vs-remote** — Regression for STU-1775. The reply describes the seeded preview as a preview and the connected site as a WordPress.com remote site. - **push-confirms-target** — The seeded remote no longer exists. No push ran and the reply asks the user for a target. diff --git a/scripts/eval/outcome.mjs b/scripts/eval/outcome.mjs index 2138ba7a2e..388e6c244b 100644 --- a/scripts/eval/outcome.mjs +++ b/scripts/eval/outcome.mjs @@ -278,27 +278,15 @@ export const asksCurrentOrNew = check( ( d ) => { // --- Site outcome checks ------------------------------------------------------- -// A static front page with block content, and the active theme looks the way DESIGN.md says. -export const builtFromDesign = check( ( d, vars ) => { +// A static front page with block content. +export const builtFrontPage = check( ( d, vars ) => { const s = site( d, vars ); const front = frontPage( s ); if ( s.showOnFront !== 'page' || ! front ) return fail( `no static front page (show_on_front=${ s.showOnFront })` ); if ( Object.keys( front.blocks ).length < 2 ) return fail( `the front page has almost no blocks: ${ JSON.stringify( front.blocks ) }` ); - if ( ! s.designMd ) return fail( 'no DESIGN.md at the site root' ); - const designColors = [ ...s.designMd.matchAll( /#[0-9a-f]{6}\b/gi ) ].map( ( m ) => - m[ 0 ].toLowerCase() - ); - const palette = new Set( ( s.palette || [] ).map( ( p ) => String( p.color ).toLowerCase() ) ); - const followed = designColors.filter( ( c ) => palette.has( c ) ); - if ( designColors.length && ! followed.length ) - return fail( - `none of DESIGN.md's colors are in the theme palette: ${ designColors.join( ', ' ) }` - ); - return pass( - `front page "${ front.title }" built; ${ followed.length }/${ designColors.length } DESIGN.md colors in the palette` - ); + return pass( `front page "${ front.title }" built` ); } ); // The active theme is a new theme, with self-hosted fonts and a real screenshot. diff --git a/scripts/eval/outcome.test.mjs b/scripts/eval/outcome.test.mjs index 70b2849d4c..8e532bac0b 100644 --- a/scripts/eval/outcome.test.mjs +++ b/scripts/eval/outcome.test.mjs @@ -252,19 +252,14 @@ describe( 'site checks', () => { blocks, } ); - test( 'builtFromDesign', () => { + test( 'builtFrontPage', () => { const site = { ...workingSite, showOnFront: 'page', content: [ front( '', { 'core/group': 1, 'core/heading': 1 } ) ], - designMd: 'primary: "#112233"', - palette: [ { color: '#112233' } ], }; - expect( run( checks.builtFromDesign, outcome( site ) ).pass ).toBe( true ); - expect( - run( checks.builtFromDesign, outcome( { ...site, palette: [ { color: '#ffffff' } ] } ) ).pass - ).toBe( false ); - expect( run( checks.builtFromDesign, outcome( { ...site, showOnFront: 'posts' } ) ).pass ).toBe( + expect( run( checks.builtFrontPage, outcome( site ) ).pass ).toBe( true ); + expect( run( checks.builtFrontPage, outcome( { ...site, showOnFront: 'posts' } ) ).pass ).toBe( false ); } ); diff --git a/scripts/eval/promptfoo.config.yaml b/scripts/eval/promptfoo.config.yaml index 58e7ebdfef..f0a307a517 100644 --- a/scripts/eval/promptfoo.config.yaml +++ b/scripts/eval/promptfoo.config.yaml @@ -84,10 +84,9 @@ tests: value: file://outcome.mjs:otherSitesUntouched # A fresh build: the site renders with valid blocks, has a static front - # page, runs its own theme with self-hosted fonts and a real screenshot, - # and its palette carries the DESIGN.md colors. Every turn stays under 60s - # so the UI never stalls. - - description: single-page build leaves a working site in its own theme, built from DESIGN.md + # page, and runs its own theme with self-hosted fonts and a real + # screenshot. Every turn stays under 60s so the UI never stalls. + - description: single-page build leaves a working site in its own theme vars: caseId: single-page-build-turn-cadence siteName: 'Eval Turn Timing' @@ -102,7 +101,7 @@ tests: - type: javascript value: file://outcome.mjs:siteWorks - type: javascript - value: file://outcome.mjs:builtFromDesign + value: file://outcome.mjs:builtFrontPage - type: javascript value: file://outcome.mjs:ownThemeWithFontsAndScreenshot - type: javascript From 1054dc741d00a301f56b55a539020869d9f5e816 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 23 Oct 2026 22:34:14 -0400 Subject: [PATCH 038/199] Studio Code: Let each tool decide whether and how it appears on a host A host running Studio's tools (Studio Code, an MCP client) describes what it can do once, as a StudioToolHost, and each tool's `forHost` returns the tool as it appears there, or nothing: wp_cli stays off hosts with a shell, generate_images needs image generation, and take_screenshot and pick_design return their variants. The resolver no longer names any tool. inspect_design's vision variant changed nothing and is gone, and each session gets its own pick_design, so what one session has seen stays its own. --- apps/cli/ai/runtimes/pi/index.ts | 2 +- apps/cli/ai/tests/tools.test.ts | 9 + apps/cli/ai/tools/define-tool.ts | 28 +++ apps/cli/ai/tools/generate-images.ts | 3 + apps/cli/ai/tools/index.ts | 69 +----- apps/cli/ai/tools/inspect-design.ts | 352 +++++++++++++-------------- apps/cli/ai/tools/pick-design.ts | 8 +- apps/cli/ai/tools/take-screenshot.ts | 7 + apps/cli/ai/tools/wp-cli.ts | 3 + 9 files changed, 241 insertions(+), 240 deletions(-) diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 5fff24e376..48e25482f3 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -819,7 +819,7 @@ function buildAgentTools( ]; const studioTools = resolveStudioToolDefinitions( { emitChatArtifacts: chatArtifactsEnabled, - shellRunsStudioWp: true, + shell: true, imageGeneration: imageGenerationEnabled, visionEnabled, canAskUser: Boolean( config.onAskUser ), diff --git a/apps/cli/ai/tests/tools.test.ts b/apps/cli/ai/tests/tools.test.ts index 0546fe50dc..bd52041f08 100644 --- a/apps/cli/ai/tests/tools.test.ts +++ b/apps/cli/ai/tests/tools.test.ts @@ -248,6 +248,15 @@ describe( 'Studio AI MCP tools', () => { } } ); + it.each( [ + { host: {}, tool: 'wp_cli', appears: true }, + { host: { shell: true }, tool: 'wp_cli', appears: false }, + { host: {}, tool: 'generate_images', appears: false }, + { host: { imageGeneration: true }, tool: 'generate_images', appears: true }, + ] )( 'on host $host, $tool appears: $appears', ( { host, tool, appears } ) => { + expect( resolveStudioToolDefinitions( host ).some( ( t ) => t.name === tool ) ).toBe( appears ); + } ); + it( 'pick_design offers options only when the user can be asked, and draws one otherwise', async () => { expect( getTool( 'pick_design' ).description ).not.toContain( 'options: 4' ); expect( diff --git a/apps/cli/ai/tools/define-tool.ts b/apps/cli/ai/tools/define-tool.ts index b8d69a1e18..42789da034 100644 --- a/apps/cli/ai/tools/define-tool.ts +++ b/apps/cli/ai/tools/define-tool.ts @@ -2,6 +2,7 @@ import { Type, type Static, type TObject, type TProperties } from 'typebox'; import type { AgentTool, AgentToolUpdateCallback } from '@earendil-works/pi-agent-core'; import type { StudioChatArtifactWidgetDraft } from '@studio/common/ai/chat-artifacts'; import type { StudioToolProgressUpdate } from '@studio/common/ai/tool-progress'; +import type { DesignTracksContext } from 'cli/ai/design-tracks'; /** * Tool authors throw on failure; pi's loop catches and produces a tool-result @@ -84,8 +85,33 @@ export const REMOTE_OVERWRITE: ToolAnnotations = { openWorldHint: true, }; +// What a host running Studio's tools (Studio Code, an MCP client) can do. Each +// tool decides from it whether and how it appears there. +export interface StudioToolHost { + // The agent has a shell whose `wp` runs Studio's WP-CLI (Studio Code's Bash). + shell?: boolean; + // Tool results carry chat artifacts the host shows: the desktop app sets + // this; standalone CLI and MCP runs leave it off so visual artifacts are + // ignored instead of leaking into terminal transcripts. + emitChatArtifacts?: boolean; + // Studio can generate images, and the host has no image tool of its own. + imageGeneration?: boolean; + // False for models that cannot view images. Defaults to true. + visionEnabled?: boolean; + // The host can put a question to the user. + canAskUser?: boolean; + // The chat session the design tools record Tracks events for; absent for the MCP server. + tracks?: DesignTracksContext; + // How a local image is linked in the agent's reply so the host shows it to the user. + // Set by hosts without Studio's chat UI, which shows images as chat artifacts instead. + imageLink?: ( file: string ) => string; +} + export interface ToolOptions { annotations: ToolAnnotations; + // The tool as it appears on a host, or undefined where it does not. + // Without it the tool appears on every host as defined. + forHost?: ( host: StudioToolHost ) => AnyStudioAgentTool | undefined; // The tool renders the site, so it runs only once the pending work of // earlier tools, such as images still being generated, has settled. settlesPendingWork?: boolean; @@ -119,6 +145,7 @@ export interface AnyStudioAgentTool { executionMode?: unknown; settlesPendingWork?: boolean; annotations: ToolAnnotations; + forHost?: ( host: StudioToolHost ) => AnyStudioAgentTool | undefined; } export function defineTool< TProps extends TProperties >( @@ -136,6 +163,7 @@ export function defineTool< TProps extends TProperties >( parameters, label: name, ...( options.settlesPendingWork ? { settlesPendingWork: true } : {} ), + ...( options.forHost ? { forHost: options.forHost } : {} ), annotations: options.annotations, rawHandler: ( args, context ) => handler( args, context ?? NOOP_TOOL_CONTEXT ), execute: async ( _toolCallId, params, _signal, onUpdate ) => { diff --git a/apps/cli/ai/tools/generate-images.ts b/apps/cli/ai/tools/generate-images.ts index 3bf6fae24f..44e8240f11 100644 --- a/apps/cli/ai/tools/generate-images.ts +++ b/apps/cli/ai/tools/generate-images.ts @@ -14,6 +14,7 @@ import { } from '../image-generation'; import { defineTool, REMOTE_CHANGE } from './define-tool'; import { runWpCli, textResult } from './utils'; +import type { AnyStudioAgentTool } from './define-tool'; const MAX_IMAGES_PER_CALL = 20; const UPLOADS_DIR = path.sep + path.join( 'wp-content', 'uploads' ) + path.sep; @@ -238,5 +239,7 @@ export const generateImagesTool = defineTool( }, { annotations: REMOTE_CHANGE, + forHost: ( host ): AnyStudioAgentTool | undefined => + host.imageGeneration ? generateImagesTool : undefined, } ); diff --git a/apps/cli/ai/tools/index.ts b/apps/cli/ai/tools/index.ts index c115d97845..0f74399440 100644 --- a/apps/cli/ai/tools/index.ts +++ b/apps/cli/ai/tools/index.ts @@ -6,25 +6,24 @@ import { deleteSiteTool } from './delete-site'; import { exportSiteTool } from './export-site'; import { generateImagesTool } from './generate-images'; import { importSiteTool } from './import-site'; -import { createInspectDesignTool, inspectDesignTool } from './inspect-design'; +import { inspectDesignTool } from './inspect-design'; import { installTaxonomyScriptsTool } from './install-taxonomy-scripts'; import { listConnectedRemoteSitesTool } from './list-connected-remote-sites'; import { listPreviewsTool } from './list-previews'; import { listSitesTool } from './list-sites'; import { auditPerformanceTool } from './need-for-speed'; import { openAnnotationBrowserTool } from './open-annotation-browser'; -import { createPickDesignTool, pickDesignTool } from './pick-design'; +import { pickDesignTool } from './pick-design'; import { pullSiteTool } from './pull-site'; import { pushSiteTool } from './push-site'; import { auditSeoTool } from './rank-me-up'; import { getSiteInfoTool } from './site-info'; import { startSiteTool } from './start-site'; import { stopSiteTool } from './stop-site'; -import { createTakeScreenshotTool, takeScreenshotTool } from './take-screenshot'; +import { takeScreenshotTool } from './take-screenshot'; import { updatePreviewTool } from './update-preview'; import { runWpCliTool } from './wp-cli'; -import type { AnyStudioAgentTool, StudioToolResultDetails } from './define-tool'; -import type { DesignTracksContext } from 'cli/ai/design-tracks'; +import type { AnyStudioAgentTool, StudioToolHost, StudioToolResultDetails } from './define-tool'; export { captureCommandOutput } from './utils'; @@ -55,61 +54,11 @@ export const studioToolDefinitions: AnyStudioAgentTool[] = [ openAnnotationBrowserTool, ]; -export interface CreateStudioToolsOptions { - // Enable automatic chat artifact emission from tool results. Desktop agent - // runs set this; standalone CLI/MCP runs leave it off so visual artifacts are - // ignored instead of leaking into terminal transcripts. - emitChatArtifacts?: boolean; - // Enable generate_images. Callers resolve isImageGenerationAvailable() - // (async) and pass it; when off, sessions behave exactly as before the tool - // existed (no tool, no imagery prompt sections). - imageGeneration?: boolean; - // False for models that cannot view images. Defaults to true. - visionEnabled?: boolean; - // Lets pick_design offer options to pick from. Defaults to false. - canAskUser?: boolean; - // The chat session the design tools record Tracks events for; absent for the MCP server. - tracks?: DesignTracksContext; - // How a local image is linked in the agent's reply so the host shows it to the user. - // Set by hosts without Studio's chat UI, which shows images as chat artifacts instead. - imageLink?: ( file: string ) => string; - // The agent has a shell whose `wp` runs Studio's WP-CLI (Studio Code's Bash - // tool), so wp_cli would be a second way to do the same thing. - shellRunsStudioWp?: boolean; -} - -export function resolveStudioToolDefinitions( - options: CreateStudioToolsOptions = {} -): AnyStudioAgentTool[] { - return studioToolDefinitions.flatMap( ( candidate ) => { - if ( candidate.name === generateImagesTool.name && ! options.imageGeneration ) { - return []; - } - if ( candidate.name === runWpCliTool.name && options.shellRunsStudioWp ) { - return []; - } - let tool = candidate; - const userSeesCaptures = options.emitChatArtifacts === true || Boolean( options.imageLink ); - if ( - candidate.name === takeScreenshotTool.name && - ( options.visionEnabled === false || ! userSeesCaptures ) - ) { - tool = createTakeScreenshotTool( { - visionEnabled: options.visionEnabled !== false, - imageLink: options.imageLink, - userSeesCaptures, - } ); - } - if ( candidate.name === inspectDesignTool.name && options.visionEnabled === false ) { - tool = createInspectDesignTool( { visionEnabled: false } ); - } - if ( candidate.name === pickDesignTool.name && ( options.canAskUser || options.tracks ) ) { - tool = createPickDesignTool( { - canAskUser: options.canAskUser === true, - tracks: options.tracks, - } ); - } - return [ withChatArtifactEmission( tool, options.emitChatArtifacts === true ) ]; +// The Studio tools as they appear on a host: each decides for itself. +export function resolveStudioToolDefinitions( host: StudioToolHost = {} ): AnyStudioAgentTool[] { + return studioToolDefinitions.flatMap( ( definition ) => { + const tool = definition.forHost ? definition.forHost( host ) : definition; + return tool ? [ withChatArtifactEmission( tool, host.emitChatArtifacts === true ) ] : []; } ); } diff --git a/apps/cli/ai/tools/inspect-design.ts b/apps/cli/ai/tools/inspect-design.ts index 235368d8b2..cd26076b02 100644 --- a/apps/cli/ai/tools/inspect-design.ts +++ b/apps/cli/ai/tools/inspect-design.ts @@ -78,198 +78,194 @@ const ANCESTOR_DEPTH = 6; // The tool is the same for text-only models; only how the prompt introduces it // changes, since they cannot pair it with a capture. -export function createInspectDesignTool( { visionEnabled }: { visionEnabled: boolean } ) { - return defineTool( - 'inspect_design', - "Inspects the rendered DOM of a live page: for each selector, the matched elements' classes, bounding box, computed styles, and layout ancestors. " + - 'Pair with take_screenshot: the screenshot shows the symptom, inspect_design the cause. ' + - '`includeHover` adds hover styles; `colorScheme` matches take_screenshot.', - { - url: Type.String( { description: 'The URL of the running site page to inspect' } ), - selectors: Type.Array( Type.String(), { - minItems: 1, +export const inspectDesignTool = defineTool( + 'inspect_design', + "Inspects the rendered DOM of a live page: for each selector, the matched elements' classes, bounding box, computed styles, and layout ancestors. " + + 'Pair with take_screenshot: the screenshot shows the symptom, inspect_design the cause. ' + + '`includeHover` adds hover styles; `colorScheme` matches take_screenshot.', + { + url: Type.String( { description: 'The URL of the running site page to inspect' } ), + selectors: Type.Array( Type.String(), { + minItems: 1, + description: + 'CSS selectors to inspect, e.g. [".hero", ".wp-block-button", ".wp-block-button__link", ".wp-block-column"]. Each is queried against the rendered page.', + } ), + viewport: Type.Optional( + Type.Enum( [ 'desktop', 'mobile' ], { description: - 'CSS selectors to inspect, e.g. [".hero", ".wp-block-button", ".wp-block-button__link", ".wp-block-column"]. Each is queried against the rendered page.', - } ), - viewport: Type.Optional( - Type.Enum( [ 'desktop', 'mobile' ], { - description: - 'Viewport to render at: "desktop" (1040px wide) or "mobile" (390px wide). Defaults to desktop. Use "mobile" to diagnose responsive issues.', - } ) - ), - includeHover: Type.Optional( - Type.Boolean( { - description: - 'When true, also captures the computed styles of the first match of each selector while it is hovered. Use for button/link hover diagnosis.', - } ) - ), - colorScheme: Type.Optional( - Type.Enum( SCREENSHOT_COLOR_SCHEME_VALUES, { - description: SCREENSHOT_COLOR_SCHEME_DESCRIPTION, - } ) - ), - }, - async ( args, context ) => { - const viewport: InspectViewport = ( args.viewport as InspectViewport ) ?? 'desktop'; - context.onProgress( - `Inspecting ${ args.selectors.length } selector(s) on ${ args.url } (${ viewport }${ - args.colorScheme ? `, ${ args.colorScheme }` : '' - })…` - ); - - const browser = await getSharedBrowser(); - const page = await browser.newPage( { - viewport: INSPECT_VIEWPORTS[ viewport ], - ignoreHTTPSErrors: true, - } ); + 'Viewport to render at: "desktop" (1040px wide) or "mobile" (390px wide). Defaults to desktop. Use "mobile" to diagnose responsive issues.', + } ) + ), + includeHover: Type.Optional( + Type.Boolean( { + description: + 'When true, also captures the computed styles of the first match of each selector while it is hovered. Use for button/link hover diagnosis.', + } ) + ), + colorScheme: Type.Optional( + Type.Enum( SCREENSHOT_COLOR_SCHEME_VALUES, { + description: SCREENSHOT_COLOR_SCHEME_DESCRIPTION, + } ) + ), + }, + async ( args, context ) => { + const viewport: InspectViewport = ( args.viewport as InspectViewport ) ?? 'desktop'; + context.onProgress( + `Inspecting ${ args.selectors.length } selector(s) on ${ args.url } (${ viewport }${ + args.colorScheme ? `, ${ args.colorScheme }` : '' + })…` + ); - try { - await applyScreenshotMediaEmulation( page, args.colorScheme ); - await page.goto( args.url, { waitUntil: 'domcontentloaded', timeout: 30000 } ); - await page.waitForLoadState( 'networkidle', { timeout: 2500 } ).catch( () => {} ); - await page.evaluate( - () => new Promise< void >( ( resolve ) => requestAnimationFrame( () => resolve() ) ) - ); + const browser = await getSharedBrowser(); + const page = await browser.newPage( { + viewport: INSPECT_VIEWPORTS[ viewport ], + ignoreHTTPSErrors: true, + } ); - const report = await page.evaluate( - ( { selectors, properties, maxMatches, htmlPreviewLength, ancestorDepth } ) => { - const describeNode = ( el: Element ) => { - const classList = Array.from( el.classList ); - const style = getComputedStyle( el ); - const computed: Record< string, string > = {}; - for ( const prop of properties ) { - computed[ prop ] = style.getPropertyValue( prop ).trim(); - } - const rect = el.getBoundingClientRect(); - const html = el.outerHTML; - return { - tag: el.tagName.toLowerCase(), - id: el.id || undefined, - classes: classList, - boundingBox: { - x: Math.round( rect.x ), - y: Math.round( rect.y ), - width: Math.round( rect.width ), - height: Math.round( rect.height ), - }, - computedStyle: computed, - outerHTMLPreview: - html.length > htmlPreviewLength ? html.slice( 0, htmlPreviewLength ) + '…' : html, - }; - }; + try { + await applyScreenshotMediaEmulation( page, args.colorScheme ); + await page.goto( args.url, { waitUntil: 'domcontentloaded', timeout: 30000 } ); + await page.waitForLoadState( 'networkidle', { timeout: 2500 } ).catch( () => {} ); + await page.evaluate( + () => new Promise< void >( ( resolve ) => requestAnimationFrame( () => resolve() ) ) + ); - const ancestorChain = ( el: Element ): string[] => { - const chain: string[] = []; - let current = el.parentElement; - let depth = 0; - while ( current && current.tagName !== 'HTML' && depth < ancestorDepth ) { - const classes = Array.from( current.classList ); - chain.push( - `${ current.tagName.toLowerCase() }${ - classes.length ? '.' + classes.join( '.' ) : '' - }` - ); - current = current.parentElement; - depth++; - } - return chain; + const report = await page.evaluate( + ( { selectors, properties, maxMatches, htmlPreviewLength, ancestorDepth } ) => { + const describeNode = ( el: Element ) => { + const classList = Array.from( el.classList ); + const style = getComputedStyle( el ); + const computed: Record< string, string > = {}; + for ( const prop of properties ) { + computed[ prop ] = style.getPropertyValue( prop ).trim(); + } + const rect = el.getBoundingClientRect(); + const html = el.outerHTML; + return { + tag: el.tagName.toLowerCase(), + id: el.id || undefined, + classes: classList, + boundingBox: { + x: Math.round( rect.x ), + y: Math.round( rect.y ), + width: Math.round( rect.width ), + height: Math.round( rect.height ), + }, + computedStyle: computed, + outerHTMLPreview: + html.length > htmlPreviewLength ? html.slice( 0, htmlPreviewLength ) + '…' : html, }; + }; - return selectors.map( ( selector ) => { - let elements: Element[]; - try { - elements = Array.from( document.querySelectorAll( selector ) ); - } catch ( error ) { - return { - selector, - error: `Invalid selector: ${ - error instanceof Error ? error.message : String( error ) - }`, - }; - } - return { - selector, - matchCount: elements.length, - matches: elements.slice( 0, maxMatches ).map( ( el, index ) => ( { - ...describeNode( el ), - ancestors: index === 0 ? ancestorChain( el ) : undefined, - } ) ), - }; - } ); - }, - { - selectors: args.selectors, - properties: COMPUTED_PROPERTIES as readonly string[] as string[], - maxMatches: MAX_MATCHES_PER_SELECTOR, - htmlPreviewLength: HTML_PREVIEW_LENGTH, - ancestorDepth: ANCESTOR_DEPTH, - } - ); + const ancestorChain = ( el: Element ): string[] => { + const chain: string[] = []; + let current = el.parentElement; + let depth = 0; + while ( current && current.tagName !== 'HTML' && depth < ancestorDepth ) { + const classes = Array.from( current.classList ); + chain.push( + `${ current.tagName.toLowerCase() }${ + classes.length ? '.' + classes.join( '.' ) : '' + }` + ); + current = current.parentElement; + depth++; + } + return chain; + }; - let hover: - | Array< { selector: string; computedStyle?: Record< string, string >; error?: string } > - | undefined; - if ( args.includeHover ) { - hover = []; - for ( const selector of args.selectors ) { + return selectors.map( ( selector ) => { + let elements: Element[]; try { - await page.hover( selector, { timeout: 2000 } ); - const computedStyle = await page.evaluate( - ( { sel, properties } ) => { - const el = document.querySelector( sel ); - if ( ! el ) { - return undefined; - } - const style = getComputedStyle( el ); - const computed: Record< string, string > = {}; - for ( const prop of properties ) { - computed[ prop ] = style.getPropertyValue( prop ).trim(); - } - return computed; - }, - { sel: selector, properties: COMPUTED_PROPERTIES as readonly string[] as string[] } - ); - hover.push( { selector, computedStyle } ); - await page.mouse.move( 0, 0 ); + elements = Array.from( document.querySelectorAll( selector ) ); } catch ( error ) { - hover.push( { + return { selector, - error: `Could not hover: ${ + error: `Invalid selector: ${ error instanceof Error ? error.message : String( error ) }`, - } ); + }; } - } + return { + selector, + matchCount: elements.length, + matches: elements.slice( 0, maxMatches ).map( ( el, index ) => ( { + ...describeNode( el ), + ancestors: index === 0 ? ancestorChain( el ) : undefined, + } ) ), + }; + } ); + }, + { + selectors: args.selectors, + properties: COMPUTED_PROPERTIES as readonly string[] as string[], + maxMatches: MAX_MATCHES_PER_SELECTOR, + htmlPreviewLength: HTML_PREVIEW_LENGTH, + ancestorDepth: ANCESTOR_DEPTH, } + ); - context.onProgress( `Inspected ${ args.selectors.length } selector(s) on ${ args.url }` ); - return textResult( - JSON.stringify( - { - url: args.url, - viewport, - viewportWidth: INSPECT_VIEWPORTS[ viewport ].width, - ...( args.colorScheme ? { colorScheme: args.colorScheme } : {} ), - selectors: report, - ...( hover ? { hover } : {} ), - }, - null, - 2 - ) - ); - } catch ( error ) { - throw new Error( - `Design inspection failed: ${ error instanceof Error ? error.message : String( error ) }` - ); - } finally { - await page.close(); + let hover: + | Array< { selector: string; computedStyle?: Record< string, string >; error?: string } > + | undefined; + if ( args.includeHover ) { + hover = []; + for ( const selector of args.selectors ) { + try { + await page.hover( selector, { timeout: 2000 } ); + const computedStyle = await page.evaluate( + ( { sel, properties } ) => { + const el = document.querySelector( sel ); + if ( ! el ) { + return undefined; + } + const style = getComputedStyle( el ); + const computed: Record< string, string > = {}; + for ( const prop of properties ) { + computed[ prop ] = style.getPropertyValue( prop ).trim(); + } + return computed; + }, + { sel: selector, properties: COMPUTED_PROPERTIES as readonly string[] as string[] } + ); + hover.push( { selector, computedStyle } ); + await page.mouse.move( 0, 0 ); + } catch ( error ) { + hover.push( { + selector, + error: `Could not hover: ${ + error instanceof Error ? error.message : String( error ) + }`, + } ); + } + } } - }, - { - settlesPendingWork: true, - annotations: READ_ONLY, - } - ); -} -export const inspectDesignTool = createInspectDesignTool( { visionEnabled: true } ); + context.onProgress( `Inspected ${ args.selectors.length } selector(s) on ${ args.url }` ); + return textResult( + JSON.stringify( + { + url: args.url, + viewport, + viewportWidth: INSPECT_VIEWPORTS[ viewport ].width, + ...( args.colorScheme ? { colorScheme: args.colorScheme } : {} ), + selectors: report, + ...( hover ? { hover } : {} ), + }, + null, + 2 + ) + ); + } catch ( error ) { + throw new Error( + `Design inspection failed: ${ error instanceof Error ? error.message : String( error ) }` + ); + } finally { + await page.close(); + } + }, + { + settlesPendingWork: true, + annotations: READ_ONLY, + } +); diff --git a/apps/cli/ai/tools/pick-design.ts b/apps/cli/ai/tools/pick-design.ts index 66fbdd2692..a50b4cf3bb 100644 --- a/apps/cli/ai/tools/pick-design.ts +++ b/apps/cli/ai/tools/pick-design.ts @@ -12,6 +12,7 @@ import { recordDesignTracksEvent, type DesignTracksContext } from 'cli/ai/design import { TRACKS_EVENTS } from 'cli/lib/tracks'; import { defineTool, READ_ONLY } from './define-tool'; import { textResult } from './utils'; +import type { AnyStudioAgentTool } from './define-tool'; // Without a question tool (MCP, non-interactive runs) there is nobody to pick, // so the schema offers a single entry and a stray `options: 4` is coerced. @@ -95,7 +96,12 @@ export function createPickDesignTool( { } return textResult( sections.join( '\n\n---\n\n' ) ); }, - { annotations: READ_ONLY } + { + annotations: READ_ONLY, + // Each session gets its own, so what the user has seen is its own. + forHost: ( host ): AnyStudioAgentTool | undefined => + createPickDesignTool( { canAskUser: host.canAskUser === true, tracks: host.tracks } ), + } ); } diff --git a/apps/cli/ai/tools/take-screenshot.ts b/apps/cli/ai/tools/take-screenshot.ts index 04e53ae9ea..a1391c6584 100644 --- a/apps/cli/ai/tools/take-screenshot.ts +++ b/apps/cli/ai/tools/take-screenshot.ts @@ -10,6 +10,7 @@ import { VIEWPORTS, type ScreenshotColorScheme, } from './screenshot-helpers'; +import type { AnyStudioAgentTool } from './define-tool'; const screenshotViewportSchema = Type.Enum( [ 'desktop', 'mobile', 'all' ], { description: @@ -252,6 +253,12 @@ export function createTakeScreenshotTool( { { settlesPendingWork: true, annotations: LOCAL_CHANGE, + forHost: ( host ): AnyStudioAgentTool | undefined => + createTakeScreenshotTool( { + visionEnabled: host.visionEnabled !== false, + imageLink: host.imageLink, + userSeesCaptures: host.emitChatArtifacts === true || Boolean( host.imageLink ), + } ), } ); } diff --git a/apps/cli/ai/tools/wp-cli.ts b/apps/cli/ai/tools/wp-cli.ts index c18abb80b8..fb16339df1 100644 --- a/apps/cli/ai/tools/wp-cli.ts +++ b/apps/cli/ai/tools/wp-cli.ts @@ -5,6 +5,7 @@ import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; import { getUnsupportedWpCliPostContentMessage } from 'cli/lib/wp-cli-post-content'; import { defineTool, REMOTE_OVERWRITE } from './define-tool'; import { resolveSite } from './utils'; +import type { AnyStudioAgentTool } from './define-tool'; // LLMs sometimes emit en/em dashes (`‐porcelain`, `–color`); WP-CLI silently // ignores them, so reject up-front and let the agent retry with ASCII hyphens. @@ -93,5 +94,7 @@ export const runWpCliTool = defineTool( }, { annotations: REMOTE_OVERWRITE, + // An agent with a shell runs `wp` there. + forHost: ( host ): AnyStudioAgentTool | undefined => ( host.shell ? undefined : runWpCliTool ), } ); From 3a2ee113666d9f60f181678f8779ad84be6fd6ed Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 23 Oct 2026 23:16:49 -0400 Subject: [PATCH 039/199] Studio Code: Say at write time what WordPress will render, instead of inspecting the page after Removes inspect_design and the screenshot-and-fix loop. The defects that loop found in a build were WordPress structure, which a file write already shows: - A full-width block that cannot reach the page edges, because a block around it (or the template's post content) sits in a constrained layout. Checked on every template, part, pattern and content file. - Theme CSS that stretches a block to the viewport width (100vw, calc(50% - 50vw)) where align full belongs. - A theme.json palette slug (text, link) whose generated .has--color class is the marker the block editor saves on blocks with their own color. take_screenshot stays, to show the user a page. Studio now gives a theme without a thumbnail its theme.json design board as screenshot.png, so the themeScreenshot argument goes. Removes the edit-manage-site skill: editing and managing a site is what Studio is for, not a workflow to load. build-site ends at publishing the content. STUDIO.md drops its hand-kept tool table; the MCP server lists its tools. The font note now ends with the next step, and selfHostFonts drops its updatable-theme check: those writes are refused before they happen. --- apps/cli/ai/block-alignment.ts | 90 ++++++ apps/cli/ai/block-validation-gate.ts | 2 +- apps/cli/ai/design-board.ts | 8 + .../hooks/tests/file-tool-hook.test.ts | 55 +++- apps/cli/ai/runtimes/pi/index.ts | 2 +- apps/cli/ai/site-file-guards.ts | 99 ++++++- apps/cli/ai/skills/annotate/SKILL.md | 7 +- apps/cli/ai/skills/build-site/SKILL.md | 6 +- apps/cli/ai/skills/edit-manage-site/SKILL.md | 21 -- apps/cli/ai/tests/block-alignment.test.ts | 37 +++ apps/cli/ai/tests/site-file-guards.test.ts | 9 + apps/cli/ai/tests/tools.test.ts | 105 ------- apps/cli/ai/tools/index.ts | 2 - apps/cli/ai/tools/inspect-design.ts | 271 ------------------ apps/cli/ai/tools/screenshot-helpers.ts | 4 +- apps/cli/ai/tools/take-screenshot.ts | 43 +-- .../session-view/conversation/index.tsx | 1 - packages/common/ai/tests/tools.test.ts | 1 - packages/common/ai/tools.ts | 3 - plugins/wp-studio/.codex-plugin/plugin.json | 2 +- plugins/wp-studio/skills/build-site/SKILL.md | 2 +- .../skills/edit-manage-site/SKILL.md | 9 - skills/STUDIO.md | 23 +- 23 files changed, 303 insertions(+), 499 deletions(-) create mode 100644 apps/cli/ai/block-alignment.ts delete mode 100644 apps/cli/ai/skills/edit-manage-site/SKILL.md create mode 100644 apps/cli/ai/tests/block-alignment.test.ts delete mode 100644 apps/cli/ai/tools/inspect-design.ts delete mode 100644 plugins/wp-studio/skills/edit-manage-site/SKILL.md diff --git a/apps/cli/ai/block-alignment.ts b/apps/cli/ai/block-alignment.ts new file mode 100644 index 0000000000..cc0e63a9e6 --- /dev/null +++ b/apps/cli/ai/block-alignment.ts @@ -0,0 +1,90 @@ +/** + * Full-width blocks that cannot reach the page edges. A block spans the page + * with `align: full` only inside containers that span it: a constrained layout + * (wp-includes/block-supports/layout.php) holds each other child to the + * content width, and the full-width blocks inside that child with it. + */ + +// A block's opening, closing, or self-closing comment in serialized markup. +const BLOCK_DELIMITER = + //g; + +interface BlockAttributes { + align?: unknown; + className?: unknown; + layout?: { type?: unknown; inherit?: unknown; contentSize?: unknown }; +} + +interface OpenBlock { + label: string; + constrains: boolean; + reachesEdges: boolean; +} + +// Which layout WordPress applies: a `layout` without a type that inherits or +// sets a content size is the constrained layout. +function constrains( { layout }: BlockAttributes ): boolean { + return ( + layout?.type === 'constrained' || + ( layout?.type === undefined && + ( layout?.inherit === true || layout?.contentSize !== undefined ) ) + ); +} + +function labelOf( name: string, attributes: BlockAttributes ): string { + const block = name.includes( '/' ) ? name : `core/${ name }`; + return typeof attributes.className === 'string' + ? `${ block } (${ attributes.className + .split( /\s+/ ) + .map( ( c ) => `.${ c }` ) + .join( '' ) })` + : block; +} + +// The page content: its align full blocks span the page only when it does. +const POST_CONTENT = 'core/post-content'; + +/** + * What keeps each full-width block in this markup, and the page content, from + * the page edges, taking its top-level blocks to span the page: those of a + * template do, and those of content do unless the template holds them. + */ +export function trappedFullWidthNotes( markup: string ): string[] { + const open: OpenBlock[] = []; + const notes = new Set< string >(); + for ( const [ , closing, name, json, selfClosing ] of markup.matchAll( BLOCK_DELIMITER ) ) { + if ( closing ) { + open.pop(); + continue; + } + let attributes: BlockAttributes = {}; + try { + attributes = json ? JSON.parse( json ) : {}; + } catch { + // Invalid attributes are the block check's to report. + } + const parent = open.at( -1 ); + const full = attributes.align === 'full'; + const reachesEdges = ! parent || ( parent.reachesEdges && ( full || ! parent.constrains ) ); + const block = { + label: labelOf( name, attributes ), + constrains: constrains( attributes ), + reachesEdges, + }; + const isPostContent = block.label.startsWith( POST_CONTENT ); + if ( ( full || isPostContent ) && ! reachesEdges ) { + // The outermost block that stops short: the one align full would free. + const stop = [ ...open, block ].find( ( { reachesEdges: reaches } ) => ! reaches ) ?? block; + const subject = isPostContent + ? `${ POST_CONTENT }, and so no align full block in a page's content,` + : `${ block.label }, align full,`; + notes.add( + `${ subject } cannot reach the page edges: ${ stop.label } is not align full, and the constrained layout around it holds it to the content width (wp-includes/block-supports/layout.php). Give ${ stop.label } align full.` + ); + } + if ( ! selfClosing ) { + open.push( block ); + } + } + return [ ...notes ]; +} diff --git a/apps/cli/ai/block-validation-gate.ts b/apps/cli/ai/block-validation-gate.ts index 769ccab6c2..e4967f91e4 100644 --- a/apps/cli/ai/block-validation-gate.ts +++ b/apps/cli/ai/block-validation-gate.ts @@ -5,7 +5,7 @@ * Writing block markup (a theme template or part, or a page scratch file in the * site's tmp/) queues that file. Validation starts in the background at once, * and what puts content on the site or renders it (a shell command, which - * runs WP-CLI, take_screenshot, inspect_design) waits for it first. A fix the block editor + * runs WP-CLI, or take_screenshot) waits for it first. A fix the block editor * can make losslessly is applied to the file silently; the model hears only * about what it has to change itself. */ diff --git a/apps/cli/ai/design-board.ts b/apps/cli/ai/design-board.ts index ff6fd0d302..946ba889d3 100644 --- a/apps/cli/ai/design-board.ts +++ b/apps/cli/ai/design-board.ts @@ -129,6 +129,14 @@ export function renderDesignBoard( themeJson: string, image?: string ): string { throw new Error( `The look's theme.json is not valid JSON: ${ ( error as Error ).message }` ); } const tokens = themeJsonTokens( parsed ); + if ( + Object.keys( tokens.colors ?? {} ).length < 2 || + ! Object.values( tokens.typography ?? {} ).some( ( style ) => style.fontFamily ) + ) { + throw new Error( + "The look's theme.json needs at least two hex colors in settings.color.palette, and a font family in styles.typography." + ); + } const { palette, background, diff --git a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts index b13d3a1110..18b215e127 100644 --- a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts +++ b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts @@ -96,7 +96,7 @@ it( 'adds to a write the block validation and what WordPress renders instead of afterEach( () => vi.unstubAllGlobals() ); // A theme.json font family without font files gets them from Google Fonts, in -// the weights the theme uses. A theme WordPress updates is left alone. +// the weights the theme uses. const FACE = { fontFamily: 'Inter', src: [ 'file:./assets/fonts/inter.woff2' ] }; it.each( [ { @@ -109,10 +109,9 @@ it.each( [ theme: 'bakery', family: 'Nope Sans', fontFace: undefined, - note: 'Nope Sans has no font files in wp-content/themes/bakery/theme.json, and Google Fonts did not provide them (https://fonts.googleapis.com/css2?family=Nope+Sans:wght@400;600&display=swap responded with HTTP 400).', + note: 'Nope Sans has no font files in wp-content/themes/bakery/theme.json, and Google Fonts did not provide them (https://fonts.googleapis.com/css2?family=Nope+Sans:wght@400;600&display=swap responded with HTTP 400). Put its files in wp-content/themes/bakery/assets/fonts and declare them as its fontFace, or choose a Google Fonts family.', }, { theme: 'bakery', family: 'Inter', fontFace: [ FACE ], note: undefined }, - { theme: 'bakery-parent', family: 'Inter', fontFace: undefined, note: undefined }, ] )( 'self-hosts $family in $theme after a theme.json write, with font files: $fontFace', async ( { theme, family, fontFace, note } ) => { @@ -175,3 +174,53 @@ it.each( [ ); } ); + +// A theme.json that names a palette color after a class WordPress already +// gives blocks gets a note; a theme without a thumbnail gets its design board. +it.each( [ + { slug: 'text', screenshot: undefined, note: true, thumbnail: true }, + { slug: 'ink', screenshot: undefined, note: false, thumbnail: true }, + { slug: 'ink', screenshot: 'screenshot.jpg', note: false, thumbnail: false }, +] )( + 'after a theme.json write with palette $slug and $screenshot, notes the clash: $note, saves a thumbnail: $thumbnail', + async ( { slug, screenshot, note, thumbnail } ) => { + const themeDir = path.join( site.root, 'wp-content/themes/bakery' ); + if ( screenshot ) { + writeFileSync( path.join( themeDir, screenshot ), '' ); + } + writeFileSync( + path.join( themeDir, 'theme.json' ), + JSON.stringify( { + version: 3, + settings: { + color: { + palette: [ + { slug: 'paper', color: '#ffffff' }, + { slug, color: '#111111' }, + ], + }, + }, + styles: { typography: { fontFamily: 'Georgia, serif' } }, + } ) + ); + + const output = await runFileToolHook( { + hook_event_name: 'PostToolUse', + tool_name: 'Write', + tool_input: { file_path: path.join( themeDir, 'theme.json' ) }, + } ); + + const hook = output?.hookSpecificOutput; + const context = hook && 'additionalContext' in hook ? hook.additionalContext : ''; + expect( context.includes( 'names a palette color `text`' ) ).toBe( note ); + expect( context.includes( 'screenshot.png' ) ).toBe( thumbnail ); + const png = path.join( themeDir, 'screenshot.png' ); + expect( existsSync( png ) ).toBe( thumbnail ); + if ( thumbnail ) { + // The PNG header: width and height are big-endian at bytes 16 and 20. + const header = readFileSync( png ); + expect( [ header.readUInt32BE( 16 ), header.readUInt32BE( 20 ) ] ).toEqual( [ 1200, 900 ] ); + } + }, + 60_000 +); diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 48e25482f3..88758840dd 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -668,7 +668,7 @@ function createSettingsManager( _env: Record< string, string > ): SettingsManage } // A shell command can put content on the site through WP-CLI. -const BLOCK_VALIDATION_GATED_TOOLS = new Set( [ 'Bash', 'take_screenshot', 'inspect_design' ] ); +const BLOCK_VALIDATION_GATED_TOOLS = new Set( [ 'Bash', 'take_screenshot' ] ); function toToolDefinition( tool: AgentToolAny, diff --git a/apps/cli/ai/site-file-guards.ts b/apps/cli/ai/site-file-guards.ts index f9b2a4726c..3b0324be1d 100644 --- a/apps/cli/ai/site-file-guards.ts +++ b/apps/cli/ai/site-file-guards.ts @@ -9,11 +9,17 @@ * (runtimes/hooks) run the same checks. */ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; +import { unlink, writeFile } from 'fs/promises'; import path from 'path'; +import { pathToFileURL } from 'url'; import { selfHostThemeJsonFonts } from '@studio/common/lib/theme-fonts'; +import { trappedFullWidthNotes } from 'cli/ai/block-alignment'; +import { renderDesignBoard } from 'cli/ai/design-board'; import { savedCopyWriteNote } from 'cli/ai/saved-copies'; +import { resolveScreenshotDirectory } from 'cli/ai/screenshot-storage'; import { readWordPressSite } from 'cli/ai/site-database'; import { isUpdatableTheme } from 'cli/ai/themes'; +import { captureScreenshotBuffer } from 'cli/ai/tools/screenshot-helpers'; import { resolveSite } from 'cli/ai/tools/utils'; import { findSiteByFolder } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; @@ -55,6 +61,7 @@ export function resolveSiteFile( filePath: string ): SiteFile | undefined { const INLINE_DISPLAY = /(^|;|\s)display\s*:\s*inline(-block|-flex|-grid)?\s*(;|$|!)/i; const BOX_DECLARATION = /(^|;|\s)(padding[a-z-]*|background[a-z-]*|border[a-z-]*)\s*:/i; +const VIEWPORT_BREAKOUT = /calc\(\s*50%\s*-\s*50d?vw\s*\)|(^|;|\s)width\s*:\s*100d?vw/i; function listFiles( dir: string, extensions: string[], depth = 3 ): string[] { if ( depth < 0 || ! existsSync( dir ) ) { @@ -104,6 +111,11 @@ export function lintThemeCss( css: string, blockClassNames: Set< string > ): str `\`${ selector }\` is a block className and gets an inline-level display. Blocks in a constrained layout are placed with auto inline margins (wp-includes/block-supports/layout.php), which inline-level boxes ignore, so this block leaves the content column. To shrink-wrap it, wrap it in a group with a flex layout and drop the display rule.` ); } + if ( VIEWPORT_BREAKOUT.test( declarations ) ) { + notes.push( + `\`${ selector }\` stretches to the viewport width with CSS. WordPress spans a block across the page with align full (wp-includes/block-supports/layout.php): give the block align full instead, and when it still stops short, a block around it is not full width.` + ); + } if ( /(^|\s)\.wp-block-button(?![\w-])(?!.*\.wp-(block-button__link|element-button))/.test( selector @@ -156,13 +168,47 @@ export async function getSiteFileWriteRefusal( return `Not run: ${ reason }`; } +// Palette slugs whose `.has--color` class (class-wp-theme-json.php) is +// the class the block editor saves on every block with its own text or link +// color (wp-includes/js/dist/block-editor.js). +const MARKER_SLUGS = [ 'text', 'link' ]; + +/** What a theme.json just written makes WordPress render differently from what it says. */ +function themeJsonNotes( themeJson: unknown, themePath: string ): string[] { + const palette = ( themeJson as { settings?: { color?: { palette?: { slug?: unknown }[] } } } ) + ?.settings?.color?.palette; + return ( Array.isArray( palette ) ? palette : [] ) + .filter( ( { slug } ) => MARKER_SLUGS.includes( slug as string ) ) + .map( + ( { slug } ) => + `${ themePath }/theme.json names a palette color \`${ slug }\`, so WordPress generates \`.has-${ slug }-color\` for it (wp-includes/class-wp-theme-json.php): the class the block editor also saves on every block with its own ${ slug } color, which then loses that color to \`${ slug }\`. Rename the slug.` + ); +} + +// Block markup files: a theme's templates, parts and patterns, and page +// content scratch files. +function isBlockMarkup( { relativePath, theme }: SiteFile ): boolean { + return theme + ? /^(templates|parts|patterns)\/[^/]+\.(html|php)$/.test( theme.file ) + : /^tmp\/[^/]+\.html$/.test( relativePath ); +} + /** What WordPress will render differently from the file just written, if anything. */ async function getSiteFileWriteNotes( siteFile: SiteFile ): Promise< string[] > { const { siteRoot, relativePath, theme } = siteFile; + const notes: string[] = []; + if ( isBlockMarkup( siteFile ) ) { + notes.push( + ...trappedFullWidthNotes( readFileSync( path.join( siteRoot, relativePath ), 'utf8' ) ) + ); + } if ( ! theme ) { - return []; + return notes; + } + if ( theme.file === 'theme.json' ) { + const themeJson = JSON.parse( readFileSync( path.join( theme.dir, theme.file ), 'utf8' ) ); + notes.push( ...themeJsonNotes( themeJson, `wp-content/themes/${ theme.slug }` ) ); } - const notes: string[] = []; if ( theme.file.endsWith( '.css' ) ) { const css = readFileSync( path.join( theme.dir, theme.file ), 'utf8' ); notes.push( ...lintThemeCss( css, await getBlockClassNames( siteRoot, theme.dir ) ) ); @@ -178,11 +224,11 @@ async function getSiteFileWriteNotes( siteFile: SiteFile ): Promise< string[] > /** * Self-hosts the fonts a theme.json just written declares without font files, - * and says what it downloaded. Themes WordPress updates are left as they are. + * and says what it downloaded. */ async function selfHostFonts( siteFile: SiteFile ): Promise< string[] > { - const { siteRoot, theme } = siteFile; - if ( theme?.file !== 'theme.json' || ( await isUpdatableTheme( siteRoot, theme.slug ) ) ) { + const { theme } = siteFile; + if ( theme?.file !== 'theme.json' ) { return []; } const { hosted, failed } = await selfHostThemeJsonFonts( theme.dir ); @@ -197,19 +243,56 @@ async function selfHostFonts( siteFile: SiteFile ): Promise< string[] > { : [] ), ...failed.map( ( { family, error } ) => - `${ family } has no font files in ${ themePath }/theme.json, and Google Fonts did not provide them (${ error }).` + `${ family } has no font files in ${ themePath }/theme.json, and Google Fonts did not provide them (${ error }). Put its files in ${ themePath }/assets/fonts and declare them as its fontFace, or choose a Google Fonts family.` ), ]; } +const THUMBNAIL = { width: 1200, height: 900 } as const; + +/** + * Gives a theme without a thumbnail one: its theme.json's design board, the + * 1200×900 screenshot.png Appearance → Themes shows. + */ +async function writeThemeThumbnail( siteFile: SiteFile ): Promise< string[] > { + const { theme } = siteFile; + if ( + theme?.file !== 'theme.json' || + readdirSync( theme.dir ).some( ( file ) => /^screenshot\.(png|jpe?g|gif|webp)$/.test( file ) ) + ) { + return []; + } + const board = renderDesignBoard( readFileSync( path.join( theme.dir, theme.file ), 'utf8' ) ); + const page = path.join( await resolveScreenshotDirectory(), `thumbnail-${ theme.slug }.html` ); + await writeFile( page, `\ufeff${ board }` ); + try { + const { buffer } = await captureScreenshotBuffer( pathToFileURL( page ).href, THUMBNAIL, { + fullPage: false, + format: 'png', + } ); + await writeFile( path.join( theme.dir, 'screenshot.png' ), buffer ); + } finally { + await unlink( page ); + } + return [ + `Studio saved the theme's design board as wp-content/themes/${ theme.slug }/screenshot.png, its thumbnail in Appearance → Themes.`, + ]; +} + /** * After a write to a site's file: registers a changed pattern, self-hosts the - * theme's fonts, and returns what WordPress will render differently from the file. + * theme's fonts, gives it a thumbnail, and returns what WordPress will render + * differently from the file. */ export async function afterSiteFileWrite( siteFile: SiteFile ): Promise< string[] > { await clearThemePatternCache( siteFile ).catch( () => undefined ); const fonts = await selfHostFonts( siteFile ).catch( () => [] ); - return [ ...fonts, ...( await getSiteFileWriteNotes( siteFile ).catch( () => [] ) ) ]; + const thumbnail = await writeThemeThumbnail( siteFile ).catch( () => [] ); + return [ + ...fonts, + ...thumbnail, + ...( await getSiteFileWriteNotes( siteFile ).catch( () => [] ) ), + ]; } /** diff --git a/apps/cli/ai/skills/annotate/SKILL.md b/apps/cli/ai/skills/annotate/SKILL.md index 1c4cded815..9485f14fa8 100644 --- a/apps/cli/ai/skills/annotate/SKILL.md +++ b/apps/cli/ai/skills/annotate/SKILL.md @@ -49,13 +49,10 @@ For each annotation: - For style changes (colors, sizes, spacing): use Global Styles custom CSS with the selector from the annotation - For content changes (text, headings, block structure): edit the template or post content via WP-CLI - For block-level changes: identify the WordPress block type from the HTML structure (look for `wp-block-*` classes) and modify accordingly -3. Take a screenshot to verify the change looks correct -### 5. Verify +### 5. Report -After all annotations are addressed, take a screenshot and confirm with the user. - -The browser closes when the user clicks **Send**. If they want another round, run the skill again from the top — `/annotate` opens a fresh browser. +After all annotations are addressed, tell the user what changed. The browser closes when the user clicks **Send**. If they want another round, run the skill again from the top — `/annotate` opens a fresh browser. ## Making changes the WordPress way diff --git a/apps/cli/ai/skills/build-site/SKILL.md b/apps/cli/ai/skills/build-site/SKILL.md index a0210a370e..96624f6262 100644 --- a/apps/cli/ai/skills/build-site/SKILL.md +++ b/apps/cli/ai/skills/build-site/SKILL.md @@ -1,6 +1,6 @@ --- name: build-site -description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the design and plugins as a Site Spec, then build the theme, provision the site, publish the content, and polish the result. Load it before picking or creating the site. +description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the design and plugins as a Site Spec, then build the theme, provision the site, and publish the content. Load it before picking or creating the site. user-invokable: true tools: pick_design, present_design_options, generate_images --- @@ -62,7 +62,3 @@ With `wp`, activate the theme, install and activate the Site Spec's plugins, and ## 5. Publish the content Write each page's content to a scratch file in the site's `tmp/`, then create or update the page from the file. - -## 6. Check the result - -Take one `take_screenshot` of each page with `viewport: "all"`. List every real defect against the look and the layout map, find each cause with inspect_design and the source that produces it, fix them in one batch, and re-check once. When Studio Code wrote the theme, pass its directory as `themeScreenshot` on the home page's last desktop capture. diff --git a/apps/cli/ai/skills/edit-manage-site/SKILL.md b/apps/cli/ai/skills/edit-manage-site/SKILL.md deleted file mode 100644 index 09a08b3518..0000000000 --- a/apps/cli/ai/skills/edit-manage-site/SKILL.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: edit-manage-site -description: Edit and manage one of the user's local Studio sites — answer questions about it, add pages or sections, adjust the design, add features and plugins, update content, preview it, or publish it. Load it for any work on an existing local site that is not a full build or redesign. -user-invokable: true ---- - -# Edit and Manage a Site - -Use this skill for work on an existing local Studio site. A new site, a redesign, or a rebuild uses the `build-site` skill instead. - -## 1. Settle the site - -Start the site with site_start if it is not running. - -## 2. Make the change - -Load the other skills the change calls for. Follow the site's existing look — the active theme's theme.json presets and styles — rather than redesigning it; a change to the look is a change to theme.json. - -## 3. Check and report - -Take one screenshot of what you changed, fix real defects, then tell the user what changed and where to see it. preview_create shares a temporary preview; site_push publishes to WordPress.com. diff --git a/apps/cli/ai/tests/block-alignment.test.ts b/apps/cli/ai/tests/block-alignment.test.ts new file mode 100644 index 0000000000..8264b8295a --- /dev/null +++ b/apps/cli/ai/tests/block-alignment.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { trappedFullWidthNotes } from 'cli/ai/block-alignment'; + +const group = ( attributes: object, inner: string ) => + `

${ inner }
`; +const cover = '
'; +const constrained = { layout: { type: 'constrained' } }; + +describe( 'trappedFullWidthNotes', () => { + // [ markup, the block named as the one to free ] + it.each( [ + // A template whose page content sits in a constrained main: the smoke run's. + [ + group( { tagName: 'main', ...constrained }, '' ), + 'core/post-content', + ], + [ + group( { tagName: 'main', ...constrained }, '' ), + undefined, + ], + // A full cover inside a group that is held to the content width. + [ group( constrained, group( constrained, cover ) ), 'core/group' ], + [ group( { align: 'full', ...constrained }, cover ), undefined ], + // The legacy layout that inherits the content width is constrained too. + [ group( { layout: { inherit: true } }, group( {}, cover ) ), 'core/group' ], + // A group without a constrained layout passes its width through. + [ group( constrained, group( { align: 'full' }, group( {}, cover ) ) ), undefined ], + [ cover, undefined ], + [ group( constrained, cover ), undefined ], + ] )( '%s frees %s', ( markup, stop ) => { + const notes = trappedFullWidthNotes( markup ); + expect( notes ).toHaveLength( stop ? 1 : 0 ); + if ( stop ) { + expect( notes[ 0 ] ).toContain( `Give ${ stop } align full.` ); + } + } ); +} ); diff --git a/apps/cli/ai/tests/site-file-guards.test.ts b/apps/cli/ai/tests/site-file-guards.test.ts index 1ee32528ae..4f5f858a54 100644 --- a/apps/cli/ai/tests/site-file-guards.test.ts +++ b/apps/cli/ai/tests/site-file-guards.test.ts @@ -59,6 +59,15 @@ describe( 'theme CSS lint', () => { ).toEqual( [] ); } ); + it.each( [ + [ '.hero { width: 100vw; margin-left: calc(50% - 50vw) }', 1 ], + [ '.hero { width: 100dvw }', 1 ], + [ '.hero { max-width: 100vw }', 0 ], + [ '.hero { width: 100% }', 0 ], + ] )( 'flags a viewport-width breakout in %s: %d', ( css, count ) => { + expect( lintThemeCss( css, new Set() ) ).toHaveLength( count ); + } ); + it( 'flags box styles on the button wrapper, not the link', () => { expect( lintThemeCss( '.wp-block-button { padding: 1rem }', new Set() ) ).toHaveLength( 1 ); expect( diff --git a/apps/cli/ai/tests/tools.test.ts b/apps/cli/ai/tests/tools.test.ts index bd52041f08..47879ed98f 100644 --- a/apps/cli/ai/tests/tools.test.ts +++ b/apps/cli/ai/tests/tools.test.ts @@ -372,111 +372,6 @@ describe( 'Studio AI MCP tools', () => { } ); } ); - it( 'inspect_design returns rendered DOM facts for the requested selectors', async () => { - const report = [ - { - selector: '.wp-block-button__link', - matchCount: 1, - matches: [ - { - tag: 'a', - classes: [ 'wp-block-button__link', 'wp-element-button' ], - boundingBox: { x: 0, y: 0, width: 120, height: 44 }, - computedStyle: { 'background-color': 'rgb(0, 0, 0)' }, - ancestors: [ 'div.wp-block-button' ], - }, - ], - }, - ]; - const page = { - emulateMedia: vi.fn(), - goto: vi.fn(), - waitForLoadState: vi.fn().mockResolvedValue( undefined ), - evaluate: vi.fn().mockResolvedValueOnce( undefined ).mockResolvedValueOnce( report ), - hover: vi.fn(), - mouse: { move: vi.fn() }, - close: vi.fn(), - }; - const browser = { newPage: vi.fn().mockResolvedValue( page ) }; - vi.mocked( getSharedBrowser ).mockResolvedValue( browser as never ); - - const result = await getTool( 'inspect_design' ).rawHandler( { - url: 'http://localhost:8903/', - selectors: [ '.wp-block-button__link' ], - } as never ); - - const parsed = JSON.parse( getTextContent( result )! ); - expect( parsed.viewport ).toBe( 'desktop' ); - expect( parsed.viewportWidth ).toBe( 1040 ); - expect( parsed.selectors[ 0 ].matchCount ).toBe( 1 ); - expect( parsed.selectors[ 0 ].matches[ 0 ].computedStyle[ 'background-color' ] ).toBe( - 'rgb(0, 0, 0)' - ); - expect( parsed.hover ).toBeUndefined(); - expect( page.hover ).not.toHaveBeenCalled(); - expect( page.close ).toHaveBeenCalled(); - } ); - - it( 'inspect_design can force a dark color scheme', async () => { - const report = [ { selector: '.hero', matchCount: 1, matches: [] } ]; - const page = { - emulateMedia: vi.fn(), - goto: vi.fn(), - waitForLoadState: vi.fn().mockResolvedValue( undefined ), - evaluate: vi.fn().mockResolvedValueOnce( undefined ).mockResolvedValueOnce( report ), - hover: vi.fn(), - mouse: { move: vi.fn() }, - close: vi.fn(), - }; - const browser = { newPage: vi.fn().mockResolvedValue( page ) }; - vi.mocked( getSharedBrowser ).mockResolvedValue( browser as never ); - - const result = await getTool( 'inspect_design' ).rawHandler( { - url: 'http://localhost:8903/', - selectors: [ '.hero' ], - colorScheme: 'dark', - } as never ); - - const parsed = JSON.parse( getTextContent( result )! ); - expect( parsed.colorScheme ).toBe( 'dark' ); - expect( page.emulateMedia ).toHaveBeenCalledWith( { - reducedMotion: 'reduce', - colorScheme: 'dark', - } ); - } ); - - it( 'inspect_design captures hover styles when includeHover is set', async () => { - const report = [ { selector: '.wp-block-button__link', matchCount: 1, matches: [] } ]; - const page = { - emulateMedia: vi.fn(), - goto: vi.fn(), - waitForLoadState: vi.fn().mockResolvedValue( undefined ), - evaluate: vi - .fn() - .mockResolvedValueOnce( undefined ) - .mockResolvedValueOnce( report ) - .mockResolvedValueOnce( { 'background-color': 'rgb(255, 0, 0)' } ), - hover: vi.fn().mockResolvedValue( undefined ), - mouse: { move: vi.fn().mockResolvedValue( undefined ) }, - close: vi.fn(), - }; - const browser = { newPage: vi.fn().mockResolvedValue( page ) }; - vi.mocked( getSharedBrowser ).mockResolvedValue( browser as never ); - - const result = await getTool( 'inspect_design' ).rawHandler( { - url: 'http://localhost:8903/', - selectors: [ '.wp-block-button__link' ], - viewport: 'mobile', - includeHover: true, - } as never ); - - const parsed = JSON.parse( getTextContent( result )! ); - expect( parsed.viewport ).toBe( 'mobile' ); - expect( parsed.viewportWidth ).toBe( 390 ); - expect( page.hover ).toHaveBeenCalledWith( '.wp-block-button__link', expect.anything() ); - expect( parsed.hover[ 0 ].computedStyle[ 'background-color' ] ).toBe( 'rgb(255, 0, 0)' ); - } ); - it( 'creates previews for a resolved local site', async () => { const result = await getTool( 'preview_create' ).rawHandler( { nameOrPath: 'My Site', diff --git a/apps/cli/ai/tools/index.ts b/apps/cli/ai/tools/index.ts index 0f74399440..e9610d564c 100644 --- a/apps/cli/ai/tools/index.ts +++ b/apps/cli/ai/tools/index.ts @@ -6,7 +6,6 @@ import { deleteSiteTool } from './delete-site'; import { exportSiteTool } from './export-site'; import { generateImagesTool } from './generate-images'; import { importSiteTool } from './import-site'; -import { inspectDesignTool } from './inspect-design'; import { installTaxonomyScriptsTool } from './install-taxonomy-scripts'; import { listConnectedRemoteSitesTool } from './list-connected-remote-sites'; import { listPreviewsTool } from './list-previews'; @@ -41,7 +40,6 @@ export const studioToolDefinitions: AnyStudioAgentTool[] = [ runWpCliTool, pickDesignTool, takeScreenshotTool, - inspectDesignTool, generateImagesTool, installTaxonomyScriptsTool, auditPerformanceTool, diff --git a/apps/cli/ai/tools/inspect-design.ts b/apps/cli/ai/tools/inspect-design.ts deleted file mode 100644 index cd26076b02..0000000000 --- a/apps/cli/ai/tools/inspect-design.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { Type } from 'typebox'; -import { getSharedBrowser } from 'cli/ai/browser-utils'; -import { defineTool, READ_ONLY } from './define-tool'; -import { - applyScreenshotMediaEmulation, - SCREENSHOT_COLOR_SCHEME_DESCRIPTION, - SCREENSHOT_COLOR_SCHEME_VALUES, -} from './screenshot-helpers'; -import { textResult } from './utils'; - -/** - * Viewports mirror `take_screenshot` so the DOM the agent inspects matches the - * pixels it sees. Layout-dependent issues (constrained width, column stacking) - * differ between desktop and mobile, so the agent picks the one it is - * diagnosing. - */ -const INSPECT_VIEWPORTS = { - desktop: { width: 1040, height: 1248 }, - mobile: { width: 390, height: 844 }, -} as const; - -type InspectViewport = keyof typeof INSPECT_VIEWPORTS; - -/** - * Curated, design-relevant computed-style properties. The full computed style - * is hundreds of properties; this set covers the layout, box, background, - * typography, flex/grid, and interaction concerns the polish loop reasons - * about, while keeping the tool output small. - */ -const COMPUTED_PROPERTIES = [ - 'display', - 'position', - 'width', - 'height', - 'max-width', - 'min-height', - 'box-sizing', - 'margin-top', - 'margin-right', - 'margin-bottom', - 'margin-left', - 'padding-top', - 'padding-right', - 'padding-bottom', - 'padding-left', - 'background-color', - 'background-image', - 'color', - 'border-top-width', - 'border-style', - 'border-color', - 'border-radius', - 'box-shadow', - 'opacity', - 'font-family', - 'font-size', - 'font-weight', - 'line-height', - 'letter-spacing', - 'text-align', - 'text-decoration-line', - 'flex-direction', - 'flex-wrap', - 'justify-content', - 'align-items', - 'gap', - 'column-gap', - 'row-gap', - 'grid-template-columns', - 'transform', - 'transition', - 'cursor', -] as const; - -const MAX_MATCHES_PER_SELECTOR = 5; -const HTML_PREVIEW_LENGTH = 400; -const ANCESTOR_DEPTH = 6; - -// The tool is the same for text-only models; only how the prompt introduces it -// changes, since they cannot pair it with a capture. -export const inspectDesignTool = defineTool( - 'inspect_design', - "Inspects the rendered DOM of a live page: for each selector, the matched elements' classes, bounding box, computed styles, and layout ancestors. " + - 'Pair with take_screenshot: the screenshot shows the symptom, inspect_design the cause. ' + - '`includeHover` adds hover styles; `colorScheme` matches take_screenshot.', - { - url: Type.String( { description: 'The URL of the running site page to inspect' } ), - selectors: Type.Array( Type.String(), { - minItems: 1, - description: - 'CSS selectors to inspect, e.g. [".hero", ".wp-block-button", ".wp-block-button__link", ".wp-block-column"]. Each is queried against the rendered page.', - } ), - viewport: Type.Optional( - Type.Enum( [ 'desktop', 'mobile' ], { - description: - 'Viewport to render at: "desktop" (1040px wide) or "mobile" (390px wide). Defaults to desktop. Use "mobile" to diagnose responsive issues.', - } ) - ), - includeHover: Type.Optional( - Type.Boolean( { - description: - 'When true, also captures the computed styles of the first match of each selector while it is hovered. Use for button/link hover diagnosis.', - } ) - ), - colorScheme: Type.Optional( - Type.Enum( SCREENSHOT_COLOR_SCHEME_VALUES, { - description: SCREENSHOT_COLOR_SCHEME_DESCRIPTION, - } ) - ), - }, - async ( args, context ) => { - const viewport: InspectViewport = ( args.viewport as InspectViewport ) ?? 'desktop'; - context.onProgress( - `Inspecting ${ args.selectors.length } selector(s) on ${ args.url } (${ viewport }${ - args.colorScheme ? `, ${ args.colorScheme }` : '' - })…` - ); - - const browser = await getSharedBrowser(); - const page = await browser.newPage( { - viewport: INSPECT_VIEWPORTS[ viewport ], - ignoreHTTPSErrors: true, - } ); - - try { - await applyScreenshotMediaEmulation( page, args.colorScheme ); - await page.goto( args.url, { waitUntil: 'domcontentloaded', timeout: 30000 } ); - await page.waitForLoadState( 'networkidle', { timeout: 2500 } ).catch( () => {} ); - await page.evaluate( - () => new Promise< void >( ( resolve ) => requestAnimationFrame( () => resolve() ) ) - ); - - const report = await page.evaluate( - ( { selectors, properties, maxMatches, htmlPreviewLength, ancestorDepth } ) => { - const describeNode = ( el: Element ) => { - const classList = Array.from( el.classList ); - const style = getComputedStyle( el ); - const computed: Record< string, string > = {}; - for ( const prop of properties ) { - computed[ prop ] = style.getPropertyValue( prop ).trim(); - } - const rect = el.getBoundingClientRect(); - const html = el.outerHTML; - return { - tag: el.tagName.toLowerCase(), - id: el.id || undefined, - classes: classList, - boundingBox: { - x: Math.round( rect.x ), - y: Math.round( rect.y ), - width: Math.round( rect.width ), - height: Math.round( rect.height ), - }, - computedStyle: computed, - outerHTMLPreview: - html.length > htmlPreviewLength ? html.slice( 0, htmlPreviewLength ) + '…' : html, - }; - }; - - const ancestorChain = ( el: Element ): string[] => { - const chain: string[] = []; - let current = el.parentElement; - let depth = 0; - while ( current && current.tagName !== 'HTML' && depth < ancestorDepth ) { - const classes = Array.from( current.classList ); - chain.push( - `${ current.tagName.toLowerCase() }${ - classes.length ? '.' + classes.join( '.' ) : '' - }` - ); - current = current.parentElement; - depth++; - } - return chain; - }; - - return selectors.map( ( selector ) => { - let elements: Element[]; - try { - elements = Array.from( document.querySelectorAll( selector ) ); - } catch ( error ) { - return { - selector, - error: `Invalid selector: ${ - error instanceof Error ? error.message : String( error ) - }`, - }; - } - return { - selector, - matchCount: elements.length, - matches: elements.slice( 0, maxMatches ).map( ( el, index ) => ( { - ...describeNode( el ), - ancestors: index === 0 ? ancestorChain( el ) : undefined, - } ) ), - }; - } ); - }, - { - selectors: args.selectors, - properties: COMPUTED_PROPERTIES as readonly string[] as string[], - maxMatches: MAX_MATCHES_PER_SELECTOR, - htmlPreviewLength: HTML_PREVIEW_LENGTH, - ancestorDepth: ANCESTOR_DEPTH, - } - ); - - let hover: - | Array< { selector: string; computedStyle?: Record< string, string >; error?: string } > - | undefined; - if ( args.includeHover ) { - hover = []; - for ( const selector of args.selectors ) { - try { - await page.hover( selector, { timeout: 2000 } ); - const computedStyle = await page.evaluate( - ( { sel, properties } ) => { - const el = document.querySelector( sel ); - if ( ! el ) { - return undefined; - } - const style = getComputedStyle( el ); - const computed: Record< string, string > = {}; - for ( const prop of properties ) { - computed[ prop ] = style.getPropertyValue( prop ).trim(); - } - return computed; - }, - { sel: selector, properties: COMPUTED_PROPERTIES as readonly string[] as string[] } - ); - hover.push( { selector, computedStyle } ); - await page.mouse.move( 0, 0 ); - } catch ( error ) { - hover.push( { - selector, - error: `Could not hover: ${ - error instanceof Error ? error.message : String( error ) - }`, - } ); - } - } - } - - context.onProgress( `Inspected ${ args.selectors.length } selector(s) on ${ args.url }` ); - return textResult( - JSON.stringify( - { - url: args.url, - viewport, - viewportWidth: INSPECT_VIEWPORTS[ viewport ].width, - ...( args.colorScheme ? { colorScheme: args.colorScheme } : {} ), - selectors: report, - ...( hover ? { hover } : {} ), - }, - null, - 2 - ) - ); - } catch ( error ) { - throw new Error( - `Design inspection failed: ${ error instanceof Error ? error.message : String( error ) }` - ); - } finally { - await page.close(); - } - }, - { - settlesPendingWork: true, - annotations: READ_ONLY, - } -); diff --git a/apps/cli/ai/tools/screenshot-helpers.ts b/apps/cli/ai/tools/screenshot-helpers.ts index 650836aef8..167fe3f2ba 100644 --- a/apps/cli/ai/tools/screenshot-helpers.ts +++ b/apps/cli/ai/tools/screenshot-helpers.ts @@ -55,9 +55,7 @@ export const SCREENSHOT_COLOR_SCHEME_DESCRIPTION = 'Color scheme to emulate: "light" or "dark". Defaults to the browser/system preference.'; /** - * Apply the media emulation shared by every browser-driving tool. Keeping - * this in one place guarantees `inspect_design` reports styles under the same - * prefers-color-scheme mode `take_screenshot` renders. + * Apply the prefers-color-scheme emulation a capture asks for. */ export async function applyScreenshotMediaEmulation( page: Page, diff --git a/apps/cli/ai/tools/take-screenshot.ts b/apps/cli/ai/tools/take-screenshot.ts index a1391c6584..e29a88bd02 100644 --- a/apps/cli/ai/tools/take-screenshot.ts +++ b/apps/cli/ai/tools/take-screenshot.ts @@ -1,7 +1,4 @@ -import { copyFile } from 'node:fs/promises'; -import path from 'node:path'; import { Type } from 'typebox'; -import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths'; import { defineTool, LOCAL_CHANGE } from './define-tool'; import { captureScreenshotBuffer, @@ -56,11 +53,10 @@ function getCaptureListLabel( return targets.map( getCaptureLabel ).join( ', ' ); } -const TEXT_ONLY_NOTE = - 'This model cannot view images, so the capture is not shown to you: verify the rendered page with inspect_design.'; +const TEXT_ONLY_NOTE = 'This model cannot view images, so the capture is not shown to you.'; -// Text-only models still get the tool (it sets the theme screenshot) but no -// image block, which they would otherwise describe without seeing. +// Text-only models still get the tool, to show the user a page, but no image +// block, which they would otherwise describe without seeing. export function createTakeScreenshotTool( { visionEnabled, imageLink, @@ -77,22 +73,20 @@ export function createTakeScreenshotTool( { ( visionEnabled ? 'Returns the screenshot as an image that you can analyze visually; tall pages are scaled down to 2000 pixels on their longest side, and the saved file keeps full resolution. ' : `${ TEXT_ONLY_NOTE } ` ) + - 'Supports desktop and mobile viewports; pass `viewport: "all"` when you need both for design verification. ' + - 'Pass `colorScheme: "light"`, `colorScheme: "dark"`, or `colorScheme: "all"` to verify pages that respond to prefers-color-scheme. ' + + 'Supports desktop and mobile viewports; pass `viewport: "all"` for both. ' + + 'Pass `colorScheme: "light"`, `colorScheme: "dark"`, or `colorScheme: "all"` for pages that respond to prefers-color-scheme. ' + 'Long pages are clipped at 8000 vertical pixels; the response reports the document height and whether more remains, and you can call again with `offset` to fetch the next slice. ' + - 'Use this to verify the site looks correct after building it. ' + ( imageLink ? 'Captures meant for the user come back with image lines to put in your reply, which show them; ' : 'Captures are shown to the user in the chat by default; ' ) + - 'pass `display: false` for internal verification captures while iterating so the user only sees deliberate milestones.', + 'pass `display: false` for a capture only you need.', { url: Type.String( { description: 'The URL to screenshot' } ), viewport: Type.Optional( screenshotViewportSchema ), colorScheme: Type.Optional( screenshotColorSchemeSchema ), display: Type.Optional( Type.Boolean( { - description: - 'Whether to show the capture to the user in the chat. Defaults to true; set false for internal verification captures the user does not need to see.', + description: 'Whether to show the capture to the user in the chat. Defaults to true.', } ) ), offset: Type.Optional( @@ -102,25 +96,10 @@ export function createTakeScreenshotTool( { 'Y-offset in CSS pixels for the capture region. Defaults to 0 (top of page). When a previous call reports the page was clipped, pass `offset` equal to where that capture ended to fetch the next slice.', } ) ), - themeScreenshot: Type.Optional( - Type.String( { - description: - "The absolute path of a theme's directory in a Studio site (`/wp-content/themes/`): also saves the desktop capture there as screenshot.jpg, the theme's thumbnail in Appearance → Themes.", - } ) - ), }, async ( args, context ) => { try { - const themeDirectory = args.themeScreenshot && path.resolve( args.themeScreenshot ); - if ( themeDirectory && ! themeDirectory.startsWith( STUDIO_SITES_ROOT + path.sep ) ) { - throw new Error( - `themeScreenshot must be the absolute path of a theme directory in a Studio site (${ STUDIO_SITES_ROOT }//wp-content/themes/).` - ); - } const viewportTypes = resolveViewportTypes( args.viewport ); - if ( themeDirectory && ! viewportTypes.includes( 'desktop' ) ) { - throw new Error( 'themeScreenshot needs a desktop capture.' ); - } const colorSchemes = resolveColorSchemes( args.colorScheme ); const captureTargets = viewportTypes.flatMap( ( viewportType ) => colorSchemes.map( ( colorScheme ) => ( { viewportType, colorScheme } ) ) @@ -202,14 +181,6 @@ export function createTakeScreenshotTool( { captures.length === 1 ? [ `Screenshot captured — ${ captureLines[ 0 ] }` ] : [ 'Screenshots captured:', ...captureLines.map( ( line ) => `- ${ line }` ) ]; - const desktop = captures.find( ( capture ) => capture.viewportType === 'desktop' ); - if ( themeDirectory && desktop ) { - const themeScreenshot = path.join( themeDirectory, 'screenshot.jpg' ); - await copyFile( desktop.path, themeScreenshot ); - textLines.push( - `Saved the desktop capture as the theme screenshot: ${ themeScreenshot }` - ); - } if ( ! visionEnabled ) { textLines.push( TEXT_ONLY_NOTE ); } diff --git a/apps/ui/src/components/session-view/conversation/index.tsx b/apps/ui/src/components/session-view/conversation/index.tsx index d4c0d1c1d2..dc624fcc02 100644 --- a/apps/ui/src/components/session-view/conversation/index.tsx +++ b/apps/ui/src/components/session-view/conversation/index.tsx @@ -657,7 +657,6 @@ function getClassicToolDisplay( display.detail = ''; break; case 'take_screenshot': - case 'inspect_design': case 'open_annotation_browser': display.detail = ''; break; diff --git a/packages/common/ai/tests/tools.test.ts b/packages/common/ai/tests/tools.test.ts index ce506e277c..5a108b8e0d 100644 --- a/packages/common/ai/tests/tools.test.ts +++ b/packages/common/ai/tests/tools.test.ts @@ -98,7 +98,6 @@ describe( 'tool display helpers', () => { it( 'adds human names for Studio-specific visual tools', () => { expect( getToolDisplayName( 'take_screenshot' ) ).toBe( 'Take screenshot' ); - expect( getToolDisplayName( 'inspect_design' ) ).toBe( 'Inspect design' ); expect( getToolDisplayName( 'open_annotation_browser' ) ).toBe( 'Open annotation browser' ); } ); diff --git a/packages/common/ai/tools.ts b/packages/common/ai/tools.ts index ebb1b91245..66337766c1 100644 --- a/packages/common/ai/tools.ts +++ b/packages/common/ai/tools.ts @@ -312,7 +312,6 @@ export function getToolDisplayName( name: string, input?: Record< string, unknow site_connected_remote_sites: __( 'List connected remote sites' ), pick_design: __( 'Pick design' ), present_design_options: __( 'Present design options' ), - inspect_design: __( 'Inspect design' ), validate_blocks: __( 'Validate blocks' ), take_screenshot: __( 'Take screenshot' ), generate_images: __( 'Generate images' ), @@ -429,8 +428,6 @@ export function getToolDetail( name: string, input?: Record< string, unknown > ) ) .filter( Boolean ) .join( ' · ' ); - case 'inspect_design': - return typeof input.url === 'string' ? input.url : ''; case 'validate_blocks': if ( typeof input.filePath === 'string' ) { return input.filePath.split( '/' ).slice( -2 ).join( '/' ); diff --git a/plugins/wp-studio/.codex-plugin/plugin.json b/plugins/wp-studio/.codex-plugin/plugin.json index ca82bdcbb5..e441580cf2 100644 --- a/plugins/wp-studio/.codex-plugin/plugin.json +++ b/plugins/wp-studio/.codex-plugin/plugin.json @@ -62,7 +62,7 @@ { "description": "Core workflow: build a new local WordPress site from a request. Setup: none; the plugin comes with the WordPress Studio CLI. When Codex shows look and layout options, pick any. Passes when the site runs and shows a bakery home page in the chosen style.", "prompt": "Build a one-page WordPress site for Crumb & Co, a neighborhood bakery.", - "tools_triggered": "studio_instructions, site_create, pick_design, present_design_options, wp_cli, take_screenshot, inspect_design", + "tools_triggered": "studio_instructions, site_create, pick_design, present_design_options, wp_cli, take_screenshot", "expected_behavior": "Creates a local site named \"Crumb & Co\" with site_create, asks the user to choose a look and a layout with present_design_options, builds a block theme and a home page about the bakery, checks it with take_screenshot, and replies with the site URL (http://localhost:) and its WP Admin link. Opening the URL shows the bakery home page in the chosen style." }, { diff --git a/plugins/wp-studio/skills/build-site/SKILL.md b/plugins/wp-studio/skills/build-site/SKILL.md index e481eab253..7669a4fad2 100644 --- a/plugins/wp-studio/skills/build-site/SKILL.md +++ b/plugins/wp-studio/skills/build-site/SKILL.md @@ -1,6 +1,6 @@ --- name: build-site -description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the design and plugins as a Site Spec, then build the theme, provision the site, publish the content, and polish the result. Load it before picking or creating the site. +description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the design and plugins as a Site Spec, then build the theme, provision the site, and publish the content. Load it before picking or creating the site. --- 1. If you have not called the `studio_instructions` tool in this conversation yet, call it first and follow what it returns for the rest of the session. diff --git a/plugins/wp-studio/skills/edit-manage-site/SKILL.md b/plugins/wp-studio/skills/edit-manage-site/SKILL.md deleted file mode 100644 index 83eeae86dd..0000000000 --- a/plugins/wp-studio/skills/edit-manage-site/SKILL.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: edit-manage-site -description: Edit and manage one of the user's local Studio sites — answer questions about it, add pages or sections, adjust the design, add features and plugins, update content, preview it, or publish it. Load it for any work on an existing local site that is not a full build or redesign. ---- - -1. If you have not called the `studio_instructions` tool in this conversation yet, call it first and follow what it returns for the rest of the session. -2. Then call `studio_instructions` with `skill: "edit-manage-site"` and follow the runbook it returns. - -If the `wordpress-studio` tools are missing, WordPress Studio did not start: ask the user to restart the app, and to reinstall the plugin if the tools are still missing. diff --git a/skills/STUDIO.md b/skills/STUDIO.md index 3fc7aac914..005d2d6445 100644 --- a/skills/STUDIO.md +++ b/skills/STUDIO.md @@ -97,28 +97,7 @@ Studio ships an MCP (Model Context Protocol) server so an AI assistant can drive Run `studio mcp --help` for per-assistant install commands and config paths. Docs: https://developer.wordpress.com/docs/developer-tools/studio/mcp-on-studio/ -**Available tools:** - -| Tool | What it does | -|------|--------------| -| `site_create` | Create a new local WordPress site (latest WP), register it, and start it | -| `site_list` | List all Studio sites with name, path, URL, running status | -| `site_info` | Site details: status, URL, PHP version, admin credentials | -| `site_start` / `site_stop` | Start / stop a site server | -| `site_delete` | Remove a site from Studio (optionally trash its files) | -| `wp_cli` | Run a WP-CLI command against a site (e.g. `plugin install woocommerce --activate`) | -| `scaffold_theme` | Scaffold + activate a minimal block theme (`theme.json`-based) | -| `validate_blocks` | Validate block markup on a running site | -| `take_screenshot` | Full-page screenshot of a URL (desktop/mobile/all viewports) for visual verification | -| `inspect_design` | Inspect a live page's rendered DOM to diagnose visual issues | -| `need_for_speed` | Frontend performance audit (Core Web Vitals, page weight, request count) | -| `rank_me_up` | On-page SEO audit (title/meta, canonical, OG/Twitter cards, headings) | -| `install_taxonomy_scripts` | Install Taxonomist PHP scripts for `wp_cli eval-file` use | -| `open_annotation_browser` / `wait_for_annotations` | Open the annotation inspector and collect the user's element feedback | -| `preview_create` / `preview_list` / `preview_update` / `preview_delete` | Manage WordPress.com preview sites (requires `studio auth login`) | -| `site_connected_remote_sites` | List WordPress.com sites attached to a local site | -| `site_push` / `site_pull` | Push to / pull from a connected WordPress.com site (requires `studio auth login`) | -| `site_import` / `site_export` | Import/export a site backup (`.zip`, `.tar.gz`, `.sql`, `.wpress`) | +The MCP server lists its tools, each with what it does. ## Constraints From 8ca5f5094146619639188d894dc686733ff2dc21 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 23 Oct 2026 23:31:09 -0400 Subject: [PATCH 040/199] Studio Code: One registry for what Studio tells the agent, by the action that triggers it What Studio says about a local site at the moment an action makes it true ran through three pipelines with the same shape: result notes on Studio's tool results, a refusal before a file write, and notes after it, each wired into each surface by hand. site-notes.ts now lists every source by its trigger: beforeWrite (the refusal), write (block validation, the pattern cache, fonts, the theme thumbnail, and what WordPress will render differently from the file) and result (core block settings, and site facts that are new or no longer true). Each surface fires the triggers it has: Studio Code all three, studio mcp result, and the plugin's hooks on Claude Code's and Codex's file tools beforeWrite and write. A new note is one more source. Sources stay with the modules that know their part of WordPress. Every note reads "Note: ", and a refusal "Not run: ". A note source that fails says nothing; a refusal that fails stops the write. Studio Code's Write and Edit lose their wrapper: the tool runner fires the file triggers for them. --- apps/cli/ai/result-notes.ts | 38 ------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 apps/cli/ai/result-notes.ts diff --git a/apps/cli/ai/result-notes.ts b/apps/cli/ai/result-notes.ts deleted file mode 100644 index dea642d714..0000000000 --- a/apps/cli/ai/result-notes.ts +++ /dev/null @@ -1,38 +0,0 @@ -import path from 'path'; -import { createBlockSettingsNotes } from 'cli/ai/block-settings'; -import { readSiteFacts } from 'cli/ai/site-facts'; - -// The notes Studio adds to a tool result about a local site. Every surface -// that runs Studio's tools adds them, so a new kind of note is added here and -// reaches all of them: -// -// - The settings of the core blocks a result shows, once each. -// - The site's facts (site-facts.ts) the agent does not have yet, and the ones -// that stopped being true, so a change a call makes, such as a plugin -// turning on Coming soon mode, is known at once. `stated` holds the facts the -// agent already has, by site path; a site without them gets its facts with -// its first result. -export function createResultNotes( - stated: Map< string, string[] > = new Map() -): ( sitePath: string, result: string ) => Promise< string > { - const blockSettingsBySite = new Map< string, ( result: string ) => string >(); - return async ( sitePath, result ) => { - let blockSettings = blockSettingsBySite.get( sitePath ); - if ( ! blockSettings ) { - blockSettings = createBlockSettingsNotes( path.join( sitePath, 'wp-includes', 'blocks' ) ); - blockSettingsBySite.set( sitePath, blockSettings ); - } - const facts = await readSiteFacts( sitePath ); - const before = stated.get( sitePath ) ?? []; - stated.set( sitePath, facts ); - return [ - blockSettings( result ), - ...facts.filter( ( fact ) => ! before.includes( fact ) ), - ...before - .filter( ( fact ) => ! facts.includes( fact ) ) - .map( ( fact ) => `No longer true: ${ fact }` ), - ] - .filter( Boolean ) - .join( '\n' ); - }; -} From b81a6585dd1b851d972fbd9c4e03429322728595 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 23 Oct 2026 23:31:20 -0400 Subject: [PATCH 041/199] Studio Code: Add the site notes registry the previous commit's message describes 8ca5f5094 committed only the removal of result-notes.ts; this is the rest of that change: site-notes.ts, the sources split out of site-file-guards.ts, and the three surfaces firing its triggers. --- apps/cli/ai/runtimes/hooks/index.ts | 23 +-- apps/cli/ai/runtimes/mcp/index.ts | 6 +- apps/cli/ai/runtimes/mcp/served-tool.ts | 9 +- .../ai/runtimes/mcp/tests/served-tool.test.ts | 8 +- apps/cli/ai/runtimes/pi/index.ts | 43 ++--- apps/cli/ai/runtimes/pi/tool-safety.ts | 20 +-- apps/cli/ai/site-facts.ts | 2 +- apps/cli/ai/site-file-guards.ts | 146 +++++------------ apps/cli/ai/site-notes.ts | 152 ++++++++++++++++++ apps/cli/ai/tests/file-write-refusal.test.ts | 18 ++- apps/cli/ai/tests/pi-runtime.test.ts | 44 +++++ apps/cli/ai/tests/site-facts.test.ts | 26 +-- 12 files changed, 318 insertions(+), 179 deletions(-) create mode 100644 apps/cli/ai/site-notes.ts diff --git a/apps/cli/ai/runtimes/hooks/index.ts b/apps/cli/ai/runtimes/hooks/index.ts index 28528876ac..a6765ef4d8 100644 --- a/apps/cli/ai/runtimes/hooks/index.ts +++ b/apps/cli/ai/runtimes/hooks/index.ts @@ -1,9 +1,9 @@ /** * Hooks on other agents' file tools, for agents that run Studio's tools over * MCP and write files with their own, such as Claude Code and Codex with the - * WordPress Studio plugin. Before a write, the same refusal as Studio Code's - * Write and Edit; after it, the same checks and notes, and the block - * validation Studio Code runs before the markup reaches the site. + * WordPress Studio plugin. They fire the same file triggers as Studio Code's + * Write and Edit (site-notes.ts), and report the block validation Studio Code + * runs before the markup reaches the site. * * The input and output are the hook JSON both agents use: tool_input names * the file (file_path) or carries a patch (apply_patch), and @@ -11,11 +11,7 @@ */ import path from 'path'; import { BlockValidationGate } from 'cli/ai/block-validation-gate'; -import { - afterSiteFileWrite, - getSiteFileWriteRefusal, - resolveSiteFile, -} from 'cli/ai/site-file-guards'; +import { createSiteNotes } from 'cli/ai/site-notes'; export interface HookInput { hook_event_name?: string; @@ -54,9 +50,10 @@ export async function runFileToolHook( gate = new BlockValidationGate() ): Promise< HookOutput | undefined > { const files = getWrittenFiles( input ); + const siteNotes = createSiteNotes( { gate } ); if ( input.hook_event_name === 'PreToolUse' ) { for ( const file of files ) { - const reason = await getSiteFileWriteRefusal( file ); + const reason = await siteNotes.beforeWrite( file ); if ( reason ) { return { hookSpecificOutput: { @@ -74,17 +71,13 @@ export async function runFileToolHook( } const notes: string[] = []; for ( const file of files ) { - const siteFile = resolveSiteFile( file ); - if ( siteFile ) { - gate.track( file, siteFile.siteRoot ); - notes.push( ...( await afterSiteFileWrite( siteFile ) ) ); - } + notes.push( ...( await siteNotes.afterWrite( file ) ) ); } const { summaries, problems } = await gate.settle(); const context = [ ...summaries.map( ( line ) => `Block validation: ${ line }` ), ...problems, - ...notes.map( ( note ) => `Note: ${ note }` ), + ...notes, ].join( '\n' ); return context ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } } diff --git a/apps/cli/ai/runtimes/mcp/index.ts b/apps/cli/ai/runtimes/mcp/index.ts index 42b7b28c43..0cdc422c84 100644 --- a/apps/cli/ai/runtimes/mcp/index.ts +++ b/apps/cli/ai/runtimes/mcp/index.ts @@ -14,7 +14,6 @@ import { Type, type TSchema } from 'typebox'; import { Value } from 'typebox/value'; import { type DesignTracksContext } from 'cli/ai/design-tracks'; import { isImageGenerationAvailable } from 'cli/ai/image-generation'; -import { createResultNotes } from 'cli/ai/result-notes'; import { DESIGN_PICKER_HTML, DESIGN_PICKER_URI, @@ -22,6 +21,7 @@ import { } from 'cli/ai/runtimes/mcp/design-picker'; import { type AppPage, createLibraryTools, libraryPage } from 'cli/ai/runtimes/mcp/library'; import { runServedTool } from 'cli/ai/runtimes/mcp/served-tool'; +import { createSiteNotes } from 'cli/ai/site-notes'; import { loadSkills } from 'cli/ai/skills'; import { buildSystemPrompt } from 'cli/ai/system-prompt'; import { resolveStudioToolDefinitions } from 'cli/ai/tools'; @@ -129,7 +129,7 @@ export async function startMcpStdioServer(): Promise< void > { // from terminal use. The plugin's MCP config sets STUDIO_PLUGIN_ID. process.env.STUDIO_TRACKS_ORIGIN = 'studio-mcp'; const sessionId = randomUUID(); - const resultNotes = createResultNotes(); + const siteNotes = createSiteNotes(); const imageGeneration = await isImageGenerationAvailable(); const server = new Server( { name: 'studio', version: '1.0.0' }, @@ -187,7 +187,7 @@ export async function startMcpStdioServer(): Promise< void > { isError: true, }; } - return runServedTool( tool, args, resultNotes ); + return runServedTool( tool, args, siteNotes ); } ); // Only clients that render MCP Apps get the pages listed. diff --git a/apps/cli/ai/runtimes/mcp/served-tool.ts b/apps/cli/ai/runtimes/mcp/served-tool.ts index 67976f5fa9..8c597ef4cb 100644 --- a/apps/cli/ai/runtimes/mcp/served-tool.ts +++ b/apps/cli/ai/runtimes/mcp/served-tool.ts @@ -1,5 +1,5 @@ import { resolveSite } from 'cli/ai/tools/utils'; -import type { createResultNotes } from 'cli/ai/result-notes'; +import type { SiteNotes } from 'cli/ai/site-notes'; import type { AnyStudioAgentTool } from 'cli/ai/tools/define-tool'; // The site an MCP call works on is the one it names. @@ -19,7 +19,7 @@ async function siteNamed( args: Record< string, unknown > ) { export async function runServedTool( tool: AnyStudioAgentTool, args: Record< string, unknown >, - resultNotes: ReturnType< typeof createResultNotes > + siteNotes: SiteNotes ) { try { const result = await tool.rawHandler( args as never ); @@ -27,7 +27,10 @@ export async function runServedTool( .map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) .join( '\n' ); const site = await siteNamed( args ); - const notes = [ site && ( await resultNotes( site.path, resultText ) ), await result.pending ] + const notes = [ + ...( site ? await siteNotes.afterResult( site.path, resultText ) : [] ), + await result.pending, + ] .filter( Boolean ) .join( '\n' ); return { diff --git a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts index 61cb16cd24..602caf42d5 100644 --- a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts +++ b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'os'; import path from 'path'; import { Type } from 'typebox'; import { expect, it, vi } from 'vitest'; -import { createResultNotes } from 'cli/ai/result-notes'; import { runServedTool } from 'cli/ai/runtimes/mcp/served-tool'; +import { createSiteNotes } from 'cli/ai/site-notes'; import { defineTool, READ_ONLY } from 'cli/ai/tools/define-tool'; it( 'adds the declared settings of the core blocks a result shows, from the site the call names, once', async () => { @@ -27,9 +27,9 @@ it( 'adds the declared settings of the core blocks a result shows, from the site async () => ( { content: [ { type: 'text', text: '' } ] } ), { annotations: READ_ONLY } ); - const resultNotes = createResultNotes(); + const siteNotes = createSiteNotes(); const notes = async ( args: Record< string, unknown > ) => - ( await runServedTool( tool, args, resultNotes ) ).content + ( await runServedTool( tool, args, siteNotes ) ).content .slice( 1 ) .map( ( part ) => ( part.type === 'text' ? part.text : '' ) ); const blockJson = path.join( sitePath, 'wp-includes/blocks/navigation/block.json' ); @@ -37,7 +37,7 @@ it( 'adds the declared settings of the core blocks a result shows, from the site expect( await notes( {} ) ).toEqual( [] ); expect( await notes( { nameOrPath: 'Missing Site' } ) ).toEqual( [] ); expect( await notes( { nameOrPath: 'My Site' } ) ).toEqual( [ - `core/navigation settings, declared in ${ blockJson + `Note: core/navigation settings, declared in ${ blockJson .split( path.sep ) .join( '/' ) }: submenuVisibility ("hover"|"click").`, ] ); diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 88758840dd..4d62f63ea3 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -45,9 +45,8 @@ import { getBlockValidationGate, type BlockValidationGate } from 'cli/ai/block-v import { withDesignSystemPreview } from 'cli/ai/chat-artifacts'; import { isImageGenerationAvailable } from 'cli/ai/image-generation'; import { emitEvent } from 'cli/ai/json-events'; -import { createResultNotes } from 'cli/ai/result-notes'; import { readActiveSiteFacts } from 'cli/ai/site-facts'; -import { withSiteFileGuards } from 'cli/ai/site-file-guards'; +import { createSiteNotes, type SiteNotes } from 'cli/ai/site-notes'; import { buildSystemPrompt } from 'cli/ai/system-prompt'; import { resolveStudioToolDefinitions } from 'cli/ai/tools'; import { createAskUserQuestionTool } from 'cli/ai/tools/ask-user-question'; @@ -69,7 +68,7 @@ import { } from './studio-wp-shim'; import { getIncompleteToolCallReason, - getFileWriteRefusal, + getWrittenFile, getPayloadLimitViolation, type StudioToolPayloadGuardState, updateStudioToolPayloadGuardState, @@ -385,17 +384,18 @@ async function createStudioAgentSession( const blockValidationGate = getBlockValidationGate( config.session ); const sitePin = new SitePin( config.activeSite ); const site = config.activeSite; - // The Active site section already states this turn's facts. - const notes = createResultNotes( - new Map( site && ! site.remote ? [ [ site.path, activeSiteFacts ] ] : [] ) - ); - const resultNotes = - site && ! site.remote ? ( result: string ) => notes( site.path, result ) : undefined; + const localSite = site && ! site.remote ? site.path : undefined; + const siteNotes = createSiteNotes( { + // The Active site section already states this turn's facts. + stated: new Map( localSite ? [ [ localSite, activeSiteFacts ] ] : [] ), + gate: blockValidationGate, + } ); const toolDefinitions = [ ...tools, ...mcpTools ].map( ( tool ) => toToolDefinition( tool, payloadGuardState, pendingWork, blockValidationGate, { confirm, sitePin, - resultNotes, + siteNotes, + localSite, } ) ); const modelRuntime = await createModelRuntime( model, family, creds ); @@ -678,11 +678,14 @@ function toToolDefinition( { confirm, sitePin, - resultNotes, + siteNotes, + localSite, }: { confirm: Confirmer; sitePin: SitePin; - resultNotes?: ( result: string ) => Promise< string >; + siteNotes: SiteNotes; + // Studio's tools report on the active site when it is a local one. + localSite?: string; } ): ToolDefinition { return { @@ -712,7 +715,8 @@ function toToolDefinition( throw new Error( declined ); } crossSite?.allow(); - const fileWriteRefusal = await getFileWriteRefusal( tool.name, params, STUDIO_SITES_ROOT ); + const writtenFile = getWrittenFile( tool.name, params, STUDIO_SITES_ROOT ); + const fileWriteRefusal = writtenFile && ( await siteNotes.beforeWrite( writtenFile ) ); if ( fileWriteRefusal ) { throw new Error( fileWriteRefusal ); } @@ -752,7 +756,12 @@ function toToolDefinition( .map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) .join( '\n' ); const reports = - [ validationReport, await resultNotes?.( resultText ), pendingWork.takeReports() ] + [ + validationReport, + ...( writtenFile ? await siteNotes.afterWrite( writtenFile ) : [] ), + ...( localSite ? await siteNotes.afterResult( localSite, resultText ) : [] ), + pendingWork.takeReports(), + ] .filter( Boolean ) .join( '\n' ) || undefined; return reports @@ -800,11 +809,7 @@ function buildAgentTools( ( { ...tool, name, label: name } ) as AgentTool< S >; const designSystemPreview = < TTool extends AgentToolAny >( tool: TTool ) => - withSiteFileGuards( - chatArtifactsEnabled ? withDesignSystemPreview( tool, STUDIO_SITES_ROOT ) : tool, - STUDIO_SITES_ROOT, - getBlockValidationGate( config.session ) - ); + chatArtifactsEnabled ? withDesignSystemPreview( tool, STUDIO_SITES_ROOT ) : tool; const piTools: AgentToolAny[] = [ renameTool( createReadTool( STUDIO_SITES_ROOT ), 'Read' ), designSystemPreview( renameTool( createWriteTool( STUDIO_SITES_ROOT ), 'Write' ) ), diff --git a/apps/cli/ai/runtimes/pi/tool-safety.ts b/apps/cli/ai/runtimes/pi/tool-safety.ts index b9e1ed045e..a5c6a57cff 100644 --- a/apps/cli/ai/runtimes/pi/tool-safety.ts +++ b/apps/cli/ai/runtimes/pi/tool-safety.ts @@ -1,6 +1,5 @@ import os from 'os'; import path from 'path'; -import { getSiteFileWriteRefusal } from 'cli/ai/site-file-guards'; import type { AgentSessionEvent } from '@earendil-works/pi-coding-agent'; // Keep individual model-generated tool payloads below the range where long @@ -112,21 +111,16 @@ function resolveToolPath( filePath: string, cwd: string ): string { return path.resolve( cwd, expanded ); } -/** - * Refuses Write and Edit on a Studio site's files that an update replaces, - * naming where the change belongs (site-file-guards.ts). - */ -export async function getFileWriteRefusal( +/** The file a Write or Edit call writes, as an absolute path. */ +export function getWrittenFile( toolName: string, params: unknown, - cwd: string, - isStudioSite?: ( folder: string ) => Promise< boolean > -): Promise< string | undefined > { + cwd: string +): string | undefined { const filePath = getStringParam( params, 'path' ); - if ( ( toolName !== 'Write' && toolName !== 'Edit' ) || ! filePath ) { - return undefined; - } - return getSiteFileWriteRefusal( resolveToolPath( filePath, cwd ), isStudioSite ); + return ( toolName === 'Write' || toolName === 'Edit' ) && filePath + ? resolveToolPath( filePath, cwd ) + : undefined; } // Only the last content block can be mid-generation when the model hits the diff --git a/apps/cli/ai/site-facts.ts b/apps/cli/ai/site-facts.ts index bf93ba24a0..5446100550 100644 --- a/apps/cli/ai/site-facts.ts +++ b/apps/cli/ai/site-facts.ts @@ -7,7 +7,7 @@ import type { SiteInfo } from 'cli/ai/types'; // Facts about a local site, read from its own files and database, one line // each. They are what the agent would otherwise look up before acting, stated -// in the Active site section and in tool results (result-notes.ts), the same +// in the Active site section and in tool results (site-notes.ts), the same // for Studio Code and MCP agents. // // A fact is a function of the site, owned by the module that knows that part diff --git a/apps/cli/ai/site-file-guards.ts b/apps/cli/ai/site-file-guards.ts index 3b0324be1d..326421df4c 100644 --- a/apps/cli/ai/site-file-guards.ts +++ b/apps/cli/ai/site-file-guards.ts @@ -1,12 +1,8 @@ /** - * Structural guards for the agent's file tools on a local site. - * - * Instead of teaching the model WordPress rules in prose, a write to a site's - * file is checked at the moment of the change: before it, whether an update - * would replace the file; after it, the theme's pattern cache is cleared and - * the agent hears what WordPress will render differently from the file. Studio - * Code's Write and Edit and the plugin's hooks on other agents' file tools - * (runtimes/hooks) run the same checks. + * What a write to a local site's file means to WordPress: before it, whether + * an update would replace the file; after it, what Studio does for the file + * and what WordPress will render differently from it. site-notes.ts lists + * these with the trigger each runs on. */ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; import { unlink, writeFile } from 'fs/promises'; @@ -24,7 +20,6 @@ import { resolveSite } from 'cli/ai/tools/utils'; import { findSiteByFolder } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; -import type { BlockValidationGate } from 'cli/ai/block-validation-gate'; export interface SiteFile { siteRoot: string; @@ -144,14 +139,9 @@ async function isStudioSiteFolder( folder: string ): Promise< boolean > { * stays, and for WordPress trees Studio doesn't manage. */ export async function getSiteFileWriteRefusal( - filePath: string, + { siteRoot, relativePath, theme }: SiteFile, isStudioSite: ( folder: string ) => Promise< boolean > = isStudioSiteFolder ): Promise< string | undefined > { - const siteFile = resolveSiteFile( filePath ); - if ( ! siteFile ) { - return undefined; - } - const { siteRoot, relativePath, theme } = siteFile; let reason: string | undefined; if ( relativePath.startsWith( 'wp-includes/' ) || @@ -162,10 +152,7 @@ export async function getSiteFileWriteRefusal( } else if ( theme && ( await isUpdatableTheme( siteRoot, theme.slug ) ) ) { reason = `${ theme.slug } is a theme WordPress updates, and an update overwrites changes to its files. Make the change in a child theme of it, whose style.css names \`Template: ${ theme.slug }\`.`; } - if ( ! reason || ! ( await isStudioSite( siteRoot ) ) ) { - return undefined; - } - return `Not run: ${ reason }`; + return reason && ( await isStudioSite( siteRoot ) ) ? reason : undefined; } // Palette slugs whose `.has--color` class (class-wp-theme-json.php) is @@ -173,8 +160,7 @@ export async function getSiteFileWriteRefusal( // color (wp-includes/js/dist/block-editor.js). const MARKER_SLUGS = [ 'text', 'link' ]; -/** What a theme.json just written makes WordPress render differently from what it says. */ -function themeJsonNotes( themeJson: unknown, themePath: string ): string[] { +function paletteNotes( themeJson: unknown, themePath: string ): string[] { const palette = ( themeJson as { settings?: { color?: { palette?: { slug?: unknown }[] } } } ) ?.settings?.color?.palette; return ( Array.isArray( palette ) ? palette : [] ) @@ -193,40 +179,43 @@ function isBlockMarkup( { relativePath, theme }: SiteFile ): boolean { : /^tmp\/[^/]+\.html$/.test( relativePath ); } -/** What WordPress will render differently from the file just written, if anything. */ -async function getSiteFileWriteNotes( siteFile: SiteFile ): Promise< string[] > { - const { siteRoot, relativePath, theme } = siteFile; - const notes: string[] = []; - if ( isBlockMarkup( siteFile ) ) { - notes.push( - ...trappedFullWidthNotes( readFileSync( path.join( siteRoot, relativePath ), 'utf8' ) ) - ); - } - if ( ! theme ) { - return notes; - } - if ( theme.file === 'theme.json' ) { - const themeJson = JSON.parse( readFileSync( path.join( theme.dir, theme.file ), 'utf8' ) ); - notes.push( ...themeJsonNotes( themeJson, `wp-content/themes/${ theme.slug }` ) ); - } - if ( theme.file.endsWith( '.css' ) ) { - const css = readFileSync( path.join( theme.dir, theme.file ), 'utf8' ); - notes.push( ...lintThemeCss( css, await getBlockClassNames( siteRoot, theme.dir ) ) ); - } - const savedCopy = await readWordPressSite( siteRoot, ( site ) => +const readSiteFile = ( { siteRoot, relativePath }: SiteFile ) => + readFileSync( path.join( siteRoot, relativePath ), 'utf8' ); + +/** Full-width blocks in block markup just written that cannot reach the page edges. */ +export function blockMarkupNotes( siteFile: SiteFile ): string[] { + return isBlockMarkup( siteFile ) ? trappedFullWidthNotes( readSiteFile( siteFile ) ) : []; +} + +/** What a theme.json just written makes WordPress render differently from what it says. */ +export function themeJsonNotes( siteFile: SiteFile ): string[] { + const { theme } = siteFile; + return theme?.file === 'theme.json' + ? paletteNotes( JSON.parse( readSiteFile( siteFile ) ), `wp-content/themes/${ theme.slug }` ) + : []; +} + +/** CSS in a theme stylesheet just written that fights WordPress's block layout. */ +export async function themeCssNotes( siteFile: SiteFile ): Promise< string[] > { + const { siteRoot, theme } = siteFile; + return theme?.file.endsWith( '.css' ) + ? lintThemeCss( readSiteFile( siteFile ), await getBlockClassNames( siteRoot, theme.dir ) ) + : []; +} + +/** That WordPress renders a saved Site Editor copy instead of the file just written. */ +export async function savedCopyNotes( { siteRoot, relativePath }: SiteFile ): Promise< string[] > { + const note = await readWordPressSite( siteRoot, ( site ) => savedCopyWriteNote( site, path.join( siteRoot, relativePath ) ) ); - if ( savedCopy ) { - notes.push( savedCopy ); - } - return notes; + return note ? [ note ] : []; } /** * Self-hosts the fonts a theme.json just written declares without font files, * and says what it downloaded. */ -async function selfHostFonts( siteFile: SiteFile ): Promise< string[] > { +export async function selfHostFonts( siteFile: SiteFile ): Promise< string[] > { const { theme } = siteFile; if ( theme?.file !== 'theme.json' ) { return []; @@ -254,7 +243,7 @@ const THUMBNAIL = { width: 1200, height: 900 } as const; * Gives a theme without a thumbnail one: its theme.json's design board, the * 1200×900 screenshot.png Appearance → Themes shows. */ -async function writeThemeThumbnail( siteFile: SiteFile ): Promise< string[] > { +export async function writeThemeThumbnail( siteFile: SiteFile ): Promise< string[] > { const { theme } = siteFile; if ( theme?.file !== 'theme.json' || @@ -279,31 +268,15 @@ async function writeThemeThumbnail( siteFile: SiteFile ): Promise< string[] > { ]; } -/** - * After a write to a site's file: registers a changed pattern, self-hosts the - * theme's fonts, gives it a thumbnail, and returns what WordPress will render - * differently from the file. - */ -export async function afterSiteFileWrite( siteFile: SiteFile ): Promise< string[] > { - await clearThemePatternCache( siteFile ).catch( () => undefined ); - const fonts = await selfHostFonts( siteFile ).catch( () => [] ); - const thumbnail = await writeThemeThumbnail( siteFile ).catch( () => [] ); - return [ - ...fonts, - ...thumbnail, - ...( await getSiteFileWriteNotes( siteFile ).catch( () => [] ) ), - ]; -} - /** * WordPress caches the list of a theme's pattern files (outside theme * development mode), so a pattern file added or changed would not register. * Clears that theme's cache after a write to its patterns/ directory. */ -export async function clearThemePatternCache( siteFile: SiteFile ): Promise< void > { +export async function clearThemePatternCache( siteFile: SiteFile ): Promise< string[] > { const { siteRoot, theme } = siteFile; if ( ! theme || ! /^patterns\/[^/]+\.php$/.test( theme.file ) ) { - return; + return []; } const site = await resolveSite( siteRoot ); try { @@ -316,44 +289,5 @@ export async function clearThemePatternCache( siteFile: SiteFile ): Promise< voi } finally { await disconnectFromDaemon(); } -} - -interface ToolResult { - content?: Array< { type: string; text?: string } >; -} - -interface FileWritingTool { - execute( toolCallId: string, params: { path?: unknown }, ...rest: unknown[] ): Promise< unknown >; -} - -/** Wraps Write or Edit with the site file guards. */ -export function withSiteFileGuards< TTool extends FileWritingTool >( - tool: TTool, - cwd: string, - gate?: BlockValidationGate -): TTool { - return { - ...tool, - async execute( toolCallId: string, params: { path?: unknown }, ...rest: unknown[] ) { - const siteFile = - typeof params.path === 'string' - ? resolveSiteFile( path.resolve( cwd, params.path ) ) - : undefined; - const result = ( await tool.execute( toolCallId, params, ...rest ) ) as ToolResult; - if ( siteFile && gate ) { - gate.track( path.join( siteFile.siteRoot, siteFile.relativePath ), siteFile.siteRoot ); - } - const notes = siteFile ? await afterSiteFileWrite( siteFile ) : []; - if ( ! notes.length || ! result || typeof result !== 'object' ) { - return result; - } - return { - ...result, - content: [ - ...( result.content ?? [] ), - { type: 'text', text: notes.map( ( note ) => `Note: ${ note }` ).join( '\n' ) }, - ], - }; - }, - }; + return []; } diff --git a/apps/cli/ai/site-notes.ts b/apps/cli/ai/site-notes.ts new file mode 100644 index 0000000000..94625b9df3 --- /dev/null +++ b/apps/cli/ai/site-notes.ts @@ -0,0 +1,152 @@ +/** + * What Studio tells the agent about a local WordPress site, at the moment an + * action makes it true, instead of prose the agent has to remember. + * + * Each source runs on one trigger: + * + * - `beforeWrite`: a file tool is about to write a site's file. A note here + * refuses the write, and says where the change belongs. + * - `write`: a file tool wrote a site's file. Sources may act on the file + * (self-host its fonts, give its theme a thumbnail) and say what they did, + * or say what WordPress will render differently from it. + * - `result`: one of Studio's tools returned a result about a site. + * + * A new note is one more source in SOURCES, and reaches every surface that + * fires its trigger: Studio Code fires all three; `studio mcp` fires `result`; + * the WordPress Studio plugin's hooks on Claude Code's and Codex's own file + * tools fire `beforeWrite` and `write`. + */ +import path from 'path'; +import { createBlockSettingsNotes } from 'cli/ai/block-settings'; +import { readSiteFacts } from 'cli/ai/site-facts'; +import { + blockMarkupNotes, + clearThemePatternCache, + getSiteFileWriteRefusal, + resolveSiteFile, + savedCopyNotes, + selfHostFonts, + themeCssNotes, + themeJsonNotes, + writeThemeThumbnail, + type SiteFile, +} from 'cli/ai/site-file-guards'; +import type { BlockValidationGate } from 'cli/ai/block-validation-gate'; + +type Notes = string[] | Promise< string[] >; + +interface Sources { + beforeWrite: Array< ( file: SiteFile ) => Notes >; + write: Array< ( file: SiteFile ) => Notes >; + result: Array< ( sitePath: string, text: string ) => Notes >; +} + +interface SiteNotesOptions { + /** The facts the agent already has, by site path. */ + stated?: Map< string, string[] >; + /** Validates the block markup a write leaves, for the surface to report. */ + gate?: BlockValidationGate; +} + +// The settings of the core blocks a result shows, once each per site. +function blockSettingsSource(): Sources[ 'result' ][ number ] { + const bySite = new Map< string, ( text: string ) => string >(); + return ( sitePath, text ) => { + let describe = bySite.get( sitePath ); + if ( ! describe ) { + describe = createBlockSettingsNotes( path.join( sitePath, 'wp-includes', 'blocks' ) ); + bySite.set( sitePath, describe ); + } + return [ describe( text ) ].filter( Boolean ); + }; +} + +// The site's facts the agent does not have yet, and the ones that stopped +// being true, so a change a call makes, such as a plugin turning on Coming +// soon mode, is known at once. A site without stated facts gets its facts +// with its first result. +function siteFactsSource( stated: Map< string, string[] > ): Sources[ 'result' ][ number ] { + return async ( sitePath ) => { + const facts = await readSiteFacts( sitePath ); + const before = stated.get( sitePath ) ?? []; + stated.set( sitePath, facts ); + return [ + ...facts.filter( ( fact ) => ! before.includes( fact ) ), + ...before + .filter( ( fact ) => ! facts.includes( fact ) ) + .map( ( fact ) => `No longer true: ${ fact }` ), + ]; + }; +} + +function sources( { stated = new Map(), gate }: SiteNotesOptions ): Sources { + return { + beforeWrite: [ + async ( file ) => { + const refusal = await getSiteFileWriteRefusal( file ); + return refusal ? [ refusal ] : []; + }, + ], + write: [ + ( { siteRoot, relativePath } ) => { + gate?.track( path.join( siteRoot, relativePath ), siteRoot ); + return []; + }, + clearThemePatternCache, + selfHostFonts, + writeThemeThumbnail, + blockMarkupNotes, + themeJsonNotes, + themeCssNotes, + savedCopyNotes, + ], + result: [ blockSettingsSource(), siteFactsSource( stated ) ], + }; +} + +// A source that fails says nothing, so the action it follows stands. +async function collect( notes: Array< () => Notes > ): Promise< string[] > { + const lines: string[] = []; + for ( const note of notes ) { + lines.push( + ...( await Promise.resolve() + .then( note ) + .catch( () => [] ) ) + ); + } + return lines; +} + +export interface SiteNotes { + /** Why a write to this file must not happen, or undefined. */ + beforeWrite( filePath: string ): Promise< string | undefined >; + /** The notes on a write to this file. */ + afterWrite( filePath: string ): Promise< string[] >; + /** The notes on a Studio tool's result about this site. */ + afterResult( sitePath: string, text: string ): Promise< string[] >; +} + +/** One agent session's notes: sources that say a thing once keep that here. */ +export function createSiteNotes( options: SiteNotesOptions = {} ): SiteNotes { + const { beforeWrite, write, result } = sources( options ); + const asNotes = ( lines: string[] ) => lines.map( ( line ) => `Note: ${ line }` ); + return { + async beforeWrite( filePath ) { + // Unlike a note, a refusal that fails stops the write. + const file = resolveSiteFile( filePath ); + const refusals = file + ? ( await Promise.all( beforeWrite.map( ( source ) => source( file ) ) ) ).flat() + : []; + return refusals.length ? `Not run: ${ refusals.join( ' ' ) }` : undefined; + }, + async afterWrite( filePath ) { + const file = resolveSiteFile( filePath ); + return file + ? asNotes( await collect( write.map( ( source ) => () => source( file ) ) ) ) + : []; + }, + async afterResult( sitePath, text ) { + return asNotes( await collect( result.map( ( source ) => () => source( sitePath, text ) ) ) ); + }, + }; +} diff --git a/apps/cli/ai/tests/file-write-refusal.test.ts b/apps/cli/ai/tests/file-write-refusal.test.ts index ae1603278d..fbf65358f8 100644 --- a/apps/cli/ai/tests/file-write-refusal.test.ts +++ b/apps/cli/ai/tests/file-write-refusal.test.ts @@ -4,7 +4,8 @@ import os from 'os'; import path from 'path'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getFileWriteRefusal } from 'cli/ai/runtimes/pi/tool-safety'; +import { getWrittenFile } from 'cli/ai/runtimes/pi/tool-safety'; +import { getSiteFileWriteRefusal, resolveSiteFile } from 'cli/ai/site-file-guards'; import { findSiteByFolder } from 'cli/lib/cli-config/sites'; vi.mock( 'cli/lib/cli-config/sites', () => ( { findSiteByFolder: vi.fn() } ) ); @@ -35,7 +36,20 @@ afterEach( () => { vi.mocked( findSiteByFolder ).mockReset(); } ); -describe( 'getFileWriteRefusal', () => { +// What Studio Code's Write and Edit hear before writing: the refusal of the +// file a call writes. +async function getFileWriteRefusal( + toolName: string, + params: unknown, + cwd: string, + isStudioSite?: ( folder: string ) => Promise< boolean > +) { + const file = getWrittenFile( toolName, params, cwd ); + const siteFile = file && resolveSiteFile( file ); + return siteFile ? getSiteFileWriteRefusal( siteFile, isStudioSite ) : undefined; +} + +describe( 'the file write refusal', () => { it.each( [ 'my-site/wp-includes/functions.php', 'my-site/wp-admin/includes/file.php', diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index cad3bb576b..5b48e383db 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -52,6 +52,16 @@ vi.mock( '@earendil-works/pi-coding-agent', async ( importOriginal ) => { }; } ); +// Each trigger answers with what it was fired with. +vi.mock( 'cli/ai/site-notes', () => ( { + createSiteNotes: () => ( { + beforeWrite: async ( file: string ) => + file.endsWith( 'refused.php' ) ? `Not run: ${ file }` : undefined, + afterWrite: async ( file: string ) => [ `Note: wrote ${ file }` ], + afterResult: async ( site: string ) => [ `Note: result on ${ site }` ], + } ), +} ) ); + vi.mock( 'cli/lib/site-paths', () => ( { STUDIO_SITES_ROOT: mocks.studioRoot, getDefaultSitePath: ( siteName: string ) => `${ mocks.studioRoot }/${ siteName }`, @@ -472,6 +482,40 @@ describe( 'pi runtime', () => { expect( onAskUser ).toHaveBeenCalledTimes( 1 ); } ); + it( 'fires the site notes triggers on a write, and on a result for a local active site', async () => { + const run = async ( activeSite: StudioAgentTurnConfig[ 'activeSite' ] ) => { + mocks.createdSessions.length = 0; + await runRuntime( { prompt: 'hello', env: WPCOM_ENV, session: newSession(), activeSite } ); + return getCreatedTool( 'Write' ); + }; + const local = { + name: 'A', + path: '/sites/a', + running: false, + } as StudioAgentTurnConfig[ 'activeSite' ]; + const write = await run( local ); + const file = `${ mocks.studioRoot }/a/style.css`; + const text = async ( params: Record< string, unknown > ) => + ( await write.execute( 'w', params ) ).content.map( ( part ) => part.text ); + + await expect( write.execute( 'w', { path: 'a/refused.php', content: '' } ) ).rejects.toThrow( + `Not run: ${ mocks.studioRoot }/a/refused.php` + ); + expect( await text( { path: 'a/style.css', content: '' } ) ).toEqual( [ + '', + `Note: wrote ${ file }\nNote: result on /sites/a`, + ] ); + const remote = await run( { + ...local!, + remote: true, + } as StudioAgentTurnConfig[ 'activeSite' ] ); + expect( + ( await remote.execute( 'w', { path: 'a/style.css', content: '' } ) ).content.map( + ( part ) => part.text + ) + ).toEqual( [ '', `Note: wrote ${ file }` ] ); + } ); + it( 'rejects oversized direct Write, Edit, and Bash payloads', async () => { await runRuntime( { prompt: 'hello', diff --git a/apps/cli/ai/tests/site-facts.test.ts b/apps/cli/ai/tests/site-facts.test.ts index 424f49e406..a083a435ee 100644 --- a/apps/cli/ai/tests/site-facts.test.ts +++ b/apps/cli/ai/tests/site-facts.test.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest'; -import { createResultNotes } from '../result-notes'; import { readActiveSiteFacts } from '../site-facts'; +import { createSiteNotes } from '../site-notes'; import { createSite as createWordPressSite } from './fixtures/wordpress-site'; function createSite( prefix: string ) { @@ -92,28 +92,28 @@ it( 'has no facts for a WordPress.com site without a known site ID, or no site', it( 'adds to a tool result the facts the agent does not have yet and the ones no longer true', async () => { const site = createSite( 'wp_' ); - const notes = createResultNotes( new Map( [ [ site.root, await site.facts() ] ] ) ); + const siteNotes = createSiteNotes( { stated: new Map( [ [ site.root, await site.facts() ] ] ) } ); + const notes = ( session = siteNotes ) => session.afterResult( site.root, '' ); + const said = ( ...lines: string[] ) => lines.map( ( line ) => `Note: ${ line }` ); // The facts the Active site section stated. - expect( await notes( site.root, '' ) ).toBe( '' ); + expect( await notes() ).toEqual( [] ); // A call activates WooCommerce, which turns on Coming soon mode. site.setOptions( { woocommerce_coming_soon: 'yes', ...WOOCOMMERCE_ACTIVE } ); - expect( await notes( site.root, '' ) ).toBe( - [ + expect( await notes() ).toEqual( + said( 'Active plugins: woocommerce/woocommerce.php, hello.php.', COMING_SOON( 'the site' ), - 'No longer true: Active plugins: none.', - ].join( '\n' ) + 'No longer true: Active plugins: none.' + ) ); - expect( await notes( site.root, '' ) ).toBe( '' ); + expect( await notes() ).toEqual( [] ); site.setOptions( { woocommerce_coming_soon: 'no' } ); - expect( await notes( site.root, '' ) ).toBe( `No longer true: ${ COMING_SOON( 'the site' ) }` ); + expect( await notes() ).toEqual( said( `No longer true: ${ COMING_SOON( 'the site' ) }` ) ); // A site the agent has no facts for, as over MCP, gets them with its first result. - expect( await createResultNotes()( site.root, '' ) ).toBe( - [ ...BASE.slice( 0, 2 ), 'Active plugins: woocommerce/woocommerce.php, hello.php.' ].join( - '\n' - ) + expect( await notes( createSiteNotes() ) ).toEqual( + said( ...BASE.slice( 0, 2 ), 'Active plugins: woocommerce/woocommerce.php, hello.php.' ) ); } ); From 9e7d07a5b4f7c8ddd76c6d756443618e38fe3df6 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 23 Oct 2026 23:51:06 -0400 Subject: [PATCH 042/199] Studio Code: Build a site in five steps, with the tools saying what comes next build-site was a runbook: the order of the design steps, how to ask, the AskUserQuestion limits its schema already enforces, when to skip the questions, and a Site Spec summary with required lines, a ritual before the build that slows the iteration it should leave to the user. imagery repeated what generate_images' own descriptions and failure results say. build-site is now its five steps. pick_design's description takes the rules for calling it, and each draw ends with what follows: how to ask the user, then the image set and the layout after a look, or the theme after a layout. generate_images' parameter descriptions take the spec tips. imagery keeps which images the design steps need and where they go. A new write note replaces "never use a web URL": block markup or theme CSS that shows an image from a host other than the site's is told it breaks when that host moves it, and that WordPress gives responsive sizes only to media library images (wp_filter_content_tags in wp-includes/media.php). --- .../hooks/tests/file-tool-hook.test.ts | 42 +++++++++++++ apps/cli/ai/site-file-guards.ts | 30 +++++++++ apps/cli/ai/site-notes.ts | 2 + apps/cli/ai/skills/build-site/SKILL.md | 62 ++----------------- apps/cli/ai/skills/imagery/SKILL.md | 24 ++----- apps/cli/ai/tests/tools.test.ts | 25 ++++++++ apps/cli/ai/tools/generate-images.ts | 6 +- apps/cli/ai/tools/pick-design.ts | 19 +++++- plugins/wp-studio/skills/build-site/SKILL.md | 2 +- scripts/eval/promptfoo.config.yaml | 2 +- 10 files changed, 134 insertions(+), 80 deletions(-) diff --git a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts index 18b215e127..b4d0a1a2bf 100644 --- a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts +++ b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts @@ -224,3 +224,45 @@ it.each( [ }, 60_000 ); + +// An image shown from a host other than the site's, in block markup or theme CSS. +it.each( [ + { + file: 'templates/index.html', + content: + '
', + host: 'images.example.com', + }, + { + file: 'templates/index.html', + content: '', + host: undefined, + }, + { + file: 'style.css', + content: + '/*\nTheme Name: Bakery\n*/\n.hero { background: #000 url("https://cdn.example.org/b.jpg"); }', + host: 'cdn.example.org', + }, + { + file: 'style.css', + content: + '/*\nTheme Name: Bakery\n*/\n@font-face { src: url(https://fonts.example.com/a.woff2); }', + host: undefined, + }, +] )( 'notes images in $file from $host', async ( { file, content, host } ) => { + site.setOptions( { siteurl: 'http://localhost:8881' } ); + const written = path.join( site.root, 'wp-content/themes/bakery', file ); + mkdirSync( path.dirname( written ), { recursive: true } ); + writeFileSync( written, content ); + const output = await runFileToolHook( { + hook_event_name: 'PostToolUse', + tool_name: 'Write', + tool_input: { file_path: written }, + } ); + const notes = JSON.stringify( output ?? {} ); + expect( notes.includes( 'which this site does not hold' ) ).toBe( Boolean( host ) ); + expect( + notes.includes( `wp-content/themes/bakery/${ file } shows images from ${ host },` ) + ).toBe( Boolean( host ) ); +} ); diff --git a/apps/cli/ai/site-file-guards.ts b/apps/cli/ai/site-file-guards.ts index 326421df4c..f802af7a48 100644 --- a/apps/cli/ai/site-file-guards.ts +++ b/apps/cli/ai/site-file-guards.ts @@ -203,6 +203,36 @@ export async function themeCssNotes( siteFile: SiteFile ): Promise< string[] > { : []; } +const IMAGE_SOURCE = + /]*?\bsrc="(https?:\/\/[^"]+)"|background(?:-image)?\s*:[^;}]*?url\(\s*['"]?(https?:\/\/[^'")\s]+)/gi; + +/** Images that block markup or a theme stylesheet just written shows from hosts other than the site. */ +export async function externalImageNotes( siteFile: SiteFile ): Promise< string[] > { + if ( ! isBlockMarkup( siteFile ) && ! siteFile.theme?.file.endsWith( '.css' ) ) { + return []; + } + const siteUrl = await readWordPressSite( siteFile.siteRoot, ( site ) => + site.option( 'siteurl' ) + ); + if ( typeof siteUrl !== 'string' ) { + return []; + } + const siteHost = new URL( siteUrl ).host; + const hosts = new Set( + [ ...readSiteFile( siteFile ).matchAll( IMAGE_SOURCE ) ] + .map( ( match ) => match[ 1 ] ?? match[ 2 ] ) + .map( ( url ) => ( URL.canParse( url ) ? new URL( url ).host : undefined ) ) + .filter( ( host ) => host && host !== siteHost ) + ); + return hosts.size + ? [ + `${ siteFile.relativePath } shows images from ${ [ ...hosts ].join( + ', ' + ) }, which this site does not hold: they break when that host moves or blocks them, and WordPress gives responsive sizes only to images in its media library, by their wp-image- class (wp_filter_content_tags in wp-includes/media.php). Generate them, or import them with \`wp media import\`, and use their URLs and attachment IDs.`, + ] + : []; +} + /** That WordPress renders a saved Site Editor copy instead of the file just written. */ export async function savedCopyNotes( { siteRoot, relativePath }: SiteFile ): Promise< string[] > { const note = await readWordPressSite( siteRoot, ( site ) => diff --git a/apps/cli/ai/site-notes.ts b/apps/cli/ai/site-notes.ts index 94625b9df3..c382f497d4 100644 --- a/apps/cli/ai/site-notes.ts +++ b/apps/cli/ai/site-notes.ts @@ -22,6 +22,7 @@ import { readSiteFacts } from 'cli/ai/site-facts'; import { blockMarkupNotes, clearThemePatternCache, + externalImageNotes, getSiteFileWriteRefusal, resolveSiteFile, savedCopyNotes, @@ -98,6 +99,7 @@ function sources( { stated = new Map(), gate }: SiteNotesOptions ): Sources { blockMarkupNotes, themeJsonNotes, themeCssNotes, + externalImageNotes, savedCopyNotes, ], result: [ blockSettingsSource(), siteFactsSource( stated ) ], diff --git a/apps/cli/ai/skills/build-site/SKILL.md b/apps/cli/ai/skills/build-site/SKILL.md index 96624f6262..b5290d930b 100644 --- a/apps/cli/ai/skills/build-site/SKILL.md +++ b/apps/cli/ai/skills/build-site/SKILL.md @@ -1,64 +1,14 @@ --- name: build-site -description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the design and plugins as a Site Spec, then build the theme, provision the site, and publish the content. Load it before picking or creating the site. +description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the look and the layout with the user, then build the theme, provision the site, and publish the content. Load it before picking or creating the site. user-invokable: true tools: pick_design, present_design_options, generate_images --- # Build a Site -Follow these steps in order. Run every step even when the prompt already answers the discovery questions: skip the questions, but still produce the Site Spec. - -## 1. Pick or create the site - -Build on the active site. When there is none, or the request names another site, call site_create; Studio asks the user before it creates a site while one is active. If the prompt gives no name, ask for one and wait for the reply. - -## 2. Site Spec - -Before building, 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. - -**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, just ask in your text output — the user will type their answer in the prompt. - -Use AskUserQuestion for: -- One-page site or multi-page site? (e.g., single scrollable page with sections vs. separate pages for each area) - -The business/brand name is the active site's name unless the prompt gives a different one. Do not ask for it. The one exception: when the site name is clearly a placeholder (e.g. "test", "Site 1") and the prompt gives no brand, ask for the brand name in your text output and **stop and wait for the reply** — do NOT call any tools in that turn. - -A new site's spec needs nothing from the site itself: do not search its files or list its plugins and themes before the design is settled. - -### The design - -Load the `visual-design` skill first — the catalogs, the look as theme.json, and the sneak-peek rules live there — and follow its "Concept and Direction" runbook. The design is settled in two steps, the look and then the layout, each with one `pick_design` call, repeated only when the user asks for other options: - -- Pass `options: 4` so the user picks, or `options: 1` when there is nothing to pick — the user asked to be surprised or to skip the questions, or the brief names that step's catalog entry. Neither a page count ("one page") nor adjectives ("elegant", "warm") pick an entry: ask, and apply them to the options you choose. For a reference site, look at it once with `take_screenshot` (`display: false`) and choose its closest entries, with `options` set to their number: two when `present_design_options` is available, otherwise one. -- With one entry, use it without asking. With more, ask once, one option per entry in the order returned, labelled with the entry's name, without describing the options in prose first. A typed answer ("2 but darker") is a preference to apply to the closest option; asking for other options, as a choice or in their own words, means drawing that step again and asking the same way. - -1. **The look**: `catalog: "directions"`, asking "Which look should I build?". Skip this step when the site keeps its own theme and the user did not ask for a new look. - - If `present_design_options` is available: when you can make images (`generate_images`, or an image tool of your own), load the `imagery` skill and make the look image first. Pass each option's theme.json draft (see the `visual-design` skill) as its `preview`, with that image and a one-line description of the feel. - - Otherwise use `AskUserQuestion`, each option with a one-line description of the look. - - Then, when you can make images (`generate_images`, or an image tool of your own), load the `imagery` skill and make the site's image set in that look so the layout previews and the build have it. -2. **The layout**: `catalog: "layouts"`, asking "Which layout should I build?". - - If `present_design_options` is available: pass one sneak peek per option, in the picked look and with the site's image set (see the `visual-design` skill), as its `preview`, with a one-line description of the layout. - - Otherwise use `AskUserQuestion`, each option with a one-line description of how its first screen would look. - -### The Site Spec - -Before step 3, state the plan in your text output as a short **Site Spec** summary. The summary MUST include a **Concept** line — the signature layout concept settled by `pick_design` or picked by the user, as `Concept: — `, the entry's name verbatim so the user can find it, then the twist — followed by a **Layout map** (as the `visual-design` skill defines it: one line per section of the page saying what the concept does to it), a **Direction** line — the artistic direction behind the theme.json, as `Direction: — ` — and a **Functionality & plugins** line. A side designed from the brief rather than the catalog is stated as `Concept: — ` or `Direction: — `. For the plugins line: review the requested features, load the `plugin-recommendations` skill, and list the specific plugins the site needs — e.g. WooCommerce for selling products, Jetpack Forms for a contact form, Jetpack Newsletter for email signups, Sensei LMS for courses, Crowdsignal for polls/surveys — or "None — core blocks only" when nothing beyond static content is required. Install the listed plugins in step 4; do not silently hand-build static markup for a feature a plugin should provide. - -### When to Skip the Questions - -Skipping means skipping the interactive questions only — each design step then returns a single entry, and you still produce the Site Spec summary (including the Functionality & plugins line) before building. Do NOT ask questions if: -- The user says "just build something" or "surprise me". Pick a bold creative direction yourself and proceed. -- The user explicitly asks to skip the setup or says they don't want questions. - -## 3. Theme files - -Write a block theme in `wp-content/themes/`, or a child theme to restyle an installed theme, one file at a time: style.css, theme.json, templates, parts, patterns. Its theme.json is the picked look's: copy the file `present_design_options` named, or write the one settled look. - -## 4. Provision the site - -With `wp`, activate the theme, install and activate the Site Spec's plugins, and set options, before writing page content. - -## 5. Publish the content - -Write each page's content to a scratch file in the site's `tmp/`, then create or update the page from the file. +1. **The site**: the active site, or `site_create` when there is none or the request names another site. +2. **The design**: load the `visual-design` skill, then settle the look and the layout with `pick_design`; each result says what comes next. A site that keeps its own theme, without a request for a new look, skips the look. +3. **The theme**: a block theme in `wp-content/themes/`, or a child theme to restyle an installed theme — style.css, the picked look as theme.json, templates, parts, patterns. +4. **Provisioning**: with `wp`, activate the theme, install and activate the plugins the site's features need (the `plugin-recommendations` skill), and set options. +5. **The content**: each page's content as a scratch file in the site's `tmp/`, then the page created or updated from it. diff --git a/apps/cli/ai/skills/imagery/SKILL.md b/apps/cli/ai/skills/imagery/SKILL.md index 171a194fe8..295a897c55 100644 --- a/apps/cli/ai/skills/imagery/SKILL.md +++ b/apps/cli/ai/skills/imagery/SKILL.md @@ -1,6 +1,6 @@ --- name: imagery -description: Generate AI images for a site with generate_images, or with an image tool of your own when it is not available — the images the design steps need, where files go, how to write image specs, and how to handle failures. +description: Generate AI images for a site with generate_images, or with an image tool of your own when it is not available — the images the design steps need, where files go, and how to write image specs. tools: generate_images --- @@ -8,27 +8,15 @@ tools: generate_images ## Images for the design steps -- **The look image**: before the look options, one image of the site's first-screen scene in `landscape` at `/wp-content/uploads/.png`, with a neutral grade in `pageContext` and no `imageGrade`. Pass its path as each look's `image`. A direction that rejects photography passes no image. -- **The site's set**: once the look is picked, one call with 3–4 images in it (the direction's Imagery line as `imageGrade`): the first-screen scene plus distinct supporting subjects, each in the aspect ratio of its likely slot. The sneak peeks and the build use this set first; generate more only for slots it cannot fill, with the same `imageGrade`. +- **The look image**: one image of the site's first-screen scene, in `landscape` at `/wp-content/uploads/.png`, with a neutral grade in `pageContext` and no `imageGrade`, passed as each look option's `image`. A direction that rejects photography passes none. +- **The site's set**: once the look is picked, 3–4 images in one call, with the direction's Imagery line as `imageGrade`: the first-screen scene plus distinct supporting subjects, each in the aspect ratio of its likely slot. The sneak peeks and the build use this set first. -## Placing images +## Where images go -- **Content imagery** (anything shown by page or post content, heroes included): `/wp-content/uploads/.png`. The tool adds it to the media library; use the reported URL as `src` and the attachment ID in the block attributes. +- **Content imagery** (shown by page or post content, heroes included): `/wp-content/uploads/.png`, added to the media library; its URL is the `src` and its attachment ID goes in the block attributes. Give it real alt text. - **Theme imagery** (shown by a template, part, or `style.css`): `/wp-content/themes//assets/images/.png`, referenced by that path. -- Batch every image a page needs into one call, before writing the markup that uses them. Give content images real alt text. - -## Writing specs - -- `subject`: 1–3 sentences on what the image shows and its composition. Never ask for text, signage, or the business name; image models garble lettering. -- `pageContext`: the slot in photographic terms, in English (`full-frame photograph with the left third kept as open, low-detail space`), never layout words like "hero" or "headline". -- `aspectRatio`: the slot's shape; images in one row or grid share a ratio, and full-bleed backgrounds are `landscape` or `ultrawide`. -- `imageGrade`: one site-wide treatment, identical in every call. -- Generate content imagery only — no icons, ornaments, or logos. +- Content imagery only — icons, ornaments, and logos are not generated. ## Without generate_images With an image tool of your own, make the same images with the same intent, then `wp media import` content images from the site's `tmp/` (it prints attachment IDs) and copy theme images into the theme. - -## Failures - -Rewrite a safety-filtered subject and retry once. If an image still fails, adapt the layout (color or text-led) — never use a web URL or reference a missing file. diff --git a/apps/cli/ai/tests/tools.test.ts b/apps/cli/ai/tests/tools.test.ts index 47879ed98f..f5cf1c7e51 100644 --- a/apps/cli/ai/tests/tools.test.ts +++ b/apps/cli/ai/tests/tools.test.ts @@ -7,6 +7,7 @@ import { fetchSyncableSites } from '@studio/common/lib/sync/sync-api'; import { vi } from 'vitest'; import { getSharedBrowser } from 'cli/ai/browser-utils'; import { setChatArtifactCallback } from 'cli/ai/chat-artifacts'; +import { loadDesignCatalog } from 'cli/ai/design-catalog'; import { generateImages, isImageGenerationAvailable } from 'cli/ai/image-generation'; import { emitEvent } from 'cli/ai/json-events'; import { setScreenshotDirectoryProvider } from 'cli/ai/screenshot-storage'; @@ -271,6 +272,30 @@ describe( 'Studio AI MCP tools', () => { expect( text ).toContain( 'cannot be asked in this session' ); } ); + // Each draw says what follows it, so the design steps need no runbook. + it.each( [ + { canAskUser: false, catalog: 'layouts', options: 1, next: 'Next: write the theme.' }, + { canAskUser: false, catalog: 'directions', options: 1, next: 'in this look' }, + { canAskUser: true, catalog: 'directions', options: 4, next: 'Once picked, make the' }, + { canAskUser: true, catalog: 'layouts', options: 4, next: 'Once picked, write the theme.' }, + ] )( + 'pick_design says what follows a $catalog draw of $options', + async ( { canAskUser, catalog, options, next } ) => { + const tool = resolveStudioToolDefinitions( { canAskUser } ).find( + ( t ) => t.name === 'pick_design' + )!; + const chosen = + catalog === 'directions' + ? loadDesignCatalog( 'directions' ) + .slice( 0, options ) + .map( ( { name } ) => ( { name, reason: 'Fits.' } ) ) + : undefined; + expect( getTextContent( await executeTool( tool, { catalog, options, chosen } ) ) ).toContain( + next + ); + } + ); + it( 'refuses to guess between sites that share a name', async () => { vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ mockSite, { ...mockSite, id: 'site-456', path: '/other/my-site' } ], diff --git a/apps/cli/ai/tools/generate-images.ts b/apps/cli/ai/tools/generate-images.ts index 44e8240f11..14abbbe2d6 100644 --- a/apps/cli/ai/tools/generate-images.ts +++ b/apps/cli/ai/tools/generate-images.ts @@ -89,7 +89,7 @@ foreach ( $data['failed'] as $id ) { export const generateImagesTool = defineTool( 'generate_images', "Generate AI images (PNG) from text specs and write them to files inside a site. An image written under the site's wp-content/uploads/ is added to its media library; any other image, such as theme imagery in a theme's assets/images, stays where it is written. " + - 'Load the `imagery` skill FIRST — it defines how to write subjects and page context, which aspect ratio fits which layout slot, and where generated images go (theme assets or the media library). ' + + 'The `imagery` skill says which images the design steps need and where they go. ' + 'Batch every image a page or site needs into as few calls as possible; each call accepts up to ' + `${ MAX_IMAGES_PER_CALL } images and generates them concurrently. ` + "The call returns at once with each image's path, and for the media library its attachment ID and URL, while the images are generated in the background: the tools that render the site wait for them, and any that failed is reported with a later tool result. " + @@ -108,7 +108,7 @@ export const generateImagesTool = defineTool( pageContext: Type.Optional( Type.String( { description: - 'Where and how the image is used, in pictorial slot language (e.g. "full-frame editorial photograph with the left third kept as open, low-detail negative space"). Steers mood and composition; it is not drawn. Write in English.', + 'Where and how the image is used, in pictorial slot language (e.g. "full-frame editorial photograph with the left third kept as open, low-detail negative space"), not layout words such as "hero" or "headline", which get drawn. Steers mood and composition; it is not drawn. Write in English.', } ) ), style: Type.Optional( @@ -121,7 +121,7 @@ export const generateImagesTool = defineTool( Object.fromEntries( IMAGE_ASPECT_RATIOS.map( ( ratio ) => [ ratio, ratio ] ) ), { description: - 'Canvas shape, matched to the layout slot (see the imagery skill). Defaults to landscape.', + 'Canvas shape, matched to the layout slot: images in one row or grid share a ratio, and full-bleed backgrounds are landscape or ultrawide. Defaults to landscape.', } ) ), diff --git a/apps/cli/ai/tools/pick-design.ts b/apps/cli/ai/tools/pick-design.ts index a50b4cf3bb..516337810e 100644 --- a/apps/cli/ai/tools/pick-design.ts +++ b/apps/cli/ai/tools/pick-design.ts @@ -14,6 +14,22 @@ import { defineTool, READ_ONLY } from './define-tool'; import { textResult } from './utils'; import type { AnyStudioAgentTool } from './define-tool'; +// What follows a draw, so the design steps are not a runbook to remember. +function nextStep( catalog: 'directions' | 'layouts', toPick: boolean ): string { + const after = + catalog === 'directions' + ? 'make the site\'s image set in that look if you can make images (the imagery skill), then draw the layout with `catalog: "layouts"`' + : 'write the theme'; + if ( ! toPick ) { + return `Next: ${ after.replace( 'that look', 'this look' ) }.`; + } + const preview = + catalog === 'directions' + ? "each option's theme.json as its preview, with the look image if you can make images (the imagery skill)" + : 'a sneak peek of each option in the picked look as its preview'; + return `Next: ask the user once, one option per entry in this order, labelled with its name, without describing them in prose first: with present_design_options (${ preview }), or else AskUserQuestion with a one-line description each. A typed answer such as "2 but darker" is a preference to apply to the closest option; asking for other options means drawing again. Once picked, ${ after }.`; +} + // Without a question tool (MCP, non-interactive runs) there is nobody to pick, // so the schema offers a single entry and a stray `options: 4` is coerced. export function createPickDesignTool( { @@ -27,7 +43,7 @@ export function createPickDesignTool( { return defineTool( 'pick_design', canAskUser - ? `Returns entries from one catalog of the visual-design skill (load it first: names must match its catalogs exactly), each with its full notes: "directions" for the look, "layouts" for the page structure. \`options\` is how many come back: \`options: ${ DESIGN_OPTIONS }\` for the user to pick from, \`options: 1\` to build directly. Directions are all yours: pass exactly that many in \`chosen\`, each with a one-line reason; none is drawn at random. Layouts mix yours with chance: up to ${ MAX_CHOSEN_LAYOUTS } in \`chosen\`, and the rest are drawn at random and shuffled in so the user cannot tell which is which. Call once per catalog, and again only when the user asks for other options: what they have seen is left out until the catalog runs low. Build what is returned or picked.` + ? `Returns entries from one catalog of the visual-design skill (load it first: names must match its catalogs exactly), each with its full notes: "directions" for the look, "layouts" for the page structure; the look is settled first. \`options\` is how many come back: \`options: ${ DESIGN_OPTIONS }\` for the user to pick from, \`options: 1\` to build directly, when the user asked to be surprised or to skip the questions, or the brief names that catalog's entry. A page count ("one page") or adjectives ("elegant") name no entry: apply them to the entries you choose. For a reference site, choose its closest entries. Directions are all yours: pass exactly that many in \`chosen\`, each with a one-line reason; none is drawn at random. Layouts mix yours with chance: up to ${ MAX_CHOSEN_LAYOUTS } in \`chosen\`, and the rest are drawn at random and shuffled in so the user cannot tell which is which. Call once per catalog, and again only when the user asks for other options: what they have seen is left out until the catalog runs low. Build what is returned or picked.` : 'Returns one entry from one catalog of the visual-design skill (load it first: names must match its catalogs exactly), with its full notes: "directions" for the look, "layouts" for the page structure. The user cannot be asked in this session, so there is nothing to pick from: build what is returned. For directions, pass the one you will build in `chosen`, with a reason. For layouts, pass one in `chosen` only when the brief names it or a reference site points to it; otherwise one is drawn at random. Call once per catalog.', { catalog: Type.Union( [ Type.Literal( 'directions' ), Type.Literal( 'layouts' ) ], { @@ -94,6 +110,7 @@ export function createPickDesignTool( { 'The user cannot be asked in this session, so one entry came back instead of options to pick from. Build it.' ); } + sections.push( nextStep( args.catalog, entries.length > 1 ) ); return textResult( sections.join( '\n\n---\n\n' ) ); }, { diff --git a/plugins/wp-studio/skills/build-site/SKILL.md b/plugins/wp-studio/skills/build-site/SKILL.md index 7669a4fad2..61ccfb00e8 100644 --- a/plugins/wp-studio/skills/build-site/SKILL.md +++ b/plugins/wp-studio/skills/build-site/SKILL.md @@ -1,6 +1,6 @@ --- name: build-site -description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the design and plugins as a Site Spec, then build the theme, provision the site, and publish the content. Load it before picking or creating the site. +description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the look and the layout with the user, then build the theme, provision the site, and publish the content. Load it before picking or creating the site. --- 1. If you have not called the `studio_instructions` tool in this conversation yet, call it first and follow what it returns for the rest of the session. diff --git a/scripts/eval/promptfoo.config.yaml b/scripts/eval/promptfoo.config.yaml index f0a307a517..c55a0b061c 100644 --- a/scripts/eval/promptfoo.config.yaml +++ b/scripts/eval/promptfoo.config.yaml @@ -345,7 +345,7 @@ tests: prompt: | Build me a minimal one-page site for a small neighborhood bakery: a hero and a short about section. Skip the discovery questions. Before - writing any files, state the Site Spec and stop — I will confirm the + writing any files, state your plan and stop — I will confirm the plan first. assert: - type: javascript From 2e70621a4aa696e444abfab4b5049fe133f10fe2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 02:27:29 -0400 Subject: [PATCH 043/199] Studio Code: Leave annotate, liberate, and plan questions to the tools that know annotate taught editing an existing site through Global Styles custom CSS and template overrides saved in the database, the copies that make WordPress ignore the theme's files, which the site facts and write notes now flag. It now says only where an annotated element is rendered: the page's content or the active theme's files, where the file notes and refusals apply. open_annotation_browser takes the site and the page, and resolves the URL itself, instead of a URL the agent had to look up first. liberate repeated what `studio create --from` prints: missing pages, how to resume, and the comparison command. It keeps the commands and the loop. hosting-plans-helper fetched WordPress.com's plan features with curl and answered from them; WordPress.com's MCP server serves the same catalogue as wpcom/plans-list, so plan questions go there, like every other WordPress.com rule. --- apps/cli/ai/skills/annotate/SKILL.md | 72 +------------------ .../ai/skills/hosting-plans-helper/SKILL.md | 56 --------------- apps/cli/ai/skills/liberate/SKILL.md | 39 ++-------- .../ai/tests/open-annotation-browser.test.ts | 22 +++++- apps/cli/ai/tools/open-annotation-browser.ts | 18 +++-- .../skills/hosting-plans-helper/SKILL.md | 9 --- scripts/eval/promptfoo.config.yaml | 10 --- 7 files changed, 42 insertions(+), 184 deletions(-) delete mode 100644 apps/cli/ai/skills/hosting-plans-helper/SKILL.md delete mode 100644 plugins/wp-studio/skills/hosting-plans-helper/SKILL.md diff --git a/apps/cli/ai/skills/annotate/SKILL.md b/apps/cli/ai/skills/annotate/SKILL.md index 9485f14fa8..3cc43f3bc3 100644 --- a/apps/cli/ai/skills/annotate/SKILL.md +++ b/apps/cli/ai/skills/annotate/SKILL.md @@ -7,74 +7,8 @@ tools: open_annotation_browser # Visual Annotations -Open a browser where the user can click elements on their WordPress site and annotate them with feedback. You read those annotations and make the requested changes. +`open_annotation_browser` opens the site for the user to click elements and leave notes on them, and returns the notes when the user clicks **Send**. Each note gives the page, the comment, and the element's selector, nearby text, and computed styles. -## On Startup +Address the notes in the order they were sent. To the user, name each element by what they see — its tag and nearby text — not by its selector. -When the user invokes this skill, introduce yourself: - -> **Visual Annotations** — I'll open your site in a browser. Click any element, leave a note, and I'll fix it. - -Then identify the target site. If there's an active site, use it. If there are multiple, ask which one. - -## Workflow - -### 1. Open the browser - -Call `site_info` to get the site URL — do NOT guess the URL or port. - -In the same message, tell the user: -> I'm opening your site in a browser. Click any element on the page and leave a note — you can paste images too — then press Enter to save it. Click **Send** when you're finished. - -Then call `open_annotation_browser` with the site URL. It opens a headed browser where clicking an element opens a note editor next to it, with a **Cancel** / **Send** bar at the bottom of the page, and waits there until the user is done. - -### 2. Read the annotations - -When the user clicks **Send**, `open_annotation_browser` returns their annotations in the same format as annotations sent from the Studio preview, with any images they attached. If the user cancels or closes the window instead, it says so. If it times out, the browser is still open with the user's notes: call it again to keep waiting. - -Each annotation includes the page, the user's comment, and the element's selector, nearby text and computed styles. - -### 3. Review the annotations - -Address the annotations in the order they were submitted. When you reference one for the user, identify the element by what they can see — the tag name plus its nearby text — rather than by selector. Selectors are noisy and unreadable; use them only for implementation. - -### 4. Make changes - -For each annotation: -1. **Identify what to change:** - - Use the CSS selector to find the element in theme templates or stylesheets - - Use `wp post list --post_type=wp_template --format=json` to check if it's in a template override - - Use `wp eval "echo wp_get_custom_css();"` to check existing custom CSS -2. **Apply the change using the right approach:** - - For style changes (colors, sizes, spacing): use Global Styles custom CSS with the selector from the annotation - - For content changes (text, headings, block structure): edit the template or post content via WP-CLI - - For block-level changes: identify the WordPress block type from the HTML structure (look for `wp-block-*` classes) and modify accordingly - -### 5. Report - -After all annotations are addressed, tell the user what changed. The browser closes when the user clicks **Send**. If they want another round, run the skill again from the top — `/annotate` opens a fresh browser. - -## Making changes the WordPress way - -Always prefer WordPress APIs over direct file edits or custom plugins. - -### CSS / design changes - -Use **Global Styles custom CSS** — never create throwaway plugins: -``` -wp eval 'echo wp_get_custom_css();' → read current custom CSS -wp eval 'wp_update_custom_css_post("CSS HERE");' → update custom CSS -``` - -### Template changes - -Create **template overrides via the database**, not file edits: -``` -wp post create --post_type=wp_template --post_name="theme-slug//template-name" --post_content="BLOCK MARKUP" --post_status=publish -``` - -### When to use what - -- **Tweaking an existing site**: Prefer Global Styles custom CSS and template overrides in the database — these are non-destructive and easy to revert -- **Building a theme or new site**: Edit theme files directly — that's the job. Follow Studio's existing guidelines for block themes (theme.json, templates/, style.css) -- **Never**: Modify WordPress core files +An element is rendered by the page's content or by the active theme — a template, part, or pattern, its theme.json, or its style.css — and its `wp-block-*` classes name its block. Make each change where the element is rendered, then tell the user what changed. Another round is another `open_annotation_browser` call. diff --git a/apps/cli/ai/skills/hosting-plans-helper/SKILL.md b/apps/cli/ai/skills/hosting-plans-helper/SKILL.md deleted file mode 100644 index e088492499..0000000000 --- a/apps/cli/ai/skills/hosting-plans-helper/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: hosting-plans-helper -description: Answer WordPress.com plan and feature-tier questions (plan names and what each tier includes — plugins, themes, custom code, SSH, hosting) from authoritative live data. Load before answering ANY plan or feature-gating question; never answer these from memory. -user-invokable: true ---- - -# Hosting Plans Helper - -Use this skill whenever the user asks about WordPress.com (or Pressable) plans or -what a plan tier includes — for example "which plan do I need for plugins?", "what -does Business include?", or "can I use custom CSS on Premium?". - -## Hard rule: never answer from memory - -Plan names and feature-tier gating change, and your training data is stale. You -MUST fetch current data with this skill before answering. Do not state a plan name -or which tier unlocks a feature from memory — even if you are confident. If the -fetch fails, say you can't verify current plan data right now and point the user to -https://wordpress.com/pricing; do not guess. - -## Step 1: Fetch plan names and features - -Fetch `wpcom/v2/plans/features`. It returns, per plan, the current `name` and the -full list of `features` that tier unlocks — grouped (Essential features, -Performance boosters, High Availability, Developer tools, Security, etc.). - -```text -curl -s "https://public-api.wordpress.com/wpcom/v2/plans/features?locale=en" -``` - -The endpoint is public (no authentication). - -## Step 2: Answer from the fetched data only - -- Use the exact plan **names** from Step 1 — do not rename or substitute legacy - names. -- To answer "does plan X include feature Y?" or "what do I need for Y?", check the - per-plan `features` list from Step 1. Each feature has a `title`, a `tooltip` (use - it to explain), and a `group`. Name the plans whose `features` include it. - -### Phrasing - -Answer directly and authoritatively, as plain product knowledge. Do not mention that -you fetched anything, or reference "the live data", "the data I fetched", "according -to the API", or any source or tool. The fetched data is simply the truth — state it. - -- Yes: "Plugins are supported on the Personal plan and above." -- No: "Based on the live data I just fetched, plugins are supported on Personal and - above." - -## Scope - -Currently covers the WordPress.com consumer plans (Free, Personal, Premium, -Business, Commerce). If the user asks about a plan or product not in the response -(e.g. Pressable, Woo Hosted, VIP, enterprise), say it's not covered by this data and -point them to https://wordpress.com/pricing rather than answering from memory. diff --git a/apps/cli/ai/skills/liberate/SKILL.md b/apps/cli/ai/skills/liberate/SKILL.md index 90aa6b2506..2ebe874d00 100644 --- a/apps/cli/ai/skills/liberate/SKILL.md +++ b/apps/cli/ai/skills/liberate/SKILL.md @@ -4,46 +4,21 @@ description: Liberate any website into a portable HTML copy and import it into W user-invokable: true --- -## 1. Bring the site over - -Ask where the website lives if the user has not already provided a URL. It can be a public address starting with `https://`, or a folder or `.zip` of the site's files on this computer. +# Liberate a Website -For an address, the import has two stages: first Data Liberation copies every page of the original site, then Static Site Importer rebuilds that copy as a WordPress site. Copying takes longest and grows with the number of pages, so a small site takes a few minutes and a large one can take much longer. Tell the user what to expect. +## 1. Bring the site over -Import it with the Studio CLI. For a website address: +The source is a public `https://` address, or a folder or `.zip` of the site's files on this computer. For an address, Data Liberation first copies every page of the original — the longest part, growing with the number of pages — then Static Site Importer rebuilds the copy as a WordPress site. ```bash studio create --from --name "" --path ~/Studio/ --keep-source --skip-browser -``` - -For a folder or `.zip` of website files, pass its path instead and leave out `--keep-source`: - -```bash studio create --from --name "" --path ~/Studio/ --skip-browser ``` -Run it with a timeout of at least 20 minutes. For an address, `--keep-source` keeps the copy of the original in a `-source` folder next to the new site, which the comparison below uses. - -If a few pages could not be copied, the import still finishes without them and lists them with the reason each one failed; tell the user which pages are missing. The import stops instead when the home page could not be copied, or more than one page in ten; report the reason it printed rather than guessing at one. - -If the import stops after the site was created, the site is kept and `studio create` says so. Re-running the same command continues that import from the copy it already made, instead of starting a new site or copying the original again. If it stops before the site was created, re-running starts over. - -## 2. Find what came out differently - -When the import finishes, make the new site the active site with `site_info` and share its address and login details. - -For a website address, `studio create` prints a command at the end of the import that compares the new site with the original, page by page, at several screen sizes including a phone. Run it; the site must be running. It lists each page and screen size where the new site differs, and what differs: missing text, different fonts or sizes, moved or missing images, or menus and buttons that do not open. - -For a folder of files there is no automatic comparison; open the original files alongside the new site and compare them yourself. - -Tell the user, in plain words, what you found. Ask them to look through the site too and point out anything that looks wrong or is missing. - -## 3. Fix it, one change at a time - -Fix each difference on the new site itself. Edit its pages and its theme using block editor blocks, so the user can keep editing everything in the WordPress editor afterwards. Avoid raw HTML blocks and custom code the user cannot edit. +Run it with a timeout of at least 20 minutes. Its output says which pages could not be copied, how to resume an import that stopped, and, for an address, the command that compares the new site with the original page by page, at several screen sizes. -After each fix, refresh the site preview so the user sees the change, and ask whether it looks right. When you have the comparison, run it again after a batch of fixes to confirm them and catch anything new. Keep going until the comparison is clean or the user is happy with what remains. +## 2. Make it match -## 4. Wrap up +Make the new site the active site with `site_info`, and share its address and login details. Run the comparison with the site running; a folder of files has none, so compare it with the original files yourself. Tell the user what differs, and ask them to point out anything else. -Summarize what was brought over, what you changed together, and anything that still differs from the original. Point the user to the WordPress editor for future changes. +Fix each difference on the new site with blocks, so the user can keep editing everything in the WordPress editor. Run the comparison again after a batch of fixes, until it is clean or the user is happy with what remains. diff --git a/apps/cli/ai/tests/open-annotation-browser.test.ts b/apps/cli/ai/tests/open-annotation-browser.test.ts index 20b4f40ddc..0f427d4d72 100644 --- a/apps/cli/ai/tests/open-annotation-browser.test.ts +++ b/apps/cli/ai/tests/open-annotation-browser.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; import { INSPECTOR_BRIDGE_PREFIX } from '@studio/common/ai/inspector/page-script'; import { describe, expect, it, vi } from 'vitest'; import { NOTE_EDITOR_EVENT } from '../inspector/note-editor'; @@ -7,6 +10,7 @@ const page = vi.hoisted( () => ( { handlers: {} as Record< string, ( ( value?: unknown ) => void )[] >, editor: undefined as ( ( action: unknown ) => void ) | undefined, evaluate: vi.fn( async ( ..._args: unknown[] ) => undefined ), + goto: vi.fn( async ( _url: string ) => undefined ), } ) ); const close = vi.hoisted( () => vi.fn( async () => undefined ) ); @@ -22,7 +26,7 @@ vi.mock( 'cli/ai/browser-utils', () => ( { on: ( event: string, fn: () => void ) => { ( page.handlers[ event ] ??= [] ).push( fn ); }, - goto: async () => undefined, + goto: page.goto, bringToFront: async () => undefined, isClosed: () => false, } ), @@ -40,8 +44,19 @@ function editingId(): string { } describe( 'open_annotation_browser', () => { - it( 'returns the notes once the user clicks Send', async () => { - const result = openAnnotationBrowserTool.rawHandler( { url: 'http://localhost:8881' } ); + it( 'opens the page of the site it names, and returns the notes once the user clicks Send', async () => { + const root = mkdtempSync( path.join( tmpdir(), 'annotate-' ) ); + const site = { id: '1', name: 'My Site', path: root, port: 8881, phpVersion: '8.3' }; + writeFileSync( + path.join( root, 'cli.json' ), + JSON.stringify( { version: 1, sites: [ site ] } ) + ); + vi.stubEnv( 'E2E', '1' ); + vi.stubEnv( 'E2E_CLI_CONFIG_PATH', root ); + const result = openAnnotationBrowserTool.rawHandler( { + nameOrPath: 'My Site', + page: '/about', + } ); await vi.waitFor( () => expect( page.editor ).toBeDefined() ); page.handlers.console[ 0 ]( { @@ -69,5 +84,6 @@ describe( 'open_annotation_browser', () => { text: expect.stringContaining( '- Comment: Make it bigger' ), } ); expect( close ).toHaveBeenCalled(); + expect( page.goto.mock.calls[ 0 ][ 0 ] ).toBe( 'http://localhost:8881/about' ); } ); } ); diff --git a/apps/cli/ai/tools/open-annotation-browser.ts b/apps/cli/ai/tools/open-annotation-browser.ts index 6f271e92e9..02c020d60e 100644 --- a/apps/cli/ai/tools/open-annotation-browser.ts +++ b/apps/cli/ai/tools/open-annotation-browser.ts @@ -1,16 +1,22 @@ import { formatVisualAnnotationsPrompt } from '@studio/common/ai/visual-annotations'; import { Type } from 'typebox'; import { annotateInBrowser } from 'cli/ai/inspector/inspector-inject'; +import { getSiteUrl } from 'cli/lib/cli-config/sites'; import { defineTool, LOCAL_CHANGE } from './define-tool'; -import { textResult } from './utils'; +import { resolveSite, textResult } from './utils'; export const openAnnotationBrowserTool = defineTool( 'open_annotation_browser', - 'Opens a headed browser on a site for the user to annotate, and waits until they are done. ' + + 'Opens a headed browser on a running local site for the user to annotate, and waits until they are done. ' + 'The user clicks elements, leaves a note on each (text, pasted images), then clicks "Send" in the bar at the bottom, which closes the browser. ' + 'Returns the annotations like the ones sent from the Studio preview, with their images attached.', { - url: Type.String( { description: 'The site URL to open (e.g., "http://localhost:8881")' } ), + nameOrPath: Type.String( { description: 'The site name or file system path.' } ), + page: Type.Optional( + Type.String( { + description: 'The page to open first, as a path such as "/about". Defaults to "/".', + } ) + ), // `0` would never time out. 120 minutes is generous for any annotation session. timeoutMinutes: Type.Optional( Type.Integer( { @@ -22,8 +28,10 @@ export const openAnnotationBrowserTool = defineTool( }, async ( args, context ) => { try { - context.onProgress( `Opening annotation browser at ${ args.url }…` ); - const notes = await annotateInBrowser( args.url, ( args.timeoutMinutes ?? 30 ) * 60 * 1000 ); + const url = new URL( args.page ?? '/', getSiteUrl( await resolveSite( args.nameOrPath ) ) ) + .href; + context.onProgress( `Opening annotation browser at ${ url }…` ); + const notes = await annotateInBrowser( url, ( args.timeoutMinutes ?? 30 ) * 60 * 1000 ); if ( ! notes ) { return textResult( 'The user closed the annotation browser without sending annotations.' ); } diff --git a/plugins/wp-studio/skills/hosting-plans-helper/SKILL.md b/plugins/wp-studio/skills/hosting-plans-helper/SKILL.md deleted file mode 100644 index 3e7b224ae0..0000000000 --- a/plugins/wp-studio/skills/hosting-plans-helper/SKILL.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: hosting-plans-helper -description: Answer WordPress.com plan and feature-tier questions (plan names and what each tier includes — plugins, themes, custom code, SSH, hosting) from authoritative live data. Load before answering ANY plan or feature-gating question; never answer these from memory. ---- - -1. If you have not called the `studio_instructions` tool in this conversation yet, call it first and follow what it returns for the rest of the session. -2. Then call `studio_instructions` with `skill: "hosting-plans-helper"` and follow the runbook it returns. - -If the `wordpress-studio` tools are missing, WordPress Studio did not start: ask the user to restart the app, and to reinstall the plugin if the tools are still missing. diff --git a/scripts/eval/promptfoo.config.yaml b/scripts/eval/promptfoo.config.yaml index c55a0b061c..dda2a871ae 100644 --- a/scripts/eval/promptfoo.config.yaml +++ b/scripts/eval/promptfoo.config.yaml @@ -845,13 +845,3 @@ tests: assert: - type: javascript value: file://outcome.mjs:skillsLoadedFirst - - description: 'routing: a plan question loads hosting-plans-helper' - vars: - caseId: routing-plan-question - timeoutMs: 300000 - skills: [{ name: hosting-plans-helper }] - stopAt: *stopAtWork - prompt: Which WordPress.com plan do I need to install my own plugins? - assert: - - type: javascript - value: file://outcome.mjs:skillsLoadedFirst From f5e867cd246c9c6dd43460f0f0fccc38e5378d6c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 06:15:03 -0400 Subject: [PATCH 044/199] Validate post content where it lands, whatever route wrote it Page content reaches the database by WP-CLI with a file of any name or inline content, `wp eval`, REST, or an import, so a check keyed on the file a page was drafted in misses most of them. Every call now snapshots the rendered content of each local site (posts, pages, templates, parts, patterns, navigation) before it runs, and validates what changed after it: the core/html policy and the blocks' own save output. A lossless editor fix is saved to the site's tmp/ with the `wp post update` command that applies it. Studio Code and `studio mcp` report on every call; the plugin hooks run on shell calls too, keeping PreToolUse's snapshot for PostToolUse. --- apps/cli/ai/block-validation-gate.ts | 45 +++++-- apps/cli/ai/post-content-check.ts | 124 ++++++++++++++++++ apps/cli/ai/runtimes/hooks/index.ts | 43 +++++- .../hooks/tests/file-tool-hook.test.ts | 30 ++++- apps/cli/ai/runtimes/mcp/index.ts | 4 +- apps/cli/ai/runtimes/mcp/served-tool.ts | 11 +- .../ai/runtimes/mcp/tests/served-tool.test.ts | 33 ++++- apps/cli/ai/runtimes/pi/index.ts | 12 ++ apps/cli/ai/tests/fixtures/wordpress-site.ts | 19 +-- apps/cli/ai/tests/pi-runtime.test.ts | 34 ++++- apps/cli/ai/tests/post-content-check.test.ts | 109 +++++++++++++++ plugins/wp-studio/hooks/hooks.json | 6 +- 12 files changed, 441 insertions(+), 29 deletions(-) create mode 100644 apps/cli/ai/post-content-check.ts create mode 100644 apps/cli/ai/tests/post-content-check.test.ts diff --git a/apps/cli/ai/block-validation-gate.ts b/apps/cli/ai/block-validation-gate.ts index e4967f91e4..adc0036413 100644 --- a/apps/cli/ai/block-validation-gate.ts +++ b/apps/cli/ai/block-validation-gate.ts @@ -118,6 +118,14 @@ interface FileValidation { problem?: string; } +export interface MarkupValidation { + summary: string; + problem?: string; + /** What the block editor would save instead, when that loses nothing. */ + fixedContent?: string; + invalidBlocks?: number; +} + /** Validates one block markup file, fixing it in place when that is lossless. */ export async function validateBlockMarkupFile( filePath: string, @@ -136,10 +144,32 @@ export async function validateBlockMarkupFile( } } + const result = await validateBlockMarkup( content, label, siteUrl, notes ); + if ( result.fixedContent === undefined ) { + return { contentHash: hash( content ), summary: result.summary, problem: result.problem }; + } + await writeFile( filePath, result.fixedContent, 'utf8' ); + notes.push( `saved through the block editor, fixing ${ result.invalidBlocks } block(s)` ); + return { + contentHash: hash( result.fixedContent ), + summary: `${ result.summary }; ${ notes.join( '; ' ) }`, + }; +} + +/** + * Validates block markup against what each block saves on the site, and + * against the core/html policy. A lossless editor fix is returned for the + * caller to save where the markup lives. + */ +export async function validateBlockMarkup( + content: string, + label: string, + siteUrl: string, + notes: string[] = [] +): Promise< MarkupValidation > { const policy = validateHtmlBlockPolicy( content ); if ( policy.invalidHtmlBlocks.length > 0 ) { return { - contentHash: hash( content ), summary: `${ label }: core/html policy failed`, problem: [ `${ label }: ${ policy.invalidHtmlBlocks.length } core/html block(s) must become editable blocks:`, @@ -153,13 +183,11 @@ export async function validateBlockMarkupFile( const report = await validateBlocks( content, siteUrl ); if ( report.error ) { return { - contentHash: hash( content ), summary: `${ label }: not validated (${ report.error })`, }; } if ( report.invalidBlocks === 0 ) { return { - contentHash: hash( content ), summary: `${ label }: ${ report.totalBlocks }/${ report.totalBlocks } blocks valid${ notes.length ? `; ${ notes.join( '; ' ) }` : '' }`, @@ -169,7 +197,6 @@ export async function validateBlockMarkupFile( const fix = report.proposedFix; if ( ! fix || fix.report.error || fix.report.invalidBlocks > 0 ) { return { - contentHash: hash( content ), summary: `${ label }: ${ report.invalidBlocks } invalid block(s)`, problem: [ `${ label }: ${ report.invalidBlocks } block(s) do not match what the block saves, and the editor cannot fix them:`, @@ -188,17 +215,13 @@ export async function validateBlockMarkupFile( ( attribute ) => ! fixedAttributes.has( attribute ) ); if ( ! lostText && droppedClasses.length === 0 && droppedAttributes.length === 0 ) { - await writeFile( filePath, fix.fixedContent, 'utf8' ); - notes.push( `saved through the block editor, fixing ${ report.invalidBlocks } block(s)` ); return { - contentHash: hash( fix.fixedContent ), - summary: `${ label }: ${ report.totalBlocks }/${ - report.totalBlocks - } blocks valid; ${ notes.join( '; ' ) }`, + summary: `${ label }: ${ report.totalBlocks }/${ report.totalBlocks } blocks valid`, + fixedContent: fix.fixedContent, + invalidBlocks: report.invalidBlocks, }; } return { - contentHash: hash( content ), summary: `${ label }: ${ report.invalidBlocks } invalid block(s)`, problem: [ `${ label }: ${ diff --git a/apps/cli/ai/post-content-check.ts b/apps/cli/ai/post-content-check.ts new file mode 100644 index 0000000000..063a7f5e62 --- /dev/null +++ b/apps/cli/ai/post-content-check.ts @@ -0,0 +1,124 @@ +/** + * Block validation of the content WordPress renders from the database: posts, + * pages, templates, template parts, patterns and navigation menus. Content + * reaches those rows by any route (WP-CLI with inline or file content, `wp + * eval`, REST, an import), so it is checked where it lands, not on the way. + * + * Surfaces take a snapshot before a call and check after it, so what a call + * changed is reported on that call's result. + */ +import { mkdir, writeFile } from 'fs/promises'; +import path from 'path'; +import { validateBlockMarkup } from 'cli/ai/block-validation-gate'; +import { readWordPressSite } from 'cli/ai/site-database'; +import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; +import { getSiteUrl } from 'cli/lib/cli-config/sites'; + +const RENDERED_TYPES = [ + 'post', + 'page', + 'wp_template', + 'wp_template_part', + 'wp_block', + 'wp_navigation', +]; + +interface Row { + id: number; + type: string; + title: string; + content: string; +} + +/** + * Each rendered post's version, by post ID: its modified time and content + * length, so a check reads the content of changed posts only. + */ +export type PostSnapshot = Record< string, string >; + +const RENDERED = `post_type IN (${ RENDERED_TYPES.map( () => '?' ).join( + ', ' +) }) AND post_status NOT IN ('auto-draft', 'inherit', 'trash')`; + +async function readVersions( sitePath: string ): Promise< PostSnapshot > { + const rows = await readWordPressSite( sitePath, ( site ) => + site.query< { id: number; version: string } >( + `SELECT ID AS id, post_modified_gmt || ' ' || length(post_content) AS version + FROM ${ site.prefix }posts WHERE ${ RENDERED }`, + ...RENDERED_TYPES + ) + ); + return Object.fromEntries( rows.map( ( row ) => [ row.id, row.version ] ) ); +} + +async function readPosts( sitePath: string, ids: string[] ): Promise< Row[] > { + return readWordPressSite( sitePath, ( site ) => + site.query< Row >( + `SELECT ID AS id, post_type AS type, post_title AS title, post_content AS content + FROM ${ site.prefix }posts WHERE ID IN (${ ids.map( () => '?' ).join( ', ' ) })`, + ...ids + ) + ); +} + +async function localSites(): Promise< SiteData[] > { + return ( await readCliConfig() ).sites; +} + +// A lossless fix is saved next to the site for the agent to apply, since the +// row belongs to the running site. +async function validatePost( site: SiteData, post: Row ) { + const label = `${ site.name } ${ post.type } ${ post.id } "${ post.title }"`; + const result = await validateBlockMarkup( post.content, label, getSiteUrl( site ) ); + if ( result.fixedContent === undefined ) { + return result; + } + const fixFile = path.join( 'tmp', `${ post.type }-${ post.id }.html` ); + await mkdir( path.join( site.path, 'tmp' ), { recursive: true } ); + await writeFile( path.join( site.path, fixFile ), result.fixedContent, 'utf8' ); + return { + summary: `${ label }: ${ result.invalidBlocks } invalid block(s)`, + problem: `${ label }: ${ result.invalidBlocks } block(s) do not match what the block saves; the block editor's fix loses nothing and is saved in ${ fixFile }. Apply it with \`wp post update ${ post.id } ${ fixFile }\`.`, + }; +} + +export class PostContentCheck { + constructor( + private readonly snapshots = new Map< string, PostSnapshot >(), + private readonly sites: () => Promise< SiteData[] > = localSites, + private readonly validate: typeof validatePost = validatePost + ) {} + + /** Records every local site's rendered content as it is now. */ + async snapshot(): Promise< void > { + for ( const site of await this.sites() ) { + this.snapshots.set( site.path, await readVersions( site.path ) ); + } + } + + /** Validates the content changed since the snapshot, and snapshots it. */ + async settle(): Promise< { summaries: string[]; problems: string[] } > { + const summaries: string[] = []; + const problems: string[] = []; + for ( const site of await this.sites() ) { + const before = this.snapshots.get( site.path ); + const after = await readVersions( site.path ); + this.snapshots.set( site.path, after ); + // A site first seen now has no changes yet. + const changed = before + ? Object.keys( after ).filter( ( id ) => before[ id ] !== after[ id ] ) + : []; + for ( const post of changed.length ? await readPosts( site.path, changed ) : [] ) { + const result = await this.validate( site, post ).catch( ( error: unknown ) => ( { + summary: `${ site.name } ${ post.type } ${ post.id }: not validated (${ + error instanceof Error ? error.message : String( error ) + })`, + problem: undefined, + } ) ); + summaries.push( result.summary ); + problems.push( ...( result.problem ? [ result.problem ] : [] ) ); + } + } + return { summaries, problems }; + } +} diff --git a/apps/cli/ai/runtimes/hooks/index.ts b/apps/cli/ai/runtimes/hooks/index.ts index a6765ef4d8..82de9097d9 100644 --- a/apps/cli/ai/runtimes/hooks/index.ts +++ b/apps/cli/ai/runtimes/hooks/index.ts @@ -8,9 +8,17 @@ * The input and output are the hook JSON both agents use: tool_input names * the file (file_path) or carries a patch (apply_patch), and * hookSpecificOutput denies the call or adds context to its result. + * + * On a shell call they also report the content the call changed in the sites' + * databases (post-content-check.ts): each hook is its own process, so the + * snapshot PreToolUse takes is kept in a file for its PostToolUse. */ +import { createHash } from 'crypto'; +import { readFile, writeFile } from 'fs/promises'; +import os from 'os'; import path from 'path'; import { BlockValidationGate } from 'cli/ai/block-validation-gate'; +import { PostContentCheck, type PostSnapshot } from 'cli/ai/post-content-check'; import { createSiteNotes } from 'cli/ai/site-notes'; export interface HookInput { @@ -18,6 +26,7 @@ export interface HookInput { tool_name?: string; tool_input?: Record< string, unknown >; cwd?: string; + session_id?: string; } type HookOutput = { @@ -45,6 +54,29 @@ export function getWrittenFiles( { tool_input: toolInput = {}, cwd = '' }: HookI ); } +function snapshotFile( sessionId: string ): string { + const key = createHash( 'sha1' ).update( sessionId ).digest( 'hex' ).slice( 0, 16 ); + return path.join( os.tmpdir(), `studio-hook-post-content-${ key }.json` ); +} + +// The session's post content check, with the snapshots its last hook kept. +async function withPostContent< T >( + sessionId: string | undefined, + run: ( check: PostContentCheck ) => Promise< T > +): Promise< T | undefined > { + if ( ! sessionId ) { + return undefined; + } + const file = snapshotFile( sessionId ); + const kept: Record< string, PostSnapshot > = await readFile( file, 'utf8' ) + .then( ( text ) => JSON.parse( text ) ) + .catch( () => ( {} ) ); + const snapshots = new Map( Object.entries( kept ) ); + const result = await run( new PostContentCheck( snapshots ) ); + await writeFile( file, JSON.stringify( Object.fromEntries( snapshots ) ), 'utf8' ); + return result; +} + export async function runFileToolHook( input: HookInput, gate = new BlockValidationGate() @@ -52,6 +84,9 @@ export async function runFileToolHook( const files = getWrittenFiles( input ); const siteNotes = createSiteNotes( { gate } ); if ( input.hook_event_name === 'PreToolUse' ) { + await withPostContent( input.session_id, ( check ) => check.snapshot() ).catch( + () => undefined + ); for ( const file of files ) { const reason = await siteNotes.beforeWrite( file ); if ( reason ) { @@ -74,9 +109,15 @@ export async function runFileToolHook( notes.push( ...( await siteNotes.afterWrite( file ) ) ); } const { summaries, problems } = await gate.settle(); + const changedContent = ( await withPostContent( input.session_id, ( check ) => + check.settle() + ).catch( () => undefined ) ) ?? { summaries: [], problems: [] }; const context = [ - ...summaries.map( ( line ) => `Block validation: ${ line }` ), + ...[ ...summaries, ...changedContent.summaries ].map( + ( line ) => `Block validation: ${ line }` + ), ...problems, + ...changedContent.problems, ...notes, ].join( '\n' ); return context diff --git a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts index b4d0a1a2bf..988e9f15ba 100644 --- a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts +++ b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts @@ -5,9 +5,11 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { BlockValidationGate } from 'cli/ai/block-validation-gate'; import { getWrittenFiles, runFileToolHook } from 'cli/ai/runtimes/hooks'; import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; +import { readCliConfig } from 'cli/lib/cli-config/core'; import { findSiteByFolder } from 'cli/lib/cli-config/sites'; -vi.mock( 'cli/lib/cli-config/sites', () => ( { findSiteByFolder: vi.fn() } ) ); +vi.mock( 'cli/lib/cli-config/sites', () => ( { findSiteByFolder: vi.fn(), getSiteUrl: vi.fn() } ) ); +vi.mock( 'cli/lib/cli-config/core', () => ( { readCliConfig: vi.fn() } ) ); let site: ReturnType< typeof createSite >; beforeEach( () => { @@ -19,6 +21,9 @@ beforeEach( () => { > ) : undefined ); + vi.mocked( readCliConfig ).mockResolvedValue( { + sites: [ { id: 'site', name: 'Bakery', path: site.root } ], + } as Awaited< ReturnType< typeof readCliConfig > > ); } ); // Claude Code's Write and Edit name the file; Codex's apply_patch carries a patch. @@ -266,3 +271,26 @@ it.each( [ notes.includes( `wp-content/themes/bakery/${ file } shows images from ${ host },` ) ).toBe( Boolean( host ) ); } ); + +// A shell call can save post content by any route; the hook pair reports what +// it changed, keeping PreToolUse's snapshot for PostToolUse. +it( 'reports the block validation of post content a shell call changed', async () => { + site.save( 9, 'page', 'home', '\n

Hi

\n' ); + const hook = ( event: string ) => + runFileToolHook( { + hook_event_name: event, + tool_name: 'Bash', + tool_input: { command: 'wp post update 9 tmp/home.txt' }, + session_id: `session-${ site.root }`, + } ); + await hook( 'PreToolUse' ); + site.edit( 9, '\n

Hi

\n' ); + expect( await hook( 'PostToolUse' ) ).toEqual( { + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: expect.stringContaining( + 'Bakery page 9 "": 1 core/html block(s) must become editable blocks' + ), + }, + } ); +} ); diff --git a/apps/cli/ai/runtimes/mcp/index.ts b/apps/cli/ai/runtimes/mcp/index.ts index 0cdc422c84..571b53e6ee 100644 --- a/apps/cli/ai/runtimes/mcp/index.ts +++ b/apps/cli/ai/runtimes/mcp/index.ts @@ -14,6 +14,7 @@ import { Type, type TSchema } from 'typebox'; import { Value } from 'typebox/value'; import { type DesignTracksContext } from 'cli/ai/design-tracks'; import { isImageGenerationAvailable } from 'cli/ai/image-generation'; +import { PostContentCheck } from 'cli/ai/post-content-check'; import { DESIGN_PICKER_HTML, DESIGN_PICKER_URI, @@ -130,6 +131,7 @@ export async function startMcpStdioServer(): Promise< void > { process.env.STUDIO_TRACKS_ORIGIN = 'studio-mcp'; const sessionId = randomUUID(); const siteNotes = createSiteNotes(); + const postContent = new PostContentCheck(); const imageGeneration = await isImageGenerationAvailable(); const server = new Server( { name: 'studio', version: '1.0.0' }, @@ -187,7 +189,7 @@ export async function startMcpStdioServer(): Promise< void > { isError: true, }; } - return runServedTool( tool, args, siteNotes ); + return runServedTool( tool, args, siteNotes, postContent ); } ); // Only clients that render MCP Apps get the pages listed. diff --git a/apps/cli/ai/runtimes/mcp/served-tool.ts b/apps/cli/ai/runtimes/mcp/served-tool.ts index 8c597ef4cb..19473cecb1 100644 --- a/apps/cli/ai/runtimes/mcp/served-tool.ts +++ b/apps/cli/ai/runtimes/mcp/served-tool.ts @@ -1,4 +1,5 @@ import { resolveSite } from 'cli/ai/tools/utils'; +import type { PostContentCheck } from 'cli/ai/post-content-check'; import type { SiteNotes } from 'cli/ai/site-notes'; import type { AnyStudioAgentTool } from 'cli/ai/tools/define-tool'; @@ -19,15 +20,23 @@ async function siteNamed( args: Record< string, unknown > ) { export async function runServedTool( tool: AnyStudioAgentTool, args: Record< string, unknown >, - siteNotes: SiteNotes + siteNotes: SiteNotes, + postContent?: PostContentCheck ) { try { + await postContent?.snapshot().catch( () => undefined ); const result = await tool.rawHandler( args as never ); + const changedContent = ( await postContent?.settle().catch( () => undefined ) ) ?? { + summaries: [], + problems: [], + }; const resultText = result.content .map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) .join( '\n' ); const site = await siteNamed( args ); const notes = [ + ...changedContent.summaries.map( ( line ) => `Block validation: ${ line }` ), + ...changedContent.problems, ...( site ? await siteNotes.afterResult( site.path, resultText ) : [] ), await result.pending, ] diff --git a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts index 602caf42d5..169cd543c2 100644 --- a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts +++ b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts @@ -3,9 +3,11 @@ import { tmpdir } from 'os'; import path from 'path'; import { Type } from 'typebox'; import { expect, it, vi } from 'vitest'; +import { PostContentCheck } from 'cli/ai/post-content-check'; import { runServedTool } from 'cli/ai/runtimes/mcp/served-tool'; import { createSiteNotes } from 'cli/ai/site-notes'; -import { defineTool, READ_ONLY } from 'cli/ai/tools/define-tool'; +import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; +import { defineTool, LOCAL_CHANGE, READ_ONLY } from 'cli/ai/tools/define-tool'; it( 'adds the declared settings of the core blocks a result shows, from the site the call names, once', async () => { const root = mkdtempSync( path.join( tmpdir(), 'served-tool-' ) ); @@ -44,3 +46,32 @@ it( 'adds the declared settings of the core blocks a result shows, from the site // Once per MCP session, as once per Studio Code turn. expect( await notes( { nameOrPath: 'My Site' } ) ).toEqual( [] ); } ); + +it( 'reports the block validation of post content the call changed, on any site', async () => { + const fixture = createSite(); + fixture.save( 9, 'page', 'home', '\n

Hi

\n' ); + const root = mkdtempSync( path.join( tmpdir(), 'served-tool-' ) ); + const site = { id: '1', name: 'Bakery', path: fixture.root, port: 8881, phpVersion: '8.3' }; + writeFileSync( path.join( root, 'cli.json' ), JSON.stringify( { version: 1, sites: [ site ] } ) ); + vi.stubEnv( 'E2E', '1' ); + vi.stubEnv( 'E2E_CLI_CONFIG_PATH', root ); + + // As `wp post update` would, with the content from any file or inline. + const tool = defineTool( + 'wp_cli', + 'Runs WP-CLI.', + {}, + async () => { + fixture.edit( 9, '\n

Hi

\n' ); + return { content: [ { type: 'text', text: 'Success: Updated post 9.' } ] }; + }, + { annotations: LOCAL_CHANGE } + ); + const result = await runServedTool( tool, {}, createSiteNotes(), new PostContentCheck() ); + expect( result.content.map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) ).toEqual( [ + 'Success: Updated post 9.', + expect.stringMatching( + /^Block validation: Bakery page 9 "": core\/html policy failed\nBakery page 9 "": 1 core\/html block\(s\) must become editable blocks/ + ), + ] ); +} ); diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 4d62f63ea3..12e76487c6 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -45,6 +45,7 @@ import { getBlockValidationGate, type BlockValidationGate } from 'cli/ai/block-v import { withDesignSystemPreview } from 'cli/ai/chat-artifacts'; import { isImageGenerationAvailable } from 'cli/ai/image-generation'; import { emitEvent } from 'cli/ai/json-events'; +import { PostContentCheck } from 'cli/ai/post-content-check'; import { readActiveSiteFacts } from 'cli/ai/site-facts'; import { createSiteNotes, type SiteNotes } from 'cli/ai/site-notes'; import { buildSystemPrompt } from 'cli/ai/system-prompt'; @@ -390,12 +391,15 @@ async function createStudioAgentSession( stated: new Map( localSite ? [ [ localSite, activeSiteFacts ] ] : [] ), gate: blockValidationGate, } ); + // What a call changed in the content the sites render, reported on its result. + const postContent = new PostContentCheck(); const toolDefinitions = [ ...tools, ...mcpTools ].map( ( tool ) => toToolDefinition( tool, payloadGuardState, pendingWork, blockValidationGate, { confirm, sitePin, siteNotes, localSite, + postContent, } ) ); const modelRuntime = await createModelRuntime( model, family, creds ); @@ -680,10 +684,12 @@ function toToolDefinition( sitePin, siteNotes, localSite, + postContent, }: { confirm: Confirmer; sitePin: SitePin; siteNotes: SiteNotes; + postContent: PostContentCheck; // Studio's tools report on the active site when it is a local one. localSite?: string; } @@ -733,6 +739,7 @@ function toToolDefinition( '\n' ) || undefined; } + await postContent.snapshot().catch( () => undefined ); // The validation report was taken from the gate, so it rides on a failure too. const result = await tool .execute( toolCallId, params, signal, onUpdate ) @@ -752,12 +759,17 @@ function toToolDefinition( pendingWork.add( details.pending ); delete details.pending; } + const changedContent = await postContent + .settle() + .catch( () => ( { summaries: [], problems: [] } ) ); const resultText = result.content .map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) .join( '\n' ); const reports = [ validationReport, + ...changedContent.summaries.map( ( line ) => `Block validation: ${ line }` ), + ...changedContent.problems, ...( writtenFile ? await siteNotes.afterWrite( writtenFile ) : [] ), ...( localSite ? await siteNotes.afterResult( localSite, resultText ) : [] ), pendingWork.takeReports(), diff --git a/apps/cli/ai/tests/fixtures/wordpress-site.ts b/apps/cli/ai/tests/fixtures/wordpress-site.ts index 4bad25c9fb..ad1e642ea3 100644 --- a/apps/cli/ai/tests/fixtures/wordpress-site.ts +++ b/apps/cli/ai/tests/fixtures/wordpress-site.ts @@ -23,7 +23,7 @@ export function createSite( prefix = 'wp_' ) { } const db = new DatabaseSync( path.join( root, 'wp-content/database/.ht.sqlite' ) ); db.exec( ` - CREATE TABLE ${ prefix }posts ( ID INTEGER PRIMARY KEY, post_type TEXT, post_status TEXT, post_name TEXT, post_content TEXT, post_date TEXT ); + CREATE TABLE ${ prefix }posts ( ID INTEGER PRIMARY KEY, post_type TEXT, post_status TEXT, post_name TEXT, post_content TEXT, post_date TEXT, post_title TEXT DEFAULT '', post_modified_gmt TEXT DEFAULT '2026-10-22 00:00:00' ); CREATE TABLE ${ prefix }terms ( term_id INTEGER PRIMARY KEY, name TEXT ); CREATE TABLE ${ prefix }term_taxonomy ( term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER, taxonomy TEXT ); CREATE TABLE ${ prefix }term_relationships ( object_id INTEGER, term_taxonomy_id INTEGER ); @@ -42,13 +42,9 @@ export function createSite( prefix = 'wp_' ) { theme = 1, status = 'publish' ) => { - db.prepare( `INSERT INTO ${ prefix }posts VALUES ( ?, ?, ?, ?, ?, '2026-10-22' )` ).run( - id, - type, - status, - slug, - content - ); + db.prepare( + `INSERT INTO ${ prefix }posts ( ID, post_type, post_status, post_name, post_content, post_date ) VALUES ( ?, ?, ?, ?, ?, '2026-10-22' )` + ).run( id, type, status, slug, content ); db.prepare( `INSERT INTO ${ prefix }term_relationships VALUES ( ?, ? )` ).run( id, theme ); }; const setOptions = ( options: Record< string, string > ) => { @@ -56,5 +52,10 @@ export function createSite( prefix = 'wp_' ) { db.prepare( `INSERT OR REPLACE INTO ${ prefix }options VALUES ( ?, ? )` ).run( name, value ); } }; - return { root, save, setOptions }; + // An edit as WordPress saves it: new content and a new modified time. + const edit = ( id: number, content: string, modified = '2026-10-23 00:00:00' ) => + db + .prepare( `UPDATE ${ prefix }posts SET post_content = ?, post_modified_gmt = ? WHERE ID = ?` ) + .run( content, modified, id ); + return { root, save, setOptions, edit }; } diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index 5b48e383db..8f0808f3ec 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -15,6 +15,9 @@ const mocks = vi.hoisted( () => ( { onCustomMessage: null as ( () => void ) | null, studioRoot: '/tmp/studio-ai-pi-runtime', configRoot: '/tmp/studio-ai-pi-runtime-config', + // What ran, in order: tool executions and the post content check. + steps: [] as string[], + postChanges: { summaries: [] as string[], problems: [] as string[] }, } ) ); // Model-swap test uses a synthetic id outside `AI_MODELS`; route unknowns to @@ -37,7 +40,10 @@ vi.mock( '@earendil-works/pi-coding-agent', async ( importOriginal ) => { label: name, description: name, parameters: {}, - execute: async () => ( { content: [ { type: 'text', text: '' } ], details: undefined } ), + execute: async () => { + mocks.steps.push( name ); + return { content: [ { type: 'text', text: '' } ], details: undefined }; + }, } ); return { ...actual, @@ -62,6 +68,18 @@ vi.mock( 'cli/ai/site-notes', () => ( { } ), } ) ); +vi.mock( 'cli/ai/post-content-check', () => ( { + PostContentCheck: class { + async snapshot() { + mocks.steps.push( 'snapshot' ); + } + async settle() { + mocks.steps.push( 'settle' ); + return mocks.postChanges; + } + }, +} ) ); + vi.mock( 'cli/lib/site-paths', () => ( { STUDIO_SITES_ROOT: mocks.studioRoot, getDefaultSitePath: ( siteName: string ) => `${ mocks.studioRoot }/${ siteName }`, @@ -516,6 +534,20 @@ describe( 'pi runtime', () => { ).toEqual( [ '', `Note: wrote ${ file }` ] ); } ); + it( 'reports the post content a call changed, from a snapshot taken before it', async () => { + mocks.createdSessions.length = 0; + await runRuntime( { prompt: 'hello', env: WPCOM_ENV, session: newSession() } ); + mocks.steps.length = 0; + mocks.postChanges = { summaries: [ 'page 9 changed' ], problems: [ 'page 9 problem' ] }; + const result = await getCreatedTool( 'Read' ).execute( 'r', { path: 'a/style.css' } ); + expect( mocks.steps ).toEqual( [ 'snapshot', 'Read', 'settle' ] ); + expect( result.content.map( ( part ) => part.text ) ).toEqual( [ + '', + 'Block validation: page 9 changed\npage 9 problem', + ] ); + mocks.postChanges = { summaries: [], problems: [] }; + } ); + it( 'rejects oversized direct Write, Edit, and Bash payloads', async () => { await runRuntime( { prompt: 'hello', diff --git a/apps/cli/ai/tests/post-content-check.test.ts b/apps/cli/ai/tests/post-content-check.test.ts new file mode 100644 index 0000000000..c0b04da570 --- /dev/null +++ b/apps/cli/ai/tests/post-content-check.test.ts @@ -0,0 +1,109 @@ +import { existsSync, readFileSync, rmSync } from 'fs'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { validateBlocks, type ValidationReport } from 'cli/ai/block-validator'; +import { PostContentCheck } from 'cli/ai/post-content-check'; +import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; +import type { SiteData } from 'cli/lib/cli-config/core'; + +vi.mock( 'cli/ai/block-validator', () => ( { validateBlocks: vi.fn() } ) ); + +const PARAGRAPH = '\n

Hello

\n'; +const UNWRAPPED_GROUP = '\n

Hello

\n'; +const WRAPPED_GROUP = + '\n

Hello

\n'; +const HTML_ONLY = '\n

Hello

\n'; + +const valid: ValidationReport = { totalBlocks: 1, validBlocks: 1, invalidBlocks: 0, results: [] }; +const losslessFix: ValidationReport = { + totalBlocks: 1, + validBlocks: 0, + invalidBlocks: 1, + results: [ + { blockName: 'core/group', isValid: false, issues: [ 'mismatch' ], originalContent: '' }, + ], + proposedFix: { fixedContent: WRAPPED_GROUP, report: valid }, +}; + +let fixture: ReturnType< typeof createSite >; +let site: SiteData; + +beforeEach( () => { + fixture = createSite(); + fixture.save( 9, 'page', 'home', PARAGRAPH ); + site = { id: '1', name: 'Bakery', path: fixture.root, port: 8881 } as SiteData; + vi.mocked( validateBlocks ).mockReset().mockResolvedValue( valid ); +} ); + +afterEach( () => rmSync( fixture.root, { recursive: true, force: true } ) ); + +describe( 'PostContentCheck', () => { + it.each( [ + { name: 'an unchanged site', change: () => {}, reported: [] }, + { + name: 'an edited page', + change: () => fixture.edit( 9, PARAGRAPH + PARAGRAPH ), + reported: [ 'Bakery page 9 "": 1/1 blocks valid' ], + }, + { + name: 'a new template part', + change: () => fixture.save( 10, 'wp_template_part', 'header', PARAGRAPH ), + reported: [ 'Bakery wp_template_part 10 "": 1/1 blocks valid' ], + }, + { + name: 'content WordPress does not render as a page', + change: () => { + fixture.save( 11, 'attachment', 'photo', PARAGRAPH ); + fixture.save( 12, 'page', 'draft', PARAGRAPH, 1, 'auto-draft' ); + fixture.save( 13, 'revision', 'home-revision', PARAGRAPH, 1, 'inherit' ); + }, + reported: [], + }, + ] )( 'reports what a call changed: $name', async ( { change, reported } ) => { + const check = new PostContentCheck( new Map(), async () => [ site ] ); + await check.snapshot(); + change(); + expect( ( await check.settle() ).summaries ).toEqual( reported ); + // Reported once: the check moves its snapshot on. + expect( ( await check.settle() ).summaries ).toEqual( [] ); + } ); + + it( 'reports nothing for a site it has not seen before', async () => { + const check = new PostContentCheck( new Map(), async () => [ site ] ); + fixture.edit( 9, HTML_ONLY ); + expect( await check.settle() ).toEqual( { summaries: [], problems: [] } ); + } ); + + it( 'asks for editable blocks in place of core/html', async () => { + const check = new PostContentCheck( new Map(), async () => [ site ] ); + await check.snapshot(); + fixture.edit( 9, HTML_ONLY ); + const { problems } = await check.settle(); + expect( problems.join( '\n' ) ).toContain( '1 core/html block(s) must become editable blocks' ); + expect( validateBlocks ).not.toHaveBeenCalled(); + } ); + + it( 'saves a lossless editor fix next to the site, with the command that applies it', async () => { + vi.mocked( validateBlocks ).mockResolvedValue( losslessFix ); + const check = new PostContentCheck( new Map(), async () => [ site ] ); + await check.snapshot(); + fixture.edit( 9, UNWRAPPED_GROUP ); + const { summaries, problems } = await check.settle(); + expect( summaries ).toEqual( [ 'Bakery page 9 "": 1 invalid block(s)' ] ); + expect( problems[ 0 ] ).toContain( 'Apply it with `wp post update 9 tmp/page-9.html`' ); + expect( readFileSync( path.join( fixture.root, 'tmp/page-9.html' ), 'utf8' ) ).toBe( + WRAPPED_GROUP + ); + } ); + + it( 'reports a validation that cannot run', async () => { + vi.mocked( validateBlocks ).mockRejectedValue( new Error( 'site is not running' ) ); + const check = new PostContentCheck( new Map(), async () => [ site ] ); + await check.snapshot(); + fixture.edit( 9, UNWRAPPED_GROUP ); + expect( ( await check.settle() ).summaries ).toEqual( [ + 'Bakery page 9: not validated (site is not running)', + ] ); + expect( existsSync( path.join( fixture.root, 'tmp' ) ) ).toBe( false ); + } ); +} ); diff --git a/plugins/wp-studio/hooks/hooks.json b/plugins/wp-studio/hooks/hooks.json index a57836b34f..446f4a50cd 100644 --- a/plugins/wp-studio/hooks/hooks.json +++ b/plugins/wp-studio/hooks/hooks.json @@ -1,9 +1,9 @@ { - "description": "Studio's checks on writes to a WordPress site's files with the agent's own file tools: refuses files a WordPress or theme update replaces, and reports block validation and what WordPress renders differently from the file.", + "description": "Studio's checks on writes to a WordPress site's files with the agent's own file tools: refuses files a WordPress or theme update replaces, and reports block validation and what WordPress renders differently from the file; on a shell call, reports block validation of the post content the call changed.", "hooks": { "PreToolUse": [ { - "matcher": "Write|Edit|MultiEdit|apply_patch", + "matcher": "Write|Edit|MultiEdit|apply_patch|Bash", "hooks": [ { "type": "command", @@ -16,7 +16,7 @@ ], "PostToolUse": [ { - "matcher": "Write|Edit|MultiEdit|apply_patch", + "matcher": "Write|Edit|MultiEdit|apply_patch|Bash", "hooks": [ { "type": "command", From 6a28f2b5814a7dad18881aebdd33980182f55fc3 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 06:28:30 -0400 Subject: [PATCH 045/199] Report the post content a failed call changed A shell command can save a page and then exit non-zero; the next call's snapshot took the change in silently, so it was never reported. --- apps/cli/ai/runtimes/mcp/served-tool.ts | 21 ++++++++++------ .../ai/runtimes/mcp/tests/served-tool.test.ts | 21 ++++++++++++++++ apps/cli/ai/runtimes/pi/index.ts | 24 ++++++++++++------- apps/cli/ai/tests/pi-runtime.test.ts | 10 ++++++++ 4 files changed, 61 insertions(+), 15 deletions(-) diff --git a/apps/cli/ai/runtimes/mcp/served-tool.ts b/apps/cli/ai/runtimes/mcp/served-tool.ts index 19473cecb1..a8ca5aee18 100644 --- a/apps/cli/ai/runtimes/mcp/served-tool.ts +++ b/apps/cli/ai/runtimes/mcp/served-tool.ts @@ -23,20 +23,24 @@ export async function runServedTool( siteNotes: SiteNotes, postContent?: PostContentCheck ) { - try { - await postContent?.snapshot().catch( () => undefined ); - const result = await tool.rawHandler( args as never ); - const changedContent = ( await postContent?.settle().catch( () => undefined ) ) ?? { + await postContent?.snapshot().catch( () => undefined ); + // A call that fails can still have changed content. + const changedContent = async () => { + const { summaries, problems } = ( await postContent?.settle().catch( () => undefined ) ) ?? { summaries: [], problems: [], }; + return [ ...summaries.map( ( line ) => `Block validation: ${ line }` ), ...problems ]; + }; + try { + const result = await tool.rawHandler( args as never ); + const changed = await changedContent(); const resultText = result.content .map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) .join( '\n' ); const site = await siteNamed( args ); const notes = [ - ...changedContent.summaries.map( ( line ) => `Block validation: ${ line }` ), - ...changedContent.problems, + ...changed, ...( site ? await siteNotes.afterResult( site.path, resultText ) : [] ), await result.pending, ] @@ -50,7 +54,10 @@ export async function runServedTool( isError: false, }; } catch ( error ) { - const message = error instanceof Error ? error.message : String( error ); + const message = [ + error instanceof Error ? error.message : String( error ), + ...( await changedContent() ), + ].join( '\n' ); return { content: [ { type: 'text' as const, text: message } ], isError: true, diff --git a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts index 169cd543c2..689d2c8af1 100644 --- a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts +++ b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts @@ -74,4 +74,25 @@ it( 'reports the block validation of post content the call changed, on any site' /^Block validation: Bakery page 9 "": core\/html policy failed\nBakery page 9 "": 1 core\/html block\(s\) must become editable blocks/ ), ] ); + + // A call that fails after changing content reports it too. + const failing = defineTool( + 'wp_cli', + 'Runs WP-CLI.', + {}, + async () => { + fixture.edit( + 9, + '\n

Hi again

\n', + '2026-10-24 00:00:00' + ); + throw new Error( 'Command exited with code 1' ); + }, + { annotations: LOCAL_CHANGE } + ); + const failed = await runServedTool( failing, {}, createSiteNotes(), new PostContentCheck() ); + expect( failed.isError ).toBe( true ); + expect( JSON.stringify( failed.content ) ).toMatch( + /Command exited with code 1\\nBlock validation: Bakery page 9 \\"\\": core\/html policy failed/ + ); } ); diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 12e76487c6..b2f7d307c6 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -740,15 +740,26 @@ function toToolDefinition( ) || undefined; } await postContent.snapshot().catch( () => undefined ); + // A call that fails can still have changed content, as a shell + // command does that exits non-zero after saving a page. + const changedContent = async () => { + const { summaries, problems } = await postContent + .settle() + .catch( () => ( { summaries: [], problems: [] } ) ); + return [ ...summaries.map( ( line ) => `Block validation: ${ line }` ), ...problems ]; + }; // The validation report was taken from the gate, so it rides on a failure too. const result = await tool .execute( toolCallId, params, signal, onUpdate ) - .catch( ( error: unknown ) => { - if ( ! validationReport ) { + .catch( async ( error: unknown ) => { + const report = [ validationReport, ...( await changedContent() ) ] + .filter( Boolean ) + .join( '\n' ); + if ( ! report ) { throw error; } throw new Error( - `${ error instanceof Error ? error.message : String( error ) }\n${ validationReport }` + `${ error instanceof Error ? error.message : String( error ) }\n${ report }` ); } ); if ( tool.name === 'site_create' ) { @@ -759,17 +770,14 @@ function toToolDefinition( pendingWork.add( details.pending ); delete details.pending; } - const changedContent = await postContent - .settle() - .catch( () => ( { summaries: [], problems: [] } ) ); + const changed = await changedContent(); const resultText = result.content .map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) .join( '\n' ); const reports = [ validationReport, - ...changedContent.summaries.map( ( line ) => `Block validation: ${ line }` ), - ...changedContent.problems, + ...changed, ...( writtenFile ? await siteNotes.afterWrite( writtenFile ) : [] ), ...( localSite ? await siteNotes.afterResult( localSite, resultText ) : [] ), pendingWork.takeReports(), diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index 8f0808f3ec..339f90e554 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -18,6 +18,7 @@ const mocks = vi.hoisted( () => ( { // What ran, in order: tool executions and the post content check. steps: [] as string[], postChanges: { summaries: [] as string[], problems: [] as string[] }, + failNext: false, } ) ); // Model-swap test uses a synthetic id outside `AI_MODELS`; route unknowns to @@ -42,6 +43,10 @@ vi.mock( '@earendil-works/pi-coding-agent', async ( importOriginal ) => { parameters: {}, execute: async () => { mocks.steps.push( name ); + if ( mocks.failNext ) { + mocks.failNext = false; + throw new Error( 'exit 1' ); + } return { content: [ { type: 'text', text: '' } ], details: undefined }; }, } ); @@ -545,6 +550,11 @@ describe( 'pi runtime', () => { '', 'Block validation: page 9 changed\npage 9 problem', ] ); + // A failed call reports what it changed too. + mocks.failNext = true; + await expect( + getCreatedTool( 'Read' ).execute( 'r', { path: 'a/style.css' } ) + ).rejects.toThrow( 'exit 1\nBlock validation: page 9 changed\npage 9 problem' ); mocks.postChanges = { summaries: [], problems: [] }; } ); From 2c2fe503de64fe490c1587a02708595ff465a674 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 06:42:26 -0400 Subject: [PATCH 046/199] Save block markup through the editor even when a wrapper is missing A block written without its wrapper HTML loses the attributes the editor reads from that HTML, such as the class list, so the editor's fix dropped them and was refused. The fix now keeps each attribute the comment names, keeps a block as written when making it again would still be invalid, and compares text as the browser reads it. HTML outside any block is reported, since the editor shows it as one Classic block. --- apps/cli/ai/block-validation-gate.ts | 12 +-- apps/cli/ai/block-validator.ts | 75 +++++++++++++++--- .../ai/tests/block-validation-gate.test.ts | 9 ++- apps/cli/ai/tests/block-validator.test.ts | 76 +++++++++++++++++++ apps/cli/ai/tests/post-content-check.test.ts | 2 +- 5 files changed, 150 insertions(+), 24 deletions(-) create mode 100644 apps/cli/ai/tests/block-validator.test.ts diff --git a/apps/cli/ai/block-validation-gate.ts b/apps/cli/ai/block-validation-gate.ts index adc0036413..7cdaa9a13d 100644 --- a/apps/cli/ai/block-validation-gate.ts +++ b/apps/cli/ai/block-validation-gate.ts @@ -48,14 +48,6 @@ function hash( content: string ): string { return createHash( 'sha1' ).update( content ).digest( 'hex' ); } -function visibleText( html: string ): string { - return html - .replace( //g, ' ' ) - .replace( /<[^>]+>/g, ' ' ) - .replace( /\s+/g, ' ' ) - .trim(); -} - function classTokens( html: string ): Set< string > { const tokens = new Set< string >(); for ( const match of html.matchAll( /\sclass="([^"]*)"/g ) ) { @@ -199,13 +191,13 @@ export async function validateBlockMarkup( return { summary: `${ label }: ${ report.invalidBlocks } invalid block(s)`, problem: [ - `${ label }: ${ report.invalidBlocks } block(s) do not match what the block saves, and the editor cannot fix them:`, + `${ label }: ${ report.invalidBlocks } invalid block(s), which the block editor cannot fix:`, ...describeInvalidBlocks( report ), ].join( '\n' ), }; } - const lostText = visibleText( fix.fixedContent ) !== visibleText( content ); + const lostText = ! fix.sameText; const fixedClasses = classTokens( fix.fixedContent ); const droppedClasses = [ ...classTokens( content ) ].filter( ( token ) => ! fixedClasses.has( token ) diff --git a/apps/cli/ai/block-validator.ts b/apps/cli/ai/block-validator.ts index d6e1c96802..39b7d68b2c 100644 --- a/apps/cli/ai/block-validator.ts +++ b/apps/cli/ai/block-validator.ts @@ -19,6 +19,8 @@ export interface ValidationReportBase { export interface BlockFixProposal { fixedContent: string; report: ValidationReportBase; + /** Whether the fix shows the same text, read as the browser reads it. */ + sameText: boolean; } export interface ValidationReport extends ValidationReportBase { @@ -231,10 +233,24 @@ export async function validateBlocks( } // Text between blocks is a raw entry without a name and a core/freeform - // block, which is not checked; pair the rest in order. - const rawBlocks = ( typeof rawParse === 'function' ? rawParse( blockHtml ) : [] ).filter( - ( raw: any ) => raw.blockName - ); + // block; pair the rest in order. + const rawEntries = typeof rawParse === 'function' ? rawParse( blockHtml ) : []; + const rawBlocks = rawEntries.filter( ( raw: any ) => raw.blockName ); + // Markup outside any block is a Classic block in the editor, edited + // as one blob of HTML. + for ( const raw of rawEntries ) { + const outside = raw.blockName ? '' : String( raw.innerHTML ?? '' ).trim(); + if ( /<[a-z]/i.test( outside ) ) { + results.push( { + blockName: 'core/freeform', + isValid: false, + issues: [ + 'This HTML is outside any block, so the editor shows it as one Classic block. Write it as blocks: core/group for each section, with core/heading, core/paragraph, core/image and core/buttons inside.', + ], + originalContent: outside, + } ); + } + } blocks .filter( ( block: any ) => block.name !== 'core/freeform' ) .forEach( ( block: any, i: number ) => validateRecursive( block, rawBlocks[ i ] ) ); @@ -251,7 +267,12 @@ export async function validateBlocks( }; } - function normalizeBlock( block: any ): any { + // What the editor saves for a block it cannot parse as written: the + // block made again from its attributes, if that block is valid; the + // block as written otherwise. `raw` is the block from the default + // parser, whose comment attributes the editor drops when it reads + // them from markup that is not there, such as a missing wrapper's class. + function normalizeBlock( block: any, raw?: any ): any { if ( ! block.name || block.name === 'core/freeform' || block.name === 'core/missing' ) { return block; } @@ -261,23 +282,42 @@ export async function validateBlocks( return block; } + const sameBlock = rawName( raw ) === block.name; const fixedInnerBlocks = Array.isArray( block.innerBlocks ) - ? block.innerBlocks.map( normalizeBlock ) + ? block.innerBlocks.map( ( inner: any, i: number ) => + normalizeBlock( inner, sameBlock ? raw.innerBlocks?.[ i ] : undefined ) + ) : []; + const attributes = { ...block.attributes }; + for ( const [ key, value ] of Object.entries( sameBlock ? raw.attrs ?? {} : {} ) ) { + if ( attributes[ key ] === undefined && blockType.attributes?.[ key ] ) { + attributes[ key ] = value; + } + } + // The block as written, which the serializer keeps for an invalid block. + const asWritten = { ...block, attributes, isValid: false }; + const validOrAsWritten = ( candidate: any ) => { + const [ reparsed ] = wpBlocks.parse( wpBlocks.serialize( [ candidate ] ) ); + return reparsed?.isValid === false ? asWritten : candidate; + }; + // Re-creating a block the parser accepted can only lose content — // createBlock() drops the raw inner HTML of core/html, whose save - // returns null — so keep it as parsed and only fix its children. + // returns null — so keep it as parsed and only fix its children. A + // deprecated version the parser migrated can still save invalid + // markup, and is then kept as written. if ( block.isValid === true ) { - return { ...block, innerBlocks: fixedInnerBlocks }; + return validOrAsWritten( { ...block, attributes, innerBlocks: fixedInnerBlocks } ); } try { - return wpBlocks.createBlock( + const created = wpBlocks.createBlock( block.name, - block.attributes, + attributes, fixedInnerBlocks.length > 0 ? fixedInnerBlocks : undefined ); + return validOrAsWritten( created ); } catch { return block; } @@ -289,7 +329,13 @@ export async function validateBlocks( return { report: initial.report }; } - const fixedBlocks = initial.blocks.map( normalizeBlock ); + const rawTop = ( typeof rawParse === 'function' ? rawParse( html ) : [] ).filter( + ( raw: any ) => raw.blockName + ); + let named = 0; + const fixedBlocks = initial.blocks.map( ( block: any ) => + block.name === 'core/freeform' ? block : normalizeBlock( block, rawTop[ named++ ] ) + ); const fixedContent = wpBlocks.serialize( fixedBlocks ); if ( fixedContent === html ) { @@ -297,11 +343,17 @@ export async function validateBlocks( } const fixed = validateContent( fixedContent ); + // Entities and whitespace the serializer rewrites are the same text. + const textOf = ( markup: string ) => + ( new DOMParser().parseFromString( markup, 'text/html' ).body.textContent ?? '' ) + .replace( /\s+/g, ' ' ) + .trim(); return { report: initial.report, proposedFix: { fixedContent, report: fixed.report, + sameText: textOf( fixedContent ) === textOf( html ), }, }; /* eslint-enable @typescript-eslint/no-explicit-any */ @@ -313,6 +365,7 @@ export async function validateBlocks( report.proposedFix = { fixedContent: validation.proposedFix.fixedContent, report: validation.proposedFix.report as ValidationReportBase, + sameText: validation.proposedFix.sameText, }; } diff --git a/apps/cli/ai/tests/block-validation-gate.test.ts b/apps/cli/ai/tests/block-validation-gate.test.ts index c03011ce63..6a0b080aaa 100644 --- a/apps/cli/ai/tests/block-validation-gate.test.ts +++ b/apps/cli/ai/tests/block-validation-gate.test.ts @@ -21,7 +21,11 @@ function write( relativePath: string, content: string ) { return file; } -function invalidWithFix( fixedContent: string, fixValid = true ): ValidationReport { +function invalidWithFix( + fixedContent: string, + fixValid = true, + sameText = true +): ValidationReport { return { totalBlocks: 1, validBlocks: 0, @@ -31,6 +35,7 @@ function invalidWithFix( fixedContent: string, fixValid = true ): ValidationRepo ], proposedFix: { fixedContent, + sameText, report: { totalBlocks: 1, validBlocks: fixValid ? 1 : 0, @@ -119,7 +124,7 @@ describe( 'validating a file', () => { it( 'refuses a fix that would change the text', async () => { const file = write( 'tmp/home.html', original ); vi.mocked( validateBlocks ).mockResolvedValue( - invalidWithFix( '\n

\n' ) + invalidWithFix( '\n

\n', true, false ) ); const result = await validateBlockMarkupFile( file, 'http://localhost:8881' ); expect( result.problem ).toContain( 'change the text' ); diff --git a/apps/cli/ai/tests/block-validator.test.ts b/apps/cli/ai/tests/block-validator.test.ts new file mode 100644 index 0000000000..f31359bd61 --- /dev/null +++ b/apps/cli/ai/tests/block-validator.test.ts @@ -0,0 +1,76 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { cleanupValidatorPages, validateBlocks } from 'cli/ai/block-validator'; + +// Runs the site's own block editor, so it needs a running WordPress site: +// STUDIO_BLOCK_EDITOR_URL=http://localhost:8881 npx vitest run block-validator +const siteUrl = process.env.STUDIO_BLOCK_EDITOR_URL; + +const group = ( attrs: string, inner: string ) => + `\n${ inner }\n`; +const paragraph = ( attrs: string, html: string ) => + `\n${ html }\n`; + +describe.runIf( siteUrl )( 'validating in the block editor (live)', () => { + afterAll( cleanupValidatorPages ); + + it.each( [ + { + name: 'a group written without its wrapper keeps its classes', + markup: group( + '{"className":"grid"}', + group( '{"className":"cell"}', paragraph( '{}', '

Fast — always

' ) ) + ), + fixed: [ '
\n
\n' + ), + fixed: [ '"sizeSlug":"large"', 'size-large' ], + }, + { + name: 'a block made again from its attributes only if that is valid', + markup: group( + '{}', + paragraph( + '{"className":"muted","fontSize":"small","align":"right"}', + '

Top

' + ) + ), + fixed: [ '-->\n

Top

\n\n

Top

\n\n${ html }\n`, + siteUrl! + ); + expect( report.results[ 0 ].issues ).toContain( + 'core/cover\'s "minHeight" is of type number, so WordPress ignores "100dvh".' + ); + expect( report.proposedFix?.report.invalidBlocks ).toBe( 0 ); + expect( report.proposedFix?.fixedContent ).not.toContain( '100dvh' ); + }, + 60_000 + ); + it( 'reports HTML outside any block', async () => { const report = await validateBlocks( `

Hi

\n${ paragraph( '{}', '

Ok

' ) }`, From 64baa26437a66b256643d2063034350fe0f3afdb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 07:14:57 -0400 Subject: [PATCH 048/199] Continue a turn with the problems still open on the sites it changed When the agent ends its turn, the block validation problems it was told about and has not fixed, and a static front page whose content the front-page template never renders, continue the turn once each. Studio Code checks at the end of each run; Claude Code and Codex through the plugin's Stop hook. --- apps/cli/ai/block-validation-gate.ts | 7 +- apps/cli/ai/open-problems.ts | 122 ++++++++++++++++++ apps/cli/ai/post-content-check.ts | 3 + apps/cli/ai/runtimes/hooks/index.ts | 87 ++++++++----- .../hooks/tests/file-tool-hook.test.ts | 8 +- apps/cli/ai/runtimes/pi/index.ts | 91 +++++++------ apps/cli/ai/saved-copies.ts | 2 +- .../ai/tests/block-validation-gate.test.ts | 23 ++++ apps/cli/ai/tests/open-problems.test.ts | 78 +++++++++++ apps/cli/ai/tests/pi-runtime.test.ts | 31 +++++ apps/cli/ai/tests/post-content-check.test.ts | 10 +- apps/cli/index.ts | 2 +- plugins/wp-studio/hooks/hooks.json | 14 +- 13 files changed, 400 insertions(+), 78 deletions(-) create mode 100644 apps/cli/ai/open-problems.ts create mode 100644 apps/cli/ai/tests/open-problems.test.ts diff --git a/apps/cli/ai/block-validation-gate.ts b/apps/cli/ai/block-validation-gate.ts index 7cdaa9a13d..6d7db6274a 100644 --- a/apps/cli/ai/block-validation-gate.ts +++ b/apps/cli/ai/block-validation-gate.ts @@ -14,6 +14,7 @@ import { readFile, writeFile } from 'fs/promises'; import path from 'path'; import { validateHtmlBlockPolicy } from 'cli/ai/block-content-policy'; import { validateBlocks, type ValidationReportBase } from 'cli/ai/block-validator'; +import { getOpenProblems, type OpenProblems } from 'cli/ai/open-problems'; import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config/sites'; const SHARED_THEME_MARKUP = @@ -243,7 +244,8 @@ export class BlockValidationGate { private readonly validateFile: ( filePath: string, siteRoot: string - ) => Promise< FileValidation > = validateWithSite + ) => Promise< FileValidation > = validateWithSite, + private readonly open?: OpenProblems ) {} /** Queues a written file and starts validating it in the background. */ @@ -259,6 +261,7 @@ export class BlockValidationGate { const problems: string[] = []; for ( const [ filePath, tracked ] of this.files ) { const result = await this.current( filePath, tracked.siteRoot ); + this.open?.record( tracked.siteRoot, filePath, result?.problem ); if ( ! result ) { this.files.delete( filePath ); continue; @@ -318,7 +321,7 @@ const gates = new WeakMap< object, BlockValidationGate >(); export function getBlockValidationGate( session: object ): BlockValidationGate { let gate = gates.get( session ); if ( ! gate ) { - gate = new BlockValidationGate(); + gate = new BlockValidationGate( validateWithSite, getOpenProblems( session ) ); gates.set( session, gate ); } return gate; diff --git a/apps/cli/ai/open-problems.ts b/apps/cli/ai/open-problems.ts new file mode 100644 index 0000000000..13f8666dd0 --- /dev/null +++ b/apps/cli/ai/open-problems.ts @@ -0,0 +1,122 @@ +/** + * What is still wrong on the sites an agent changed, checked when it ends its + * turn: the block validation problems its calls were told about and has not + * fixed since, and site state that only shows once every call is done, such as + * a front page whose content WordPress never renders. + * + * Each problem is raised once, so the agent decides what to do with it: fix + * it, or tell the user why it stays. + */ +import { readFile } from 'fs/promises'; +import path from 'path'; +import { readSavedCopies } from 'cli/ai/saved-copies'; +import { readWordPressSite } from 'cli/ai/site-database'; + +export interface OpenProblemsState { + // The sites changed, whose state is checked at the end of the turn. + sites: string[]; + // The latest problem of each file or post validated, while it has one. + open: Record< string, string >; + raised: string[]; +} + +export class OpenProblems { + constructor( + private readonly state: OpenProblemsState = { sites: [], open: {}, raised: [] }, + private readonly siteChecks: Array< ( sitePath: string ) => Promise< string | undefined > > = [ + unrenderedFrontPage, + ] + ) {} + + /** Records the latest validation of a site's file or post, open while it has a problem. */ + record( site: string, key: string, problem?: string ): void { + if ( ! this.state.sites.includes( site ) ) { + this.state.sites.push( site ); + } + if ( problem ) { + this.state.open[ key ] = problem; + } else { + delete this.state.open[ key ]; + } + } + + /** The open problems not raised before, which are raised now. */ + async raise(): Promise< string[] > { + const siteProblems = await Promise.all( + this.state.sites.flatMap( ( site ) => + this.siteChecks.map( ( check ) => check( site ).catch( () => undefined ) ) + ) + ); + const problems = [ ...Object.values( this.state.open ), ...siteProblems ].filter( + ( problem ): problem is string => !! problem && ! this.state.raised.includes( problem ) + ); + this.state.raised.push( ...problems ); + return problems; + } + + toJSON(): OpenProblemsState { + return this.state; + } +} + +const bySession = new WeakMap< object, OpenProblems >(); + +/** The open problems of an agent session, kept across its turns. */ +export function getOpenProblems( session: object ): OpenProblems { + let open = bySession.get( session ); + if ( ! open ) { + open = new OpenProblems(); + bySession.set( session, open ); + } + return open; +} + +/** + * A static front page WordPress does not show: the theme's front-page + * template renders instead of the page (wp-includes/template.php, + * get_front_page_template), and shows the page's content only through a + * post-content block. A template that includes patterns is left alone, since + * one of them may hold that block. + */ +export async function unrenderedFrontPage( sitePath: string ): Promise< string | undefined > { + const state = await readWordPressSite( sitePath, ( site ) => { + const pageId = Number( site.option( 'page_on_front' ) ); + if ( site.option( 'show_on_front' ) !== 'page' || ! pageId ) { + return undefined; + } + const saved = readSavedCopies( site ).find( + ( copy ) => copy.postType === 'wp_template' && copy.slug === 'front-page' + ); + const post = ( id: number ) => + site.query< { title: string; content: string } >( + `SELECT post_title AS title, post_content AS content FROM ${ site.prefix }posts WHERE ID = ?`, + id + )[ 0 ]; + const page = post( pageId ); + return { + pageId, + title: page?.title ?? '', + saved: saved && { id: saved.id, content: post( saved.id )?.content ?? '' }, + themes: [ site.option( 'stylesheet' ), site.option( 'template' ) ].filter( + ( theme ): theme is string => typeof theme === 'string' && theme !== '' + ), + }; + } ); + if ( ! state ) { + return undefined; + } + let template = state.saved?.content; + let source = `the front-page template saved in the Site Editor (post ${ state.saved?.id })`; + for ( const theme of state.saved ? [] : state.themes ) { + const file = `wp-content/themes/${ theme }/templates/front-page.html`; + template = await readFile( path.join( sitePath, file ), 'utf8' ).catch( () => undefined ); + source = file; + if ( template !== undefined ) { + break; + } + } + if ( template === undefined || / where the page's content belongs, or keep the content in the template.`; +} diff --git a/apps/cli/ai/post-content-check.ts b/apps/cli/ai/post-content-check.ts index 063a7f5e62..51b04ab120 100644 --- a/apps/cli/ai/post-content-check.ts +++ b/apps/cli/ai/post-content-check.ts @@ -13,6 +13,7 @@ import { validateBlockMarkup } from 'cli/ai/block-validation-gate'; import { readWordPressSite } from 'cli/ai/site-database'; import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; import { getSiteUrl } from 'cli/lib/cli-config/sites'; +import type { OpenProblems } from 'cli/ai/open-problems'; const RENDERED_TYPES = [ 'post', @@ -85,6 +86,7 @@ async function validatePost( site: SiteData, post: Row ) { export class PostContentCheck { constructor( private readonly snapshots = new Map< string, PostSnapshot >(), + private readonly open?: OpenProblems, private readonly sites: () => Promise< SiteData[] > = localSites, private readonly validate: typeof validatePost = validatePost ) {} @@ -115,6 +117,7 @@ export class PostContentCheck { })`, problem: undefined, } ) ); + this.open?.record( site.path, `${ site.path }#${ post.id }`, result.problem ); summaries.push( result.summary ); problems.push( ...( result.problem ? [ result.problem ] : [] ) ); } diff --git a/apps/cli/ai/runtimes/hooks/index.ts b/apps/cli/ai/runtimes/hooks/index.ts index 82de9097d9..123d9b15e3 100644 --- a/apps/cli/ai/runtimes/hooks/index.ts +++ b/apps/cli/ai/runtimes/hooks/index.ts @@ -10,14 +10,17 @@ * hookSpecificOutput denies the call or adds context to its result. * * On a shell call they also report the content the call changed in the sites' - * databases (post-content-check.ts): each hook is its own process, so the - * snapshot PreToolUse takes is kept in a file for its PostToolUse. + * databases (post-content-check.ts). When the agent stops, the Stop hook + * continues its turn with the problems still open (open-problems.ts). Each + * hook is its own process, so the session's snapshots and open problems are + * kept in a file between hooks. */ import { createHash } from 'crypto'; import { readFile, writeFile } from 'fs/promises'; import os from 'os'; import path from 'path'; import { BlockValidationGate } from 'cli/ai/block-validation-gate'; +import { OpenProblems, type OpenProblemsState } from 'cli/ai/open-problems'; import { PostContentCheck, type PostSnapshot } from 'cli/ai/post-content-check'; import { createSiteNotes } from 'cli/ai/site-notes'; @@ -29,15 +32,18 @@ export interface HookInput { session_id?: string; } -type HookOutput = { - hookSpecificOutput: +// A tool hook's output, or a Stop hook's decision to continue the turn. +interface HookOutput { + hookSpecificOutput?: | { hookEventName: 'PreToolUse'; permissionDecision: 'deny'; permissionDecisionReason: string; } | { hookEventName: 'PostToolUse'; additionalContext: string }; -}; + decision?: 'block'; + reason?: string; +} const PATCH_FILE = /^\*\*\* (?:Add File|Update File|Move to): (.+)$/gm; @@ -54,37 +60,52 @@ export function getWrittenFiles( { tool_input: toolInput = {}, cwd = '' }: HookI ); } -function snapshotFile( sessionId: string ): string { +interface SessionState { + snapshots: Record< string, PostSnapshot >; + open?: OpenProblemsState; +} + +function stateFile( sessionId: string ): string { const key = createHash( 'sha1' ).update( sessionId ).digest( 'hex' ).slice( 0, 16 ); - return path.join( os.tmpdir(), `studio-hook-post-content-${ key }.json` ); + return path.join( os.tmpdir(), `studio-hook-session-${ key }.json` ); } -// The session's post content check, with the snapshots its last hook kept. -async function withPostContent< T >( +// The session's checks, with the state its last hook kept; without a session, +// a call's own. +async function withSession< T >( sessionId: string | undefined, - run: ( check: PostContentCheck ) => Promise< T > -): Promise< T | undefined > { - if ( ! sessionId ) { - return undefined; + run: ( checks: { postContent: PostContentCheck; open: OpenProblems } ) => Promise< T > +): Promise< T > { + const file = sessionId ? stateFile( sessionId ) : undefined; + const kept: Partial< SessionState > = file + ? await readFile( file, 'utf8' ) + .then( ( text ) => JSON.parse( text ) ) + .catch( () => ( {} ) ) + : {}; + const snapshots = new Map( Object.entries( kept.snapshots ?? {} ) ); + const open = new OpenProblems( kept.open ); + const result = await run( { postContent: new PostContentCheck( snapshots, open ), open } ); + if ( file ) { + const state: SessionState = { snapshots: Object.fromEntries( snapshots ), open: open.toJSON() }; + await writeFile( file, JSON.stringify( state ), 'utf8' ); } - const file = snapshotFile( sessionId ); - const kept: Record< string, PostSnapshot > = await readFile( file, 'utf8' ) - .then( ( text ) => JSON.parse( text ) ) - .catch( () => ( {} ) ); - const snapshots = new Map( Object.entries( kept ) ); - const result = await run( new PostContentCheck( snapshots ) ); - await writeFile( file, JSON.stringify( Object.fromEntries( snapshots ) ), 'utf8' ); return result; } export async function runFileToolHook( input: HookInput, - gate = new BlockValidationGate() + makeGate = ( open?: OpenProblems ) => new BlockValidationGate( undefined, open ) ): Promise< HookOutput | undefined > { + if ( input.hook_event_name === 'Stop' ) { + const problems = await withSession( input.session_id, ( { open } ) => open.raise() ).catch( + () => [] + ); + return problems.length ? { decision: 'block', reason: problems.join( '\n' ) } : undefined; + } const files = getWrittenFiles( input ); - const siteNotes = createSiteNotes( { gate } ); if ( input.hook_event_name === 'PreToolUse' ) { - await withPostContent( input.session_id, ( check ) => check.snapshot() ).catch( + const siteNotes = createSiteNotes( { gate: makeGate() } ); + await withSession( input.session_id, ( { postContent } ) => postContent.snapshot() ).catch( () => undefined ); for ( const file of files ) { @@ -104,14 +125,18 @@ export async function runFileToolHook( if ( input.hook_event_name !== 'PostToolUse' ) { return undefined; } - const notes: string[] = []; - for ( const file of files ) { - notes.push( ...( await siteNotes.afterWrite( file ) ) ); - } - const { summaries, problems } = await gate.settle(); - const changedContent = ( await withPostContent( input.session_id, ( check ) => - check.settle() - ).catch( () => undefined ) ) ?? { summaries: [], problems: [] }; + const checked = await withSession( input.session_id, async ( { postContent, open } ) => { + const gate = makeGate( open ); + const siteNotes = createSiteNotes( { gate } ); + const notes: string[] = []; + for ( const file of files ) { + notes.push( ...( await siteNotes.afterWrite( file ) ) ); + } + return { notes, files: await gate.settle(), content: await postContent.settle() }; + } ).catch( () => undefined ); + const { summaries, problems } = checked?.files ?? { summaries: [], problems: [] }; + const changedContent = checked?.content ?? { summaries: [], problems: [] }; + const notes = checked?.notes ?? []; const context = [ ...[ ...summaries, ...changedContent.summaries ].map( ( line ) => `Block validation: ${ line }` diff --git a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts index 988e9f15ba..53eac1fb6e 100644 --- a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts +++ b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts @@ -83,7 +83,7 @@ it( 'adds to a write the block validation and what WordPress renders instead of const post = ( file: string ) => runFileToolHook( { hook_event_name: 'PostToolUse', tool_name: 'Write', tool_input: { file_path: file } }, - gate + () => gate ); expect( await post( header ) ).toEqual( { @@ -293,4 +293,10 @@ it( 'reports the block validation of post content a shell call changed', async ( ), }, } ); + // Stopping continues the turn with the problem still open, once. + expect( await hook( 'Stop' ) ).toEqual( { + decision: 'block', + reason: expect.stringContaining( '1 core/html block(s) must become editable blocks' ), + } ); + expect( await hook( 'Stop' ) ).toBeUndefined(); } ); diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index b2f7d307c6..20b3bbed86 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -45,6 +45,7 @@ import { getBlockValidationGate, type BlockValidationGate } from 'cli/ai/block-v import { withDesignSystemPreview } from 'cli/ai/chat-artifacts'; import { isImageGenerationAvailable } from 'cli/ai/image-generation'; import { emitEvent } from 'cli/ai/json-events'; +import { getOpenProblems } from 'cli/ai/open-problems'; import { PostContentCheck } from 'cli/ai/post-content-check'; import { readActiveSiteFacts } from 'cli/ai/site-facts'; import { createSiteNotes, type SiteNotes } from 'cli/ai/site-notes'; @@ -97,6 +98,7 @@ const STUDIO_AGENT_DIR = STUDIO_SITES_ROOT; // starts the same reasoning over and runs out again. const MAX_EMPTY_STOP_CONTINUES = 2; const EMPTY_STOP_CONTINUE_TYPE = 'studio.empty_stop_continue'; +const OPEN_PROBLEMS_TYPE = 'studio.open_problems'; const STUDIO_COMPACTION_SETTINGS = { enabled: true, reserveTokens: 16_384, @@ -271,32 +273,22 @@ async function runAgentSessionTurn( session = await createStudioAgentSession( config, family, resolved.creds, payloadGuardState ); setActiveSession( session ); const previewAttached = typeof process.send === 'function'; - let emptyStopContinues = 0; - let continueAfterEmptyStop = false; - let cutOffReasoning = ''; - unsubscribe = session.subscribe( ( event ) => { - updateStudioToolPayloadGuardState( event, payloadGuardState ); - if ( - event.type === 'agent_end' && - ! event.willRetry && - emptyStopContinues < MAX_EMPTY_STOP_CONTINUES - ) { - const lastAssistant = findLastAssistant( event.messages ); - if ( isEmptyStop( lastAssistant ) ) { - // Not final: consumers wait for the turn's real end. - continueAfterEmptyStop = true; - cutOffReasoning = ( lastAssistant?.content ?? [] ) - .map( ( block ) => ( block.type === 'thinking' ? block.thinking : '' ) ) - .join( '' ) - .trim(); - config.onEvent( { ...event, willRetry: true } ); - return; - } - } + const emit = ( event: AgentSessionEvent ) => { config.onEvent( event ); if ( previewAttached && changesPreview( event ) ) { emitEvent( { type: 'preview.reload', timestamp: new Date().toISOString() } ); } + }; + // The end of each run is held until it is known whether the turn + // continues: consumers wait for the turn's real end. + let heldEnd: Extract< AgentSessionEvent, { type: 'agent_end' } > | undefined; + unsubscribe = session.subscribe( ( event ) => { + updateStudioToolPayloadGuardState( event, payloadGuardState ); + if ( event.type === 'agent_end' && ! event.willRetry ) { + heldEnd = event; + return; + } + emit( event ); } ); if ( controller.signal.aborted ) { @@ -305,6 +297,37 @@ async function runAgentSessionTurn( return; } + const openProblems = getOpenProblems( config.session ); + let emptyStopContinues = 0; + // What continues the turn after a run ends, if anything: a reply cut off + // before it said anything, then the problems still open on the sites. + const continuation = async ( + end: Extract< AgentSessionEvent, { type: 'agent_end' } > + ): Promise< { customType: string; content: string } | undefined > => { + const lastAssistant = findLastAssistant( end.messages ); + if ( isEmptyStop( lastAssistant ) && emptyStopContinues < MAX_EMPTY_STOP_CONTINUES ) { + emptyStopContinues++; + const cutOffReasoning = ( lastAssistant?.content ?? [] ) + .map( ( block ) => ( block.type === 'thinking' ? block.thinking : '' ) ) + .join( '' ) + .trim(); + return { + customType: EMPTY_STOP_CONTINUE_TYPE, + content: cutOffReasoning + ? `Your reasoning was cut off before you replied. Continue from where it stopped:\n\n${ cutOffReasoning }` + : 'continue', + }; + } + if ( lastAssistant?.stopReason !== 'stop' ) { + return undefined; + } + await getPendingWork( config.session ).settle(); + const problems = await openProblems.raise(); + return problems.length + ? { customType: OPEN_PROBLEMS_TYPE, content: problems.join( '\n' ) } + : undefined; + }; + await session.prompt( config.prompt, { expandPromptTemplates: false, source: 'rpc', @@ -314,23 +337,19 @@ async function runAgentSessionTurn( mimeType: image.mimeType, } ) ), } ); - while ( continueAfterEmptyStop ) { - continueAfterEmptyStop = false; + for ( let end = heldEnd; end; end = heldEnd ) { + heldEnd = undefined; + const next = await continuation( end ); + if ( ! next ) { + emit( end ); + break; + } + emit( { ...end, willRetry: true } ); if ( controller.signal.aborted ) { config.onEvent( syntheticErrorAgentEnd( 'aborted', '' ) ); break; } - emptyStopContinues++; - await session.sendCustomMessage( - { - customType: EMPTY_STOP_CONTINUE_TYPE, - content: cutOffReasoning - ? `Your reasoning was cut off before you replied. Continue from where it stopped:\n\n${ cutOffReasoning }` - : 'continue', - display: false, - }, - { triggerTurn: true } - ); + await session.sendCustomMessage( { ...next, display: false }, { triggerTurn: true } ); } await getPendingWork( config.session ).settle(); } catch ( error ) { @@ -392,7 +411,7 @@ async function createStudioAgentSession( gate: blockValidationGate, } ); // What a call changed in the content the sites render, reported on its result. - const postContent = new PostContentCheck(); + const postContent = new PostContentCheck( undefined, getOpenProblems( config.session ) ); const toolDefinitions = [ ...tools, ...mcpTools ].map( ( tool ) => toToolDefinition( tool, payloadGuardState, pendingWork, blockValidationGate, { confirm, diff --git a/apps/cli/ai/saved-copies.ts b/apps/cli/ai/saved-copies.ts index a80a321c43..9ade5eeb6a 100644 --- a/apps/cli/ai/saved-copies.ts +++ b/apps/cli/ai/saved-copies.ts @@ -50,7 +50,7 @@ function styleAreas( content: string ): string[] { * The copies saved in the Site Editor for the active theme, read from an open * site's database. Global Styles that set nothing are left out. */ -function readSavedCopies( site: WordPressSite ): SavedCopy[] { +export function readSavedCopies( site: WordPressSite ): SavedCopy[] { const { prefix } = site; const stylesheet = site.option( 'stylesheet' ); if ( typeof stylesheet !== 'string' ) return []; diff --git a/apps/cli/ai/tests/block-validation-gate.test.ts b/apps/cli/ai/tests/block-validation-gate.test.ts index 6a0b080aaa..75e67ef6ae 100644 --- a/apps/cli/ai/tests/block-validation-gate.test.ts +++ b/apps/cli/ai/tests/block-validation-gate.test.ts @@ -9,6 +9,7 @@ import { validateBlockMarkupFile, } from 'cli/ai/block-validation-gate'; import { validateBlocks, type ValidationReport } from 'cli/ai/block-validator'; +import { OpenProblems } from 'cli/ai/open-problems'; vi.mock( 'cli/ai/block-validator', () => ( { validateBlocks: vi.fn() } ) ); @@ -175,6 +176,28 @@ describe( 'the gate', () => { expect( ( await gate.settle() ).summaries ).toEqual( [] ); } ); + it( 'keeps a file open while its latest validation has a problem', async () => { + const file = write( 'wp-content/themes/t/templates/home.html', 'bad' ); + const open = new OpenProblems( undefined, [] ); + const gate = new BlockValidationGate( async ( filePath ) => { + const content = readFileSync( filePath, 'utf8' ); + return { + ...ok( content, content ), + problem: content === 'bad' ? 'home.html is invalid' : undefined, + }; + }, open ); + gate.track( file, site ); + await gate.settle(); + writeFileSync( file, 'good' ); + gate.track( file, site ); + await gate.settle(); + expect( await open.raise() ).toEqual( [] ); + writeFileSync( file, 'bad' ); + gate.track( file, site ); + await gate.settle(); + expect( await open.raise() ).toEqual( [ 'home.html is invalid' ] ); + } ); + it( 'reports a file it could not validate', async () => { const gate = new BlockValidationGate( async () => { throw new Error( 'the site is not running' ); diff --git a/apps/cli/ai/tests/open-problems.test.ts b/apps/cli/ai/tests/open-problems.test.ts new file mode 100644 index 0000000000..867965ef70 --- /dev/null +++ b/apps/cli/ai/tests/open-problems.test.ts @@ -0,0 +1,78 @@ +import { mkdirSync, writeFileSync } from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { OpenProblems, unrenderedFrontPage } from 'cli/ai/open-problems'; +import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; + +describe( 'OpenProblems', () => { + it( 'raises each problem still open once, and the problems of the sites changed', async () => { + const checked: string[] = []; + const open = new OpenProblems( undefined, [ + async ( site ) => { + checked.push( site ); + return `${ site } has a problem`; + }, + ] ); + open.record( '/a', '/a/page-1', 'page 1 is invalid' ); + open.record( '/a', '/a/page-2', 'page 2 is invalid' ); + open.record( '/a', '/a/page-2' ); + + expect( await open.raise() ).toEqual( [ 'page 1 is invalid', '/a has a problem' ] ); + expect( checked ).toEqual( [ '/a' ] ); + expect( await open.raise() ).toEqual( [] ); + + open.record( '/a', '/a/page-1', 'page 1 is still invalid' ); + const kept = new OpenProblems( open.toJSON(), [] ); + expect( await kept.raise() ).toEqual( [ 'page 1 is still invalid' ] ); + } ); +} ); + +// The front-page template renders instead of the static front page, and shows +// its content only through a post-content block. +describe( 'unrenderedFrontPage', () => { + const SHOWN = ''; + const NOT_SHOWN = '\n

Hi

\n'; + it.each( [ + { name: 'a theme template without post-content', file: NOT_SHOWN, problem: true }, + { name: 'a theme template with post-content', file: SHOWN, problem: false }, + { + name: 'a template including a pattern', + file: '', + problem: false, + }, + { name: 'no front-page template', file: undefined, problem: false }, + { name: 'latest posts on the front', file: NOT_SHOWN, front: 'posts', problem: false }, + { name: 'a saved copy with post-content', file: NOT_SHOWN, saved: SHOWN, problem: false }, + { name: 'a saved copy without post-content', file: SHOWN, saved: NOT_SHOWN, problem: true }, + { name: "the parent theme's template", parentFile: NOT_SHOWN, problem: true }, + ] )( '$name: $problem', async ( { file, parentFile, front = 'page', saved, problem } ) => { + const site = createSite(); + site.setOptions( { show_on_front: front, page_on_front: '9' } ); + site.save( 9, 'page', 'home' ); + for ( const [ theme, content ] of [ + [ 'bakery', file ], + [ 'bakery-parent', parentFile ], + ] ) { + if ( content !== undefined ) { + const template = path.join( site.root, 'wp-content/themes', theme!, 'templates' ); + mkdirSync( template, { recursive: true } ); + writeFileSync( path.join( template, 'front-page.html' ), content ); + } + } + if ( saved ) { + site.save( 20, 'wp_template', 'front-page', saved ); + } + + const result = await unrenderedFrontPage( site.root ); + + expect( result !== undefined ).toBe( problem ); + if ( result ) { + expect( result ).toContain( + saved + ? 'the front-page template saved in the Site Editor (post 20)' + : `wp-content/themes/${ file ? 'bakery' : 'bakery-parent' }/templates/front-page.html` + ); + expect( result ).toContain( 'get_front_page_template' ); + } + } ); +} ); diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index 339f90e554..ed9d4b972b 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -19,6 +19,12 @@ const mocks = vi.hoisted( () => ( { steps: [] as string[], postChanges: { summaries: [] as string[], problems: [] as string[] }, failNext: false, + // What each end of a run finds still open on the sites. + openProblems: [] as string[][], +} ) ); + +vi.mock( 'cli/ai/open-problems', () => ( { + getOpenProblems: () => ( { raise: async () => mocks.openProblems.shift() ?? [] } ), } ) ); // Model-swap test uses a synthetic id outside `AI_MODELS`; route unknowns to @@ -272,6 +278,7 @@ describe( 'pi runtime', () => { mocks.continueEvents = []; mocks.customMessages = []; mocks.onCustomMessage = null; + mocks.openProblems = []; mocks.createAgentSession.mockReset(); mocks.createAgentSession.mockImplementation( async ( options: CreateAgentSessionOptions ) => { const session = new FakeSession( options ); @@ -702,6 +709,30 @@ describe( 'pi runtime', () => { expect( mocks.customMessages ).toHaveLength( 0 ); } ); + it( 'continues a finished turn with the problems still open on the sites, until none are', async () => { + mocks.nextEvents = runEndingWith( assistantMessage( [ { type: 'text', text: 'Done.' } ] ) ); + mocks.continueEvents = [ + runEndingWith( assistantMessage( [ { type: 'text', text: 'Fixed.' } ] ) ), + ]; + mocks.openProblems = [ [ 'page 9: 2 invalid blocks', 'front page not shown' ] ]; + + const events = await runFast(); + + expect( mocks.customMessages ).toEqual( [ + { + message: { + customType: 'studio.open_problems', + content: 'page 9: 2 invalid blocks\nfront page not shown', + display: false, + }, + options: { triggerTurn: true }, + }, + ] ); + const ends = agentEnds( events ); + expect( ends.map( ( event ) => event.willRetry ) ).toEqual( [ true, false ] ); + expect( ends[ 1 ].messages[ 0 ] ).toMatchObject( { content: [ { text: 'Fixed.' } ] } ); + } ); + it( 'ends the turn as interrupted when the user stops it first', async () => { mocks.nextEvents = runEndingWith( emptyReply() ); const events: AgentSessionEvent[] = []; diff --git a/apps/cli/ai/tests/post-content-check.test.ts b/apps/cli/ai/tests/post-content-check.test.ts index 49c69594b8..4e6235d761 100644 --- a/apps/cli/ai/tests/post-content-check.test.ts +++ b/apps/cli/ai/tests/post-content-check.test.ts @@ -60,7 +60,7 @@ describe( 'PostContentCheck', () => { reported: [], }, ] )( 'reports what a call changed: $name', async ( { change, reported } ) => { - const check = new PostContentCheck( new Map(), async () => [ site ] ); + const check = new PostContentCheck( new Map(), undefined, async () => [ site ] ); await check.snapshot(); change(); expect( ( await check.settle() ).summaries ).toEqual( reported ); @@ -69,13 +69,13 @@ describe( 'PostContentCheck', () => { } ); it( 'reports nothing for a site it has not seen before', async () => { - const check = new PostContentCheck( new Map(), async () => [ site ] ); + const check = new PostContentCheck( new Map(), undefined, async () => [ site ] ); fixture.edit( 9, HTML_ONLY ); expect( await check.settle() ).toEqual( { summaries: [], problems: [] } ); } ); it( 'asks for editable blocks in place of core/html', async () => { - const check = new PostContentCheck( new Map(), async () => [ site ] ); + const check = new PostContentCheck( new Map(), undefined, async () => [ site ] ); await check.snapshot(); fixture.edit( 9, HTML_ONLY ); const { problems } = await check.settle(); @@ -85,7 +85,7 @@ describe( 'PostContentCheck', () => { it( 'saves a lossless editor fix next to the site, with the command that applies it', async () => { vi.mocked( validateBlocks ).mockResolvedValue( losslessFix ); - const check = new PostContentCheck( new Map(), async () => [ site ] ); + const check = new PostContentCheck( new Map(), undefined, async () => [ site ] ); await check.snapshot(); fixture.edit( 9, UNWRAPPED_GROUP ); const { summaries, problems } = await check.settle(); @@ -98,7 +98,7 @@ describe( 'PostContentCheck', () => { it( 'reports a validation that cannot run', async () => { vi.mocked( validateBlocks ).mockRejectedValue( new Error( 'site is not running' ) ); - const check = new PostContentCheck( new Map(), async () => [ site ] ); + const check = new PostContentCheck( new Map(), undefined, async () => [ site ] ); await check.snapshot(); fixture.edit( 9, UNWRAPPED_GROUP ); expect( ( await check.settle() ).summaries ).toEqual( [ diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 9a89e22459..89bf76f741 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -312,7 +312,7 @@ async function main() { sitesYargs.version( false ).demandCommand( 1, __( 'You must provide a valid command' ) ); } ) .command( { - // The WordPress Studio plugin's hooks on the agent's own file tools. + // The WordPress Studio plugin's hooks on the agent's own tools and its stop. command: '_file-tool-hook', describe: false, // Hidden command handler: async () => { diff --git a/plugins/wp-studio/hooks/hooks.json b/plugins/wp-studio/hooks/hooks.json index 446f4a50cd..ff21590e04 100644 --- a/plugins/wp-studio/hooks/hooks.json +++ b/plugins/wp-studio/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Studio's checks on writes to a WordPress site's files with the agent's own file tools: refuses files a WordPress or theme update replaces, and reports block validation and what WordPress renders differently from the file; on a shell call, reports block validation of the post content the call changed.", + "description": "Studio's checks on writes to a WordPress site's files with the agent's own file tools: refuses files a WordPress or theme update replaces, and reports block validation and what WordPress renders differently from the file; on a shell call, reports block validation of the post content the call changed; when the agent stops, continues its turn with the problems still open on the sites.", "hooks": { "PreToolUse": [ { @@ -26,6 +26,18 @@ } ] } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/launch-mcp\" hook", + "commandWindows": "\"${CLAUDE_PLUGIN_ROOT}\\scripts\\launch-mcp.cmd\" hook", + "timeout": 120 + } + ] + } ] } } From eb4c261a05d29077cda0e1040b504c292f352003 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 09:15:25 -0400 Subject: [PATCH 049/199] Note a front-page template that renders no page's content WordPress renders a theme's front-page template at the front page under either Reading setting, and shows a page there only through a post-content block. A front-page template written without one, with the theme patterns it includes, gets a note citing core and the Theme Directory requirement; the end of a turn raises it for the active theme under either setting. --- apps/cli/ai/front-page-template.ts | 71 +++++++++++ apps/cli/ai/open-problems.ts | 34 +++--- apps/cli/ai/site-file-guards.ts | 33 +++++- apps/cli/ai/site-notes.ts | 2 + apps/cli/ai/tests/front-page-template.test.ts | 111 ++++++++++++++++++ apps/cli/ai/tests/open-problems.test.ts | 55 +-------- 6 files changed, 235 insertions(+), 71 deletions(-) create mode 100644 apps/cli/ai/front-page-template.ts create mode 100644 apps/cli/ai/tests/front-page-template.test.ts diff --git a/apps/cli/ai/front-page-template.ts b/apps/cli/ai/front-page-template.ts new file mode 100644 index 0000000000..b72c8048f9 --- /dev/null +++ b/apps/cli/ai/front-page-template.ts @@ -0,0 +1,71 @@ +/** + * A theme's front-page template, which WordPress renders at the site's front + * page whatever Settings → Reading picks (wp-includes/template.php, + * get_front_page_template). It shows the page Reading picks only through a + * post-content block, so a theme must render one there: the Theme Directory + * requires a theme to show the content the front page setting picks. + */ +import { readdir, readFile } from 'fs/promises'; +import path from 'path'; + +const REQUIREMENT = 'https://make.wordpress.org/themes/handbook/review/required/'; +const POST_CONTENT = //g; + +/** The front-page template's path, relative to its theme. */ +export const FRONT_PAGE_TEMPLATE = 'templates/front-page.html'; + +// The theme's patterns, by slug, from the Slug header of patterns/*.php. +async function themePatterns( themeDir: string ): Promise< Map< string, string > > { + const dir = path.join( themeDir, 'patterns' ); + const files = await readdir( dir ).catch( () => [] as string[] ); + const patterns = new Map< string, string >(); + for ( const file of files.filter( ( name ) => name.endsWith( '.php' ) ) ) { + const content = await readFile( path.join( dir, file ), 'utf8' ).catch( () => '' ); + const slug = content.match( /^[\s*]*Slug:\s*(\S+)/im )?.[ 1 ]; + if ( slug ) { + patterns.set( slug, content ); + } + } + return patterns; +} + +/** + * Whether a front-page template renders a page's content, with the theme's + * patterns it includes; undefined when it includes a pattern the theme does + * not hold, which may render it. + */ +export async function rendersPageContent( + template: string, + themeDirs: string[] +): Promise< boolean | undefined > { + const slugs = [ ...template.matchAll( PATTERN ) ].map( ( match ) => { + try { + return String( JSON.parse( match[ 1 ] ).slug ?? '' ); + } catch { + return ''; + } + } ); + if ( POST_CONTENT.test( template ) ) { + return true; + } + const patterns = new Map< string, string >(); + for ( const dir of [ ...themeDirs ].reverse() ) { + for ( const [ slug, content ] of await themePatterns( dir ) ) { + patterns.set( slug, content ); + } + } + const included = slugs.map( ( slug ) => patterns.get( slug ) ); + if ( included.some( ( content ) => content === undefined ) ) { + return undefined; + } + return included.some( ( content ) => POST_CONTENT.test( content! ) ); +} + +/** What a front-page template without a page's content makes WordPress do. */ +export function frontPageTemplateProblem( source: string, frontPage?: string ): string { + const shows = frontPage + ? `so ${ frontPage }, the front page Settings → Reading picks, never shows, and edits to it change nothing on the site` + : "so no page set as the homepage (Settings → Reading, or the editor's Set as homepage) can show, and the content lives in the theme instead of a page the site owner edits"; + return `WordPress renders ${ source } at the front page whatever Settings → Reading picks (wp-includes/template.php, get_front_page_template), and it has no core/post-content block, the only block that renders the page shown (wp-includes/blocks/post-content.php), ${ shows }. The Theme Directory requires a theme to show the content the front page setting picks (${ REQUIREMENT }). Put the content in a page, set it as the homepage (show_on_front=page, page_on_front), and render it with in the template, or remove the template so page.html renders the page.`; +} diff --git a/apps/cli/ai/open-problems.ts b/apps/cli/ai/open-problems.ts index 13f8666dd0..ddd396ac76 100644 --- a/apps/cli/ai/open-problems.ts +++ b/apps/cli/ai/open-problems.ts @@ -9,6 +9,11 @@ */ import { readFile } from 'fs/promises'; import path from 'path'; +import { + FRONT_PAGE_TEMPLATE, + frontPageTemplateProblem, + rendersPageContent, +} from 'cli/ai/front-page-template'; import { readSavedCopies } from 'cli/ai/saved-copies'; import { readWordPressSite } from 'cli/ai/site-database'; @@ -72,18 +77,12 @@ export function getOpenProblems( session: object ): OpenProblems { } /** - * A static front page WordPress does not show: the theme's front-page - * template renders instead of the page (wp-includes/template.php, - * get_front_page_template), and shows the page's content only through a - * post-content block. A template that includes patterns is left alone, since - * one of them may hold that block. + * A front-page template of the active theme that renders no page's content, + * which WordPress shows at the front page whatever Settings → Reading picks + * (front-page-template.ts). */ export async function unrenderedFrontPage( sitePath: string ): Promise< string | undefined > { const state = await readWordPressSite( sitePath, ( site ) => { - const pageId = Number( site.option( 'page_on_front' ) ); - if ( site.option( 'show_on_front' ) !== 'page' || ! pageId ) { - return undefined; - } const saved = readSavedCopies( site ).find( ( copy ) => copy.postType === 'wp_template' && copy.slug === 'front-page' ); @@ -92,12 +91,12 @@ export async function unrenderedFrontPage( sitePath: string ): Promise< string | `SELECT post_title AS title, post_content AS content FROM ${ site.prefix }posts WHERE ID = ?`, id )[ 0 ]; - const page = post( pageId ); + const pageId = + site.option( 'show_on_front' ) === 'page' ? Number( site.option( 'page_on_front' ) ) : 0; return { - pageId, - title: page?.title ?? '', + frontPage: pageId ? `page ${ pageId } "${ post( pageId )?.title ?? '' }"` : undefined, saved: saved && { id: saved.id, content: post( saved.id )?.content ?? '' }, - themes: [ site.option( 'stylesheet' ), site.option( 'template' ) ].filter( + themes: [ ...new Set( [ site.option( 'stylesheet' ), site.option( 'template' ) ] ) ].filter( ( theme ): theme is string => typeof theme === 'string' && theme !== '' ), }; @@ -105,18 +104,21 @@ export async function unrenderedFrontPage( sitePath: string ): Promise< string | if ( ! state ) { return undefined; } + const themeDirs = state.themes.map( ( theme ) => + path.join( sitePath, 'wp-content', 'themes', theme ) + ); let template = state.saved?.content; let source = `the front-page template saved in the Site Editor (post ${ state.saved?.id })`; for ( const theme of state.saved ? [] : state.themes ) { - const file = `wp-content/themes/${ theme }/templates/front-page.html`; + const file = `wp-content/themes/${ theme }/${ FRONT_PAGE_TEMPLATE }`; template = await readFile( path.join( sitePath, file ), 'utf8' ).catch( () => undefined ); source = file; if ( template !== undefined ) { break; } } - if ( template === undefined || / where the page's content belongs, or keep the content in the template.`; + return frontPageTemplateProblem( source, state.frontPage ); } diff --git a/apps/cli/ai/site-file-guards.ts b/apps/cli/ai/site-file-guards.ts index f802af7a48..25b7c31b4c 100644 --- a/apps/cli/ai/site-file-guards.ts +++ b/apps/cli/ai/site-file-guards.ts @@ -5,12 +5,17 @@ * these with the trigger each runs on. */ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; -import { unlink, writeFile } from 'fs/promises'; +import { readFile, unlink, writeFile } from 'fs/promises'; import path from 'path'; import { pathToFileURL } from 'url'; import { selfHostThemeJsonFonts } from '@studio/common/lib/theme-fonts'; import { trappedFullWidthNotes } from 'cli/ai/block-alignment'; import { renderDesignBoard } from 'cli/ai/design-board'; +import { + FRONT_PAGE_TEMPLATE, + frontPageTemplateProblem, + rendersPageContent, +} from 'cli/ai/front-page-template'; import { savedCopyWriteNote } from 'cli/ai/saved-copies'; import { resolveScreenshotDirectory } from 'cli/ai/screenshot-storage'; import { readWordPressSite } from 'cli/ai/site-database'; @@ -321,3 +326,29 @@ export async function clearThemePatternCache( siteFile: SiteFile ): Promise< str } return []; } + +/** + * A theme's front-page template written without a page's content + * (front-page-template.ts), noted for the file whether or not its theme is + * active yet. + */ +export async function frontPageTemplateNotes( { + siteRoot, + relativePath, + theme, +}: SiteFile ): Promise< string[] > { + if ( theme?.file !== FRONT_PAGE_TEMPLATE ) { + return []; + } + const template = await readFile( path.join( siteRoot, relativePath ), 'utf8' ); + const parent = ( + await readFile( path.join( theme.dir, 'style.css' ), 'utf8' ).catch( () => '' ) + ).match( /^[\s*]*Template:\s*(\S+)/im )?.[ 1 ]; + const themeDirs = [ + theme.dir, + ...( parent ? [ path.join( path.dirname( theme.dir ), parent ) ] : [] ), + ]; + return ( await rendersPageContent( template, themeDirs ) ) === false + ? [ frontPageTemplateProblem( relativePath ) ] + : []; +} diff --git a/apps/cli/ai/site-notes.ts b/apps/cli/ai/site-notes.ts index c382f497d4..de3b325047 100644 --- a/apps/cli/ai/site-notes.ts +++ b/apps/cli/ai/site-notes.ts @@ -23,6 +23,7 @@ import { blockMarkupNotes, clearThemePatternCache, externalImageNotes, + frontPageTemplateNotes, getSiteFileWriteRefusal, resolveSiteFile, savedCopyNotes, @@ -101,6 +102,7 @@ function sources( { stated = new Map(), gate }: SiteNotesOptions ): Sources { themeCssNotes, externalImageNotes, savedCopyNotes, + frontPageTemplateNotes, ], result: [ blockSettingsSource(), siteFactsSource( stated ) ], }; diff --git a/apps/cli/ai/tests/front-page-template.test.ts b/apps/cli/ai/tests/front-page-template.test.ts new file mode 100644 index 0000000000..591a8da9fa --- /dev/null +++ b/apps/cli/ai/tests/front-page-template.test.ts @@ -0,0 +1,111 @@ +import { mkdirSync, writeFileSync } from 'fs'; +import path from 'path'; +import { expect, it } from 'vitest'; +import { unrenderedFrontPage } from 'cli/ai/open-problems'; +import { frontPageTemplateNotes, resolveSiteFile } from 'cli/ai/site-file-guards'; +import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; + +const SHOWN = ''; +const NOT_SHOWN = '\n

Hi

\n'; +const pattern = ( slug: string ) => ``; + +// WordPress renders the active theme's front-page template at the front page +// under either Reading setting, and shows a page there only through a +// post-content block: noted when the template is written, and raised at the +// end of a turn for the site's state. +it.each( [ + { name: 'a template without post-content', file: NOT_SHOWN, problem: true }, + { name: 'a template with post-content', file: SHOWN, problem: false }, + { name: 'latest posts on the front', file: NOT_SHOWN, front: 'posts', problem: true }, + { + name: 'a theme pattern with post-content', + file: pattern( 'bakery/main' ), + patternContent: SHOWN, + problem: false, + }, + { + name: 'a theme pattern without it', + file: pattern( 'bakery/main' ), + patternContent: NOT_SHOWN, + problem: true, + }, + { name: 'a pattern the theme does not hold', file: pattern( 'other/main' ), problem: false }, + { name: 'no front-page template', file: undefined, problem: false }, + { + name: 'a saved copy with post-content', + file: NOT_SHOWN, + saved: SHOWN, + written: true, + problem: false, + }, + { + name: 'a saved copy without post-content', + file: SHOWN, + saved: NOT_SHOWN, + written: false, + problem: true, + }, + { name: "the parent theme's template", parentFile: NOT_SHOWN, problem: true }, +] )( + '$name: $problem', + async ( { file, parentFile, front = 'page', patternContent, saved, written, problem } ) => { + const site = createSite(); + site.setOptions( { show_on_front: front, page_on_front: '9' } ); + site.save( 9, 'page', 'home' ); + const themes = path.join( site.root, 'wp-content/themes' ); + const templates: string[] = []; + for ( const [ theme, content ] of [ + [ 'bakery', file ], + [ 'bakery-parent', parentFile ], + ] ) { + if ( content !== undefined ) { + mkdirSync( path.join( themes, theme!, 'templates' ), { recursive: true } ); + templates.push( path.join( themes, theme!, 'templates/front-page.html' ) ); + writeFileSync( templates[ templates.length - 1 ], content ); + } + } + if ( patternContent ) { + mkdirSync( path.join( themes, 'bakery-parent/patterns' ), { recursive: true } ); + writeFileSync( + path.join( themes, 'bakery-parent/patterns/main.php' ), + `\n${ patternContent }` + ); + writeFileSync( + path.join( themes, 'bakery/style.css' ), + '/*\nTheme Name: Bakery\nTemplate: bakery-parent\n*/' + ); + } + if ( saved ) { + site.save( 20, 'wp_template', 'front-page', saved ); + } + + const raised = await unrenderedFrontPage( site.root ); + const noted = ( + await Promise.all( + templates.map( ( template ) => frontPageTemplateNotes( resolveSiteFile( template )! ) ) + ) + ).flat(); + + expect( raised !== undefined ).toBe( problem ); + expect( noted.length > 0 ).toBe( written ?? problem ); + for ( const text of [ raised, ...noted ].filter( Boolean ) ) { + expect( text ).toContain( 'get_front_page_template' ); + } + if ( raised ) { + expect( raised ).toContain( + saved + ? 'the front-page template saved in the Site Editor (post 20)' + : `wp-content/themes/${ file ? 'bakery' : 'bakery-parent' }/templates/front-page.html` + ); + expect( raised.includes( 'page 9 ""' ) ).toBe( front === 'page' ); + } + } +); + +it( 'notes no other template', async () => { + const site = createSite(); + const index = path.join( site.root, 'wp-content/themes/bakery/templates/index.html' ); + mkdirSync( path.dirname( index ), { recursive: true } ); + writeFileSync( index, NOT_SHOWN ); + expect( await frontPageTemplateNotes( resolveSiteFile( index )! ) ).toEqual( [] ); +} ); diff --git a/apps/cli/ai/tests/open-problems.test.ts b/apps/cli/ai/tests/open-problems.test.ts index 867965ef70..49ef64e7d4 100644 --- a/apps/cli/ai/tests/open-problems.test.ts +++ b/apps/cli/ai/tests/open-problems.test.ts @@ -1,8 +1,5 @@ -import { mkdirSync, writeFileSync } from 'fs'; -import path from 'path'; import { describe, expect, it } from 'vitest'; -import { OpenProblems, unrenderedFrontPage } from 'cli/ai/open-problems'; -import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; +import { OpenProblems } from 'cli/ai/open-problems'; describe( 'OpenProblems', () => { it( 'raises each problem still open once, and the problems of the sites changed', async () => { @@ -26,53 +23,3 @@ describe( 'OpenProblems', () => { expect( await kept.raise() ).toEqual( [ 'page 1 is still invalid' ] ); } ); } ); - -// The front-page template renders instead of the static front page, and shows -// its content only through a post-content block. -describe( 'unrenderedFrontPage', () => { - const SHOWN = ''; - const NOT_SHOWN = '\n

Hi

\n'; - it.each( [ - { name: 'a theme template without post-content', file: NOT_SHOWN, problem: true }, - { name: 'a theme template with post-content', file: SHOWN, problem: false }, - { - name: 'a template including a pattern', - file: '', - problem: false, - }, - { name: 'no front-page template', file: undefined, problem: false }, - { name: 'latest posts on the front', file: NOT_SHOWN, front: 'posts', problem: false }, - { name: 'a saved copy with post-content', file: NOT_SHOWN, saved: SHOWN, problem: false }, - { name: 'a saved copy without post-content', file: SHOWN, saved: NOT_SHOWN, problem: true }, - { name: "the parent theme's template", parentFile: NOT_SHOWN, problem: true }, - ] )( '$name: $problem', async ( { file, parentFile, front = 'page', saved, problem } ) => { - const site = createSite(); - site.setOptions( { show_on_front: front, page_on_front: '9' } ); - site.save( 9, 'page', 'home' ); - for ( const [ theme, content ] of [ - [ 'bakery', file ], - [ 'bakery-parent', parentFile ], - ] ) { - if ( content !== undefined ) { - const template = path.join( site.root, 'wp-content/themes', theme!, 'templates' ); - mkdirSync( template, { recursive: true } ); - writeFileSync( path.join( template, 'front-page.html' ), content ); - } - } - if ( saved ) { - site.save( 20, 'wp_template', 'front-page', saved ); - } - - const result = await unrenderedFrontPage( site.root ); - - expect( result !== undefined ).toBe( problem ); - if ( result ) { - expect( result ).toContain( - saved - ? 'the front-page template saved in the Site Editor (post 20)' - : `wp-content/themes/${ file ? 'bakery' : 'bakery-parent' }/templates/front-page.html` - ); - expect( result ).toContain( 'get_front_page_template' ); - } - } ); -} ); From 949f29052f8e01bc647067d54bc78bd64549cf36 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 09:28:48 -0400 Subject: [PATCH 050/199] Say the problems that continue a turn come from Studio's checks The message is hidden from the user, so the agent took it for one of theirs. --- apps/cli/ai/open-problems.ts | 11 +++++++++++ apps/cli/ai/runtimes/hooks/index.ts | 6 ++++-- .../ai/runtimes/hooks/tests/file-tool-hook.test.ts | 4 +++- apps/cli/ai/runtimes/pi/index.ts | 4 ++-- apps/cli/ai/tests/open-problems.test.ts | 8 +++++++- apps/cli/ai/tests/pi-runtime.test.ts | 3 ++- 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/cli/ai/open-problems.ts b/apps/cli/ai/open-problems.ts index ddd396ac76..b68337669d 100644 --- a/apps/cli/ai/open-problems.ts +++ b/apps/cli/ai/open-problems.ts @@ -64,6 +64,17 @@ export class OpenProblems { } } +/** + * The message that continues a turn with open problems, from Studio rather + * than the user, who does not see it. + */ +export function describeOpenProblems( problems: string[] ): string { + return [ + "Studio's checks of the sites after your turn, not a message from the user:", + ...problems.map( ( problem ) => `- ${ problem }` ), + ].join( '\n' ); +} + const bySession = new WeakMap< object, OpenProblems >(); /** The open problems of an agent session, kept across its turns. */ diff --git a/apps/cli/ai/runtimes/hooks/index.ts b/apps/cli/ai/runtimes/hooks/index.ts index 123d9b15e3..70287981cc 100644 --- a/apps/cli/ai/runtimes/hooks/index.ts +++ b/apps/cli/ai/runtimes/hooks/index.ts @@ -20,7 +20,7 @@ import { readFile, writeFile } from 'fs/promises'; import os from 'os'; import path from 'path'; import { BlockValidationGate } from 'cli/ai/block-validation-gate'; -import { OpenProblems, type OpenProblemsState } from 'cli/ai/open-problems'; +import { describeOpenProblems, OpenProblems, type OpenProblemsState } from 'cli/ai/open-problems'; import { PostContentCheck, type PostSnapshot } from 'cli/ai/post-content-check'; import { createSiteNotes } from 'cli/ai/site-notes'; @@ -100,7 +100,9 @@ export async function runFileToolHook( const problems = await withSession( input.session_id, ( { open } ) => open.raise() ).catch( () => [] ); - return problems.length ? { decision: 'block', reason: problems.join( '\n' ) } : undefined; + return problems.length + ? { decision: 'block', reason: describeOpenProblems( problems ) } + : undefined; } const files = getWrittenFiles( input ); if ( input.hook_event_name === 'PreToolUse' ) { diff --git a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts index 53eac1fb6e..e8a3a8e1d6 100644 --- a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts +++ b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts @@ -296,7 +296,9 @@ it( 'reports the block validation of post content a shell call changed', async ( // Stopping continues the turn with the problem still open, once. expect( await hook( 'Stop' ) ).toEqual( { decision: 'block', - reason: expect.stringContaining( '1 core/html block(s) must become editable blocks' ), + reason: expect.stringMatching( + /^Studio's checks of the sites after your turn[^]*1 core\/html block\(s\) must become editable blocks/ + ), } ); expect( await hook( 'Stop' ) ).toBeUndefined(); } ); diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 20b3bbed86..4a2d4f5ee0 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -45,7 +45,7 @@ import { getBlockValidationGate, type BlockValidationGate } from 'cli/ai/block-v import { withDesignSystemPreview } from 'cli/ai/chat-artifacts'; import { isImageGenerationAvailable } from 'cli/ai/image-generation'; import { emitEvent } from 'cli/ai/json-events'; -import { getOpenProblems } from 'cli/ai/open-problems'; +import { describeOpenProblems, getOpenProblems } from 'cli/ai/open-problems'; import { PostContentCheck } from 'cli/ai/post-content-check'; import { readActiveSiteFacts } from 'cli/ai/site-facts'; import { createSiteNotes, type SiteNotes } from 'cli/ai/site-notes'; @@ -324,7 +324,7 @@ async function runAgentSessionTurn( await getPendingWork( config.session ).settle(); const problems = await openProblems.raise(); return problems.length - ? { customType: OPEN_PROBLEMS_TYPE, content: problems.join( '\n' ) } + ? { customType: OPEN_PROBLEMS_TYPE, content: describeOpenProblems( problems ) } : undefined; }; diff --git a/apps/cli/ai/tests/open-problems.test.ts b/apps/cli/ai/tests/open-problems.test.ts index 49ef64e7d4..50d9ab2da8 100644 --- a/apps/cli/ai/tests/open-problems.test.ts +++ b/apps/cli/ai/tests/open-problems.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { OpenProblems } from 'cli/ai/open-problems'; +import { describeOpenProblems, OpenProblems } from 'cli/ai/open-problems'; describe( 'OpenProblems', () => { it( 'raises each problem still open once, and the problems of the sites changed', async () => { @@ -22,4 +22,10 @@ describe( 'OpenProblems', () => { const kept = new OpenProblems( open.toJSON(), [] ); expect( await kept.raise() ).toEqual( [ 'page 1 is still invalid' ] ); } ); + + it( 'tells the agent the problems come from Studio, not the user', () => { + expect( describeOpenProblems( [ 'a', 'b' ] ) ).toBe( + "Studio's checks of the sites after your turn, not a message from the user:\n- a\n- b" + ); + } ); } ); diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index ed9d4b972b..78dfc0791b 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -25,6 +25,7 @@ const mocks = vi.hoisted( () => ( { vi.mock( 'cli/ai/open-problems', () => ( { getOpenProblems: () => ( { raise: async () => mocks.openProblems.shift() ?? [] } ), + describeOpenProblems: ( problems: string[] ) => `checks: ${ problems.join( ' | ' ) }`, } ) ); // Model-swap test uses a synthetic id outside `AI_MODELS`; route unknowns to @@ -722,7 +723,7 @@ describe( 'pi runtime', () => { { message: { customType: 'studio.open_problems', - content: 'page 9: 2 invalid blocks\nfront page not shown', + content: 'checks: page 9: 2 invalid blocks | front page not shown', display: false, }, options: { triggerTurn: true }, From eae359819a4bc3c03db87fce92ac9030e2c5af5b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 10:04:57 -0400 Subject: [PATCH 051/199] Fold the annotate, imagery and build-site skills into their tools, and state the site's address A skill teaches a workflow; these taught one tool each or routed to other skills. open_annotation_browser and generate_images are always available, and present_design_options comes with visual-design, beside pick_design. pick_design's next steps carry the images the design needs. /annotate asks for the tool. The active site's facts, and the first tool result for a site, state the address Studio serves it at, which it defines as WP_HOME. --- .../ai/runtimes/mcp/tests/served-tool.test.ts | 4 ++- apps/cli/ai/site-facts.ts | 9 ++++++ apps/cli/ai/skills/visual-design/SKILL.md | 4 +-- apps/cli/ai/tests/site-facts.test.ts | 28 ++++++++++++++++++- apps/cli/ai/tools/generate-images.ts | 1 - apps/cli/ai/tools/pick-design.ts | 4 +-- packages/common/ai/slash-commands.ts | 9 +++++- .../common/ai/tests/slash-commands.test.ts | 3 ++ scripts/eval/promptfoo.config.yaml | 16 +++++------ 9 files changed, 62 insertions(+), 16 deletions(-) diff --git a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts index 689d2c8af1..48e32e1a76 100644 --- a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts +++ b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts @@ -41,7 +41,9 @@ it( 'adds the declared settings of the core blocks a result shows, from the site expect( await notes( { nameOrPath: 'My Site' } ) ).toEqual( [ `Note: core/navigation settings, declared in ${ blockJson .split( path.sep ) - .join( '/' ) }: submenuVisibility ("hover"|"click").`, + .join( + '/' + ) }: submenuVisibility ("hover"|"click").\nNote: Address: http://localhost:8881 (home_url()).`, ] ); // Once per MCP session, as once per Studio Code turn. expect( await notes( { nameOrPath: 'My Site' } ) ).toEqual( [] ); diff --git a/apps/cli/ai/site-facts.ts b/apps/cli/ai/site-facts.ts index 5446100550..c098e2a01d 100644 --- a/apps/cli/ai/site-facts.ts +++ b/apps/cli/ai/site-facts.ts @@ -2,6 +2,7 @@ import { activePluginsFact, comingSoonFact } from 'cli/ai/plugins'; import { savedCopiesFact } from 'cli/ai/saved-copies'; import { readWordPressSite, type WordPressSite } from 'cli/ai/site-database'; import { activeThemeFact } from 'cli/ai/themes'; +import { findSiteByFolder, getSiteUrl } from 'cli/lib/cli-config/sites'; import { getWordPressVersionFromInstallation } from 'cli/lib/dependency-management/wordpress'; import type { SiteInfo } from 'cli/ai/types'; @@ -22,7 +23,15 @@ async function wordPressVersionFact( site: WordPressSite ): Promise< string | un return version ? `WordPress ${ version }.` : undefined; } +// Studio serves the site at this address and defines it as WP_HOME before +// WordPress boots (lib/native-php/site-setup.ts), so home_url() returns it. +async function siteAddressFact( site: WordPressSite ): Promise< string | undefined > { + const studioSite = await findSiteByFolder( site.root ); + return studioSite ? `Address: ${ getSiteUrl( studioSite ) } (home_url()).` : undefined; +} + const SITE_FACTS: SiteFact[] = [ + siteAddressFact, wordPressVersionFact, activeThemeFact, activePluginsFact, diff --git a/apps/cli/ai/skills/visual-design/SKILL.md b/apps/cli/ai/skills/visual-design/SKILL.md index db3be149e0..0e36e32d0e 100644 --- a/apps/cli/ai/skills/visual-design/SKILL.md +++ b/apps/cli/ai/skills/visual-design/SKILL.md @@ -1,7 +1,7 @@ --- name: visual-design description: Plan and execute high-quality visual direction for site creation, redesign, layout, typography, color, motion, and visual polish. -tools: pick_design +tools: pick_design, present_design_options --- # Visual Design @@ -38,7 +38,7 @@ A sneak peek is a standalone HTML page that lets a layout concept be judged in t - **A full frame**: the header or masthead and the hero as the concept shapes them, then the next section. The frame is 1200×900 and the page must reach its bottom edge, so make `body` a `min-height: 100vh` flex column and give the last section `flex: 1` and a background — nothing ends inside the frame, whatever the copy length. Grids need enough rows to pass 900px: four square columns of 300px need three rows. - **The look, exactly**: the picked theme.json's colors and fonts drive every color and font, and the direction's surfaces and shapes show in the hero, so only the layout differs between options. -- **Real content**: the site's real name and plausible copy, never lorem ipsum. Image slots take the site's image set (see the `imagery` skill), a different image per slot with the first-screen scene in the dominant one, never the same image twice in a frame, each under the direction's Imagery treatment; a slot the set cannot fill takes a solid color shape in a palette color. +- **Real content**: the site's real name and plausible copy, never lorem ipsum. Image slots take the site's image set (the set pick_design's result asks for), a different image per slot with the first-screen scene in the dominant one, never the same image twice in a frame, each under the direction's Imagery treatment; a slot the set cannot fill takes a solid color shape in a palette color. - **Small and self-contained**: inline CSS, under ~120 lines, no scripts; a Google Fonts `` with a fallback stack is fine. ## Craft diff --git a/apps/cli/ai/tests/site-facts.test.ts b/apps/cli/ai/tests/site-facts.test.ts index a083a435ee..856aaa7a75 100644 --- a/apps/cli/ai/tests/site-facts.test.ts +++ b/apps/cli/ai/tests/site-facts.test.ts @@ -1,7 +1,14 @@ -import { expect, it } from 'vitest'; +import { expect, it, vi } from 'vitest'; +import { findSiteByFolder } from 'cli/lib/cli-config/sites'; import { readActiveSiteFacts } from '../site-facts'; import { createSiteNotes } from '../site-notes'; import { createSite as createWordPressSite } from './fixtures/wordpress-site'; +import type { SiteData } from 'cli/lib/cli-config/core'; + +vi.mock( 'cli/lib/cli-config/sites', async ( importOriginal ) => ( { + ...( await importOriginal< typeof import('cli/lib/cli-config/sites') >() ), + findSiteByFolder: vi.fn(), +} ) ); function createSite( prefix: string ) { const site = createWordPressSite( prefix ); @@ -117,3 +124,22 @@ it( 'adds to a tool result the facts the agent does not have yet and the ones no said( ...BASE.slice( 0, 2 ), 'Active plugins: woocommerce/woocommerce.php, hello.php.' ) ); } ); + +// Studio defines the address as WP_HOME, so it is what home_url() returns. +it.each( [ + { studioSite: { port: 9098 }, address: 'http://localhost:9098' }, + { + studioSite: { port: 9098, customDomain: 'bakery.local', enableHttps: true }, + address: 'https://bakery.local', + }, +] )( + 'states the address Studio serves the site at: $address', + async ( { studioSite, address } ) => { + const site = createSite( 'wp_' ); + vi.mocked( findSiteByFolder ).mockImplementation( async ( folder ) => + folder === site.root ? ( studioSite as SiteData ) : undefined + ); + expect( await site.facts() ).toEqual( [ `Address: ${ address } (home_url()).`, ...BASE ] ); + vi.mocked( findSiteByFolder ).mockReset(); + } +); diff --git a/apps/cli/ai/tools/generate-images.ts b/apps/cli/ai/tools/generate-images.ts index 14abbbe2d6..c8d93917fc 100644 --- a/apps/cli/ai/tools/generate-images.ts +++ b/apps/cli/ai/tools/generate-images.ts @@ -89,7 +89,6 @@ foreach ( $data['failed'] as $id ) { export const generateImagesTool = defineTool( 'generate_images', "Generate AI images (PNG) from text specs and write them to files inside a site. An image written under the site's wp-content/uploads/ is added to its media library; any other image, such as theme imagery in a theme's assets/images, stays where it is written. " + - 'The `imagery` skill says which images the design steps need and where they go. ' + 'Batch every image a page or site needs into as few calls as possible; each call accepts up to ' + `${ MAX_IMAGES_PER_CALL } images and generates them concurrently. ` + "The call returns at once with each image's path, and for the media library its attachment ID and URL, while the images are generated in the background: the tools that render the site wait for them, and any that failed is reported with a later tool result. " + diff --git a/apps/cli/ai/tools/pick-design.ts b/apps/cli/ai/tools/pick-design.ts index 516337810e..4e5d2be7bd 100644 --- a/apps/cli/ai/tools/pick-design.ts +++ b/apps/cli/ai/tools/pick-design.ts @@ -18,14 +18,14 @@ import type { AnyStudioAgentTool } from './define-tool'; function nextStep( catalog: 'directions' | 'layouts', toPick: boolean ): string { const after = catalog === 'directions' - ? 'make the site\'s image set in that look if you can make images (the imagery skill), then draw the layout with `catalog: "layouts"`' + ? 'make the site\'s image set in that look if you can make images: 3–4 images in one call, the direction\'s Imagery line as `imageGrade`, the first-screen scene plus distinct supporting subjects, each in the aspect ratio of its likely slot; the sneak peeks and the build use this set first. Then draw the layout with `catalog: "layouts"`' : 'write the theme'; if ( ! toPick ) { return `Next: ${ after.replace( 'that look', 'this look' ) }.`; } const preview = catalog === 'directions' - ? "each option's theme.json as its preview, with the look image if you can make images (the imagery skill)" + ? "each option's theme.json as its preview, with a look image if you can make images: one landscape image of the site's first-screen scene in wp-content/uploads/, with a neutral grade in `pageContext` and no `imageGrade`, passed as each option's `image`, or none for a direction that rejects photography" : 'a sneak peek of each option in the picked look as its preview'; return `Next: ask the user once, one option per entry in this order, labelled with its name, without describing them in prose first: with present_design_options (${ preview }), or else AskUserQuestion with a one-line description each. A typed answer such as "2 but darker" is a preference to apply to the closest option; asking for other options means drawing again. Once picked, ${ after }.`; } diff --git a/packages/common/ai/slash-commands.ts b/packages/common/ai/slash-commands.ts index 5d320aeaa8..f3c7461397 100644 --- a/packages/common/ai/slash-commands.ts +++ b/packages/common/ai/slash-commands.ts @@ -4,6 +4,8 @@ export interface SkillSlashCommand { name: string; displayName?: string; description: string; + // What the command asks the agent, when it is not a skill: `/annotate` is a tool. + prompt?: string; } export const getAiSkillCommands = (): SkillSlashCommand[] => [ @@ -11,6 +13,8 @@ export const getAiSkillCommands = (): SkillSlashCommand[] => [ name: 'annotate', displayName: __( 'Visual Annotations' ), description: __( 'Annotate site elements visually in a browser.' ), + prompt: + 'Open the annotation browser on the active site with open_annotation_browser, then make the changes my annotations ask for.', }, { name: 'taxonomist', @@ -70,7 +74,10 @@ export function getSlashCommandMatches( } export function buildSkillInvocationPrompt( name: string ): string { - return `Run the /${ name } skill using the Skill tool.`; + return ( + getAiSkillCommands().find( ( cmd ) => cmd.name === name )?.prompt ?? + `Run the /${ name } skill using the Skill tool.` + ); } // Which predefined skill a prompt invokes, or `undefined`. Handles both shapes that reach the agent: diff --git a/packages/common/ai/tests/slash-commands.test.ts b/packages/common/ai/tests/slash-commands.test.ts index a9e37fa8aa..6f26c6b778 100644 --- a/packages/common/ai/tests/slash-commands.test.ts +++ b/packages/common/ai/tests/slash-commands.test.ts @@ -19,6 +19,9 @@ describe( 'resolveSkillFromPrompt', () => { it( 'ignores surrounding whitespace', () => { expect( resolveSkillFromPrompt( ' /annotate ' ) ).toBe( 'annotate' ); + // A command that is a tool asks for the tool, and still resolves. + expect( buildSkillInvocationPrompt( 'annotate' ) ).toContain( 'open_annotation_browser' ); + expect( resolveSkillFromPrompt( buildSkillInvocationPrompt( 'annotate' ) ) ).toBe( 'annotate' ); } ); it( 'returns undefined for an ordinary prompt', () => { diff --git a/scripts/eval/promptfoo.config.yaml b/scripts/eval/promptfoo.config.yaml index dda2a871ae..63bf541cc6 100644 --- a/scripts/eval/promptfoo.config.yaml +++ b/scripts/eval/promptfoo.config.yaml @@ -651,11 +651,11 @@ tests: # costs only the turns up to that point, and `skillsLoadedFirst` checks the # skills named in `skills` were loaded before it. The new-site cases also stop # at the site's creation. The outcome cases above judge the work itself. - - description: 'routing: a request to make a site loads build-site before creating it' + - description: 'routing: a request to make a site loads visual-design before creating it' vars: caseId: routing-new-site-make timeoutMs: 300000 - skills: [{ name: build-site }] + skills: [{ name: visual-design }] stopAt: - tool: site_create - tool: Write @@ -668,11 +668,11 @@ tests: assert: - type: javascript value: file://outcome.mjs:skillsLoadedFirst - - description: 'routing: a new site with a form loads build-site and plugin-recommendations before the work' + - description: 'routing: a new site with a form loads plugin-recommendations before the work' vars: caseId: routing-new-site-contact-form timeoutMs: 300000 - skills: [{ name: build-site }, { name: plugin-recommendations }] + skills: [{ name: plugin-recommendations }] stopAt: &stopAtWork - tool: Write - tool: Edit @@ -684,11 +684,11 @@ tests: assert: - type: javascript value: file://outcome.mjs:skillsLoadedFirst - - description: 'routing: a new site with a slideshow loads build-site and plugin-recommendations before the work' + - description: 'routing: a new site with a slideshow loads plugin-recommendations before the work' vars: caseId: routing-new-site-slideshow timeoutMs: 300000 - skills: [{ name: build-site }, { name: plugin-recommendations }] + skills: [{ name: plugin-recommendations }] stopAt: *stopAtWork prompt: Create a new site named "Routing Slideshow Site" and add a slideshow of three placeholder images to its homepage. assert: @@ -811,11 +811,11 @@ tests: assert: - type: javascript value: file://outcome.mjs:skillsLoadedFirst - - description: 'routing: a section with a photo loads block-content and imagery before the work' + - description: 'routing: a section with a photo loads block-content before the work' vars: caseId: routing-hero-image timeoutMs: 300000 - skills: [{ name: block-content }, { name: imagery }] + skills: [{ name: block-content }] stopAt: *stopAtWork seed: activeSite: From 306974c21bf249018ed0b594c3a57285dccb4824 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 10:05:26 -0400 Subject: [PATCH 052/199] Remove the annotate, imagery and build-site skill files --- apps/cli/ai/skills/annotate/SKILL.md | 14 ------------- apps/cli/ai/skills/build-site/SKILL.md | 14 ------------- apps/cli/ai/skills/imagery/SKILL.md | 22 -------------------- plugins/wp-studio/skills/annotate/SKILL.md | 9 -------- plugins/wp-studio/skills/build-site/SKILL.md | 9 -------- 5 files changed, 68 deletions(-) delete mode 100644 apps/cli/ai/skills/annotate/SKILL.md delete mode 100644 apps/cli/ai/skills/build-site/SKILL.md delete mode 100644 apps/cli/ai/skills/imagery/SKILL.md delete mode 100644 plugins/wp-studio/skills/annotate/SKILL.md delete mode 100644 plugins/wp-studio/skills/build-site/SKILL.md diff --git a/apps/cli/ai/skills/annotate/SKILL.md b/apps/cli/ai/skills/annotate/SKILL.md deleted file mode 100644 index 3cc43f3bc3..0000000000 --- a/apps/cli/ai/skills/annotate/SKILL.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: annotate -description: Open a browser with visual annotation tools. The user clicks elements on their site and leaves feedback — the agent reads annotations and makes changes. Use this when the user wants to point at specific elements to fix, tweak, or redesign. -user-invokable: true -tools: open_annotation_browser ---- - -# Visual Annotations - -`open_annotation_browser` opens the site for the user to click elements and leave notes on them, and returns the notes when the user clicks **Send**. Each note gives the page, the comment, and the element's selector, nearby text, and computed styles. - -Address the notes in the order they were sent. To the user, name each element by what they see — its tag and nearby text — not by its selector. - -An element is rendered by the page's content or by the active theme — a template, part, or pattern, its theme.json, or its style.css — and its `wp-block-*` classes name its block. Make each change where the element is rendered, then tell the user what changed. Another round is another `open_annotation_browser` call. diff --git a/apps/cli/ai/skills/build-site/SKILL.md b/apps/cli/ai/skills/build-site/SKILL.md deleted file mode 100644 index b5290d930b..0000000000 --- a/apps/cli/ai/skills/build-site/SKILL.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: build-site -description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the look and the layout with the user, then build the theme, provision the site, and publish the content. Load it before picking or creating the site. -user-invokable: true -tools: pick_design, present_design_options, generate_images ---- - -# Build a Site - -1. **The site**: the active site, or `site_create` when there is none or the request names another site. -2. **The design**: load the `visual-design` skill, then settle the look and the layout with `pick_design`; each result says what comes next. A site that keeps its own theme, without a request for a new look, skips the look. -3. **The theme**: a block theme in `wp-content/themes/`, or a child theme to restyle an installed theme — style.css, the picked look as theme.json, templates, parts, patterns. -4. **Provisioning**: with `wp`, activate the theme, install and activate the plugins the site's features need (the `plugin-recommendations` skill), and set options. -5. **The content**: each page's content as a scratch file in the site's `tmp/`, then the page created or updated from it. diff --git a/apps/cli/ai/skills/imagery/SKILL.md b/apps/cli/ai/skills/imagery/SKILL.md deleted file mode 100644 index 295a897c55..0000000000 --- a/apps/cli/ai/skills/imagery/SKILL.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: imagery -description: Generate AI images for a site with generate_images, or with an image tool of your own when it is not available — the images the design steps need, where files go, and how to write image specs. -tools: generate_images ---- - -# Site Imagery - -## Images for the design steps - -- **The look image**: one image of the site's first-screen scene, in `landscape` at `/wp-content/uploads/.png`, with a neutral grade in `pageContext` and no `imageGrade`, passed as each look option's `image`. A direction that rejects photography passes none. -- **The site's set**: once the look is picked, 3–4 images in one call, with the direction's Imagery line as `imageGrade`: the first-screen scene plus distinct supporting subjects, each in the aspect ratio of its likely slot. The sneak peeks and the build use this set first. - -## Where images go - -- **Content imagery** (shown by page or post content, heroes included): `/wp-content/uploads/.png`, added to the media library; its URL is the `src` and its attachment ID goes in the block attributes. Give it real alt text. -- **Theme imagery** (shown by a template, part, or `style.css`): `/wp-content/themes//assets/images/.png`, referenced by that path. -- Content imagery only — icons, ornaments, and logos are not generated. - -## Without generate_images - -With an image tool of your own, make the same images with the same intent, then `wp media import` content images from the site's `tmp/` (it prints attachment IDs) and copy theme images into the theme. diff --git a/plugins/wp-studio/skills/annotate/SKILL.md b/plugins/wp-studio/skills/annotate/SKILL.md deleted file mode 100644 index e56212c328..0000000000 --- a/plugins/wp-studio/skills/annotate/SKILL.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: annotate -description: Open a browser with visual annotation tools. The user clicks elements on their site and leaves feedback — the agent reads annotations and makes changes. Use this when the user wants to point at specific elements to fix, tweak, or redesign. ---- - -1. If you have not called the `studio_instructions` tool in this conversation yet, call it first and follow what it returns for the rest of the session. -2. Then call `studio_instructions` with `skill: "annotate"` and follow the runbook it returns. - -If the `wordpress-studio` tools are missing, WordPress Studio did not start: ask the user to restart the app, and to reinstall the plugin if the tools are still missing. diff --git a/plugins/wp-studio/skills/build-site/SKILL.md b/plugins/wp-studio/skills/build-site/SKILL.md deleted file mode 100644 index 61ccfb00e8..0000000000 --- a/plugins/wp-studio/skills/build-site/SKILL.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: build-site -description: Create, build, design, redesign, or rebuild a whole website as a WordPress site with WordPress Studio, on the user's computer — pick or create the site, settle the look and the layout with the user, then build the theme, provision the site, and publish the content. Load it before picking or creating the site. ---- - -1. If you have not called the `studio_instructions` tool in this conversation yet, call it first and follow what it returns for the rest of the session. -2. Then call `studio_instructions` with `skill: "build-site"` and follow the runbook it returns. - -If the `wordpress-studio` tools are missing, WordPress Studio did not start: ask the user to restart the app, and to reinstall the plugin if the tools are still missing. From 90f3709c9f0b740bb70b341c6369a8e99f7669b2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 10:20:34 -0400 Subject: [PATCH 053/199] Name the post content fix by its absolute path The agent's shell may be in another folder; with tmp/page-7.html it searched the disk for the file. --- apps/cli/ai/post-content-check.ts | 13 +++++++------ apps/cli/ai/tests/post-content-check.test.ts | 7 ++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/cli/ai/post-content-check.ts b/apps/cli/ai/post-content-check.ts index 51b04ab120..fe0b18b24c 100644 --- a/apps/cli/ai/post-content-check.ts +++ b/apps/cli/ai/post-content-check.ts @@ -66,20 +66,21 @@ async function localSites(): Promise< SiteData[] > { return ( await readCliConfig() ).sites; } -// A lossless fix is saved next to the site for the agent to apply, since the -// row belongs to the running site. +// A lossless fix is saved in the site's tmp/ for the agent to apply, since the +// row belongs to the running site; named by its absolute path, which holds +// whatever folder the agent's shell is in. async function validatePost( site: SiteData, post: Row ) { const label = `${ site.name } ${ post.type } ${ post.id } "${ post.title }"`; const result = await validateBlockMarkup( post.content, label, getSiteUrl( site ) ); if ( result.fixedContent === undefined ) { return result; } - const fixFile = path.join( 'tmp', `${ post.type }-${ post.id }.html` ); - await mkdir( path.join( site.path, 'tmp' ), { recursive: true } ); - await writeFile( path.join( site.path, fixFile ), result.fixedContent, 'utf8' ); + const fixFile = path.join( site.path, 'tmp', `${ post.type }-${ post.id }.html` ); + await mkdir( path.dirname( fixFile ), { recursive: true } ); + await writeFile( fixFile, result.fixedContent, 'utf8' ); return { summary: `${ label }: ${ result.invalidBlocks } invalid block(s)`, - problem: `${ label }: ${ result.invalidBlocks } block(s) do not match what the block saves; the block editor's fix loses nothing and is saved in ${ fixFile }. Apply it with \`wp post update ${ post.id } ${ fixFile }\`.`, + problem: `${ label }: ${ result.invalidBlocks } block(s) do not match what the block saves; the block editor's fix loses nothing and is saved in ${ fixFile }. Apply it in the site's folder with \`wp post update ${ post.id } ${ fixFile }\`.`, }; } diff --git a/apps/cli/ai/tests/post-content-check.test.ts b/apps/cli/ai/tests/post-content-check.test.ts index 4e6235d761..84fd7b1b96 100644 --- a/apps/cli/ai/tests/post-content-check.test.ts +++ b/apps/cli/ai/tests/post-content-check.test.ts @@ -90,7 +90,12 @@ describe( 'PostContentCheck', () => { fixture.edit( 9, UNWRAPPED_GROUP ); const { summaries, problems } = await check.settle(); expect( summaries ).toEqual( [ 'Bakery page 9 "": 1 invalid block(s)' ] ); - expect( problems[ 0 ] ).toContain( 'Apply it with `wp post update 9 tmp/page-9.html`' ); + expect( problems[ 0 ] ).toContain( + `Apply it in the site's folder with \`wp post update 9 ${ path.join( + fixture.root, + 'tmp/page-9.html' + ) }\`` + ); expect( readFileSync( path.join( fixture.root, 'tmp/page-9.html' ), 'utf8' ) ).toBe( WRAPPED_GROUP ); From db9be4cdedf93bba9bd0aa425775905b663edaab Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 10:23:12 -0400 Subject: [PATCH 054/199] Tag the problems that continue a turn as Studio's checks Pi's custom messages reach the model in the user's role; Pi tags the summaries it adds the same way. --- apps/cli/ai/open-problems.ts | 9 ++++++--- apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts | 2 +- apps/cli/ai/tests/open-problems.test.ts | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/cli/ai/open-problems.ts b/apps/cli/ai/open-problems.ts index b68337669d..9e734f1040 100644 --- a/apps/cli/ai/open-problems.ts +++ b/apps/cli/ai/open-problems.ts @@ -65,13 +65,16 @@ export class OpenProblems { } /** - * The message that continues a turn with open problems, from Studio rather - * than the user, who does not see it. + * The message that continues a turn with open problems. It reaches the model + * in the user's role, so it is tagged as Studio's, the way Pi tags the + * summaries it adds; the user does not see it. */ export function describeOpenProblems( problems: string[] ): string { return [ - "Studio's checks of the sites after your turn, not a message from the user:", + '', + 'Studio checked the sites this turn changed:', ...problems.map( ( problem ) => `- ${ problem }` ), + '', ].join( '\n' ); } diff --git a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts index e8a3a8e1d6..25aa4646e8 100644 --- a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts +++ b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts @@ -297,7 +297,7 @@ it( 'reports the block validation of post content a shell call changed', async ( expect( await hook( 'Stop' ) ).toEqual( { decision: 'block', reason: expect.stringMatching( - /^Studio's checks of the sites after your turn[^]*1 core\/html block\(s\) must become editable blocks/ + /^\nStudio checked the sites this turn changed:[^]*1 core\/html block\(s\) must become editable blocks/ ), } ); expect( await hook( 'Stop' ) ).toBeUndefined(); diff --git a/apps/cli/ai/tests/open-problems.test.ts b/apps/cli/ai/tests/open-problems.test.ts index 50d9ab2da8..358a465cd5 100644 --- a/apps/cli/ai/tests/open-problems.test.ts +++ b/apps/cli/ai/tests/open-problems.test.ts @@ -23,9 +23,9 @@ describe( 'OpenProblems', () => { expect( await kept.raise() ).toEqual( [ 'page 1 is still invalid' ] ); } ); - it( 'tells the agent the problems come from Studio, not the user', () => { + it( "tags the problems as Studio's checks", () => { expect( describeOpenProblems( [ 'a', 'b' ] ) ).toBe( - "Studio's checks of the sites after your turn, not a message from the user:\n- a\n- b" + '\nStudio checked the sites this turn changed:\n- a\n- b\n' ); } ); } ); From 479d46a00353e31426a01f67d725dd0ac0d9bfdb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 10:36:24 -0400 Subject: [PATCH 055/199] Tag what Studio adds to a turn: and Notes on a tool's result and the checks at the end of a turn come from one studioMessage(), the same on Studio Code, MCP and the plugin hooks; the Note: prefix goes. --- apps/cli/ai/open-problems.ts | 13 +++--- apps/cli/ai/runtimes/hooks/index.ts | 19 ++++---- .../hooks/tests/file-tool-hook.test.ts | 9 +++- apps/cli/ai/runtimes/mcp/served-tool.ts | 20 ++++----- .../ai/runtimes/mcp/tests/served-tool.test.ts | 8 ++-- apps/cli/ai/runtimes/pi/index.ts | 43 ++++++++----------- apps/cli/ai/site-notes.ts | 7 +-- apps/cli/ai/studio-message.ts | 24 +++++++++++ apps/cli/ai/tests/pi-runtime.test.ts | 14 +++--- apps/cli/ai/tests/site-facts.test.ts | 2 +- 10 files changed, 88 insertions(+), 71 deletions(-) create mode 100644 apps/cli/ai/studio-message.ts diff --git a/apps/cli/ai/open-problems.ts b/apps/cli/ai/open-problems.ts index 9e734f1040..3a7648eca3 100644 --- a/apps/cli/ai/open-problems.ts +++ b/apps/cli/ai/open-problems.ts @@ -16,6 +16,7 @@ import { } from 'cli/ai/front-page-template'; import { readSavedCopies } from 'cli/ai/saved-copies'; import { readWordPressSite } from 'cli/ai/site-database'; +import { studioMessage } from 'cli/ai/studio-message'; export interface OpenProblemsState { // The sites changed, whose state is checked at the end of the turn. @@ -70,12 +71,12 @@ export class OpenProblems { * summaries it adds; the user does not see it. */ export function describeOpenProblems( problems: string[] ): string { - return [ - '', - 'Studio checked the sites this turn changed:', - ...problems.map( ( problem ) => `- ${ problem }` ), - '', - ].join( '\n' ); + return ( + studioMessage( 'checks', [ + 'Studio checked the sites this turn changed:', + ...problems.map( ( problem ) => `- ${ problem }` ), + ] ) ?? '' + ); } const bySession = new WeakMap< object, OpenProblems >(); diff --git a/apps/cli/ai/runtimes/hooks/index.ts b/apps/cli/ai/runtimes/hooks/index.ts index 70287981cc..4e9e57418a 100644 --- a/apps/cli/ai/runtimes/hooks/index.ts +++ b/apps/cli/ai/runtimes/hooks/index.ts @@ -23,6 +23,7 @@ import { BlockValidationGate } from 'cli/ai/block-validation-gate'; import { describeOpenProblems, OpenProblems, type OpenProblemsState } from 'cli/ai/open-problems'; import { PostContentCheck, type PostSnapshot } from 'cli/ai/post-content-check'; import { createSiteNotes } from 'cli/ai/site-notes'; +import { studioMessage, validationLines } from 'cli/ai/studio-message'; export interface HookInput { hook_event_name?: string; @@ -136,17 +137,13 @@ export async function runFileToolHook( } return { notes, files: await gate.settle(), content: await postContent.settle() }; } ).catch( () => undefined ); - const { summaries, problems } = checked?.files ?? { summaries: [], problems: [] }; - const changedContent = checked?.content ?? { summaries: [], problems: [] }; - const notes = checked?.notes ?? []; - const context = [ - ...[ ...summaries, ...changedContent.summaries ].map( - ( line ) => `Block validation: ${ line }` - ), - ...problems, - ...changedContent.problems, - ...notes, - ].join( '\n' ); + const context = + checked && + studioMessage( 'notes', [ + ...validationLines( checked.files ), + ...validationLines( checked.content ), + ...checked.notes, + ] ); return context ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } } : undefined; diff --git a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts index 25aa4646e8..51ac07295f 100644 --- a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts +++ b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts @@ -90,8 +90,10 @@ it( 'adds to a write the block validation and what WordPress renders instead of hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: [ + '', 'Block validation: parts/header.html: 1/1 blocks valid', - 'Note: WordPress renders the template part header from its copy saved in the Site Editor (post 14), not from this file.', + 'WordPress renders the template part header from its copy saved in the Site Editor (post 14), not from this file.', + '', ].join( '\n' ), }, } ); @@ -175,7 +177,10 @@ it.each( [ ); } expect( output?.hookSpecificOutput ).toEqual( - note && { hookEventName: 'PostToolUse', additionalContext: `Note: ${ note }` } + note && { + hookEventName: 'PostToolUse', + additionalContext: `\n${ note }\n`, + } ); } ); diff --git a/apps/cli/ai/runtimes/mcp/served-tool.ts b/apps/cli/ai/runtimes/mcp/served-tool.ts index a8ca5aee18..95f3838661 100644 --- a/apps/cli/ai/runtimes/mcp/served-tool.ts +++ b/apps/cli/ai/runtimes/mcp/served-tool.ts @@ -1,3 +1,4 @@ +import { studioMessage, validationLines } from 'cli/ai/studio-message'; import { resolveSite } from 'cli/ai/tools/utils'; import type { PostContentCheck } from 'cli/ai/post-content-check'; import type { SiteNotes } from 'cli/ai/site-notes'; @@ -26,11 +27,8 @@ export async function runServedTool( await postContent?.snapshot().catch( () => undefined ); // A call that fails can still have changed content. const changedContent = async () => { - const { summaries, problems } = ( await postContent?.settle().catch( () => undefined ) ) ?? { - summaries: [], - problems: [], - }; - return [ ...summaries.map( ( line ) => `Block validation: ${ line }` ), ...problems ]; + const report = await postContent?.settle().catch( () => undefined ); + return report ? validationLines( report ) : []; }; try { const result = await tool.rawHandler( args as never ); @@ -39,13 +37,11 @@ export async function runServedTool( .map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) .join( '\n' ); const site = await siteNamed( args ); - const notes = [ + const notes = studioMessage( 'notes', [ ...changed, ...( site ? await siteNotes.afterResult( site.path, resultText ) : [] ), await result.pending, - ] - .filter( Boolean ) - .join( '\n' ); + ] ); return { content: notes ? [ ...result.content, { type: 'text' as const, text: notes } ] @@ -56,8 +52,10 @@ export async function runServedTool( } catch ( error ) { const message = [ error instanceof Error ? error.message : String( error ), - ...( await changedContent() ), - ].join( '\n' ); + studioMessage( 'notes', await changedContent() ), + ] + .filter( Boolean ) + .join( '\n' ); return { content: [ { type: 'text' as const, text: message } ], isError: true, diff --git a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts index 48e32e1a76..7dd4204108 100644 --- a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts +++ b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts @@ -39,11 +39,11 @@ it( 'adds the declared settings of the core blocks a result shows, from the site expect( await notes( {} ) ).toEqual( [] ); expect( await notes( { nameOrPath: 'Missing Site' } ) ).toEqual( [] ); expect( await notes( { nameOrPath: 'My Site' } ) ).toEqual( [ - `Note: core/navigation settings, declared in ${ blockJson + `\ncore/navigation settings, declared in ${ blockJson .split( path.sep ) .join( '/' - ) }: submenuVisibility ("hover"|"click").\nNote: Address: http://localhost:8881 (home_url()).`, + ) }: submenuVisibility ("hover"|"click").\nAddress: http://localhost:8881 (home_url()).\n`, ] ); // Once per MCP session, as once per Studio Code turn. expect( await notes( { nameOrPath: 'My Site' } ) ).toEqual( [] ); @@ -73,7 +73,7 @@ it( 'reports the block validation of post content the call changed, on any site' expect( result.content.map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) ).toEqual( [ 'Success: Updated post 9.', expect.stringMatching( - /^Block validation: Bakery page 9 "": core\/html policy failed\nBakery page 9 "": 1 core\/html block\(s\) must become editable blocks/ + /^\nBlock validation: Bakery page 9 "": core\/html policy failed\nBakery page 9 "": 1 core\/html block\(s\) must become editable blocks/ ), ] ); @@ -95,6 +95,6 @@ it( 'reports the block validation of post content the call changed, on any site' const failed = await runServedTool( failing, {}, createSiteNotes(), new PostContentCheck() ); expect( failed.isError ).toBe( true ); expect( JSON.stringify( failed.content ) ).toMatch( - /Command exited with code 1\\nBlock validation: Bakery page 9 \\"\\": core\/html policy failed/ + /Command exited with code 1\\n\\nBlock validation: Bakery page 9 \\"\\": core\/html policy failed/ ); } ); diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 4a2d4f5ee0..a3c3f93b4e 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -49,6 +49,7 @@ import { describeOpenProblems, getOpenProblems } from 'cli/ai/open-problems'; import { PostContentCheck } from 'cli/ai/post-content-check'; import { readActiveSiteFacts } from 'cli/ai/site-facts'; import { createSiteNotes, type SiteNotes } from 'cli/ai/site-notes'; +import { studioMessage, validationLines } from 'cli/ai/studio-message'; import { buildSystemPrompt } from 'cli/ai/system-prompt'; import { resolveStudioToolDefinitions } from 'cli/ai/tools'; import { createAskUserQuestionTool } from 'cli/ai/tools/ask-user-question'; @@ -750,30 +751,25 @@ function toToolDefinition( } // Block markup written since the last check is validated before any // tool puts content on the site or renders it. - let validationReport: string | undefined; - if ( BLOCK_VALIDATION_GATED_TOOLS.has( tool.name ) ) { - const { summaries, problems } = await blockValidationGate.settle(); - validationReport = - [ ...summaries.map( ( line ) => `Block validation: ${ line }` ), ...problems ].join( - '\n' - ) || undefined; - } + const validationReport = BLOCK_VALIDATION_GATED_TOOLS.has( tool.name ) + ? validationLines( await blockValidationGate.settle() ) + : []; await postContent.snapshot().catch( () => undefined ); // A call that fails can still have changed content, as a shell // command does that exits non-zero after saving a page. const changedContent = async () => { - const { summaries, problems } = await postContent - .settle() - .catch( () => ( { summaries: [], problems: [] } ) ); - return [ ...summaries.map( ( line ) => `Block validation: ${ line }` ), ...problems ]; + return validationLines( + await postContent.settle().catch( () => ( { summaries: [], problems: [] } ) ) + ); }; // The validation report was taken from the gate, so it rides on a failure too. const result = await tool .execute( toolCallId, params, signal, onUpdate ) .catch( async ( error: unknown ) => { - const report = [ validationReport, ...( await changedContent() ) ] - .filter( Boolean ) - .join( '\n' ); + const report = studioMessage( 'notes', [ + ...validationReport, + ...( await changedContent() ), + ] ); if ( ! report ) { throw error; } @@ -793,16 +789,13 @@ function toToolDefinition( const resultText = result.content .map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) .join( '\n' ); - const reports = - [ - validationReport, - ...changed, - ...( writtenFile ? await siteNotes.afterWrite( writtenFile ) : [] ), - ...( localSite ? await siteNotes.afterResult( localSite, resultText ) : [] ), - pendingWork.takeReports(), - ] - .filter( Boolean ) - .join( '\n' ) || undefined; + const reports = studioMessage( 'notes', [ + ...validationReport, + ...changed, + ...( writtenFile ? await siteNotes.afterWrite( writtenFile ) : [] ), + ...( localSite ? await siteNotes.afterResult( localSite, resultText ) : [] ), + pendingWork.takeReports(), + ] ); return reports ? { ...result, content: [ ...result.content, { type: 'text', text: reports } ] } : result; diff --git a/apps/cli/ai/site-notes.ts b/apps/cli/ai/site-notes.ts index de3b325047..3e16722860 100644 --- a/apps/cli/ai/site-notes.ts +++ b/apps/cli/ai/site-notes.ts @@ -133,7 +133,6 @@ export interface SiteNotes { /** One agent session's notes: sources that say a thing once keep that here. */ export function createSiteNotes( options: SiteNotesOptions = {} ): SiteNotes { const { beforeWrite, write, result } = sources( options ); - const asNotes = ( lines: string[] ) => lines.map( ( line ) => `Note: ${ line }` ); return { async beforeWrite( filePath ) { // Unlike a note, a refusal that fails stops the write. @@ -145,12 +144,10 @@ export function createSiteNotes( options: SiteNotesOptions = {} ): SiteNotes { }, async afterWrite( filePath ) { const file = resolveSiteFile( filePath ); - return file - ? asNotes( await collect( write.map( ( source ) => () => source( file ) ) ) ) - : []; + return file ? await collect( write.map( ( source ) => () => source( file ) ) ) : []; }, async afterResult( sitePath, text ) { - return asNotes( await collect( result.map( ( source ) => () => source( sitePath, text ) ) ) ); + return collect( result.map( ( source ) => () => source( sitePath, text ) ) ); }, }; } diff --git a/apps/cli/ai/studio-message.ts b/apps/cli/ai/studio-message.ts new file mode 100644 index 0000000000..fbfb4426d4 --- /dev/null +++ b/apps/cli/ai/studio-message.ts @@ -0,0 +1,24 @@ +/** + * What Studio adds to the conversation: its notes on a tool's result, and its + * checks of the sites at the end of a turn. They reach the model in the + * tool's or the user's role, so they are tagged as Studio's, the way Pi tags + * the summaries it adds. The same text reaches every surface. + */ +export function studioMessage( + kind: 'notes' | 'checks', + lines: Array< string | undefined > +): string | undefined { + const body = lines.filter( Boolean ).join( '\n' ); + return body ? `\n${ body }\n` : undefined; +} + +/** A block validation report: a summary line per checked file or post, then its problems. */ +export function validationLines( { + summaries, + problems, +}: { + summaries: string[]; + problems: string[]; +} ): string[] { + return [ ...summaries.map( ( line ) => `Block validation: ${ line }` ), ...problems ]; +} diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index 78dfc0791b..7305ead731 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -75,8 +75,8 @@ vi.mock( 'cli/ai/site-notes', () => ( { createSiteNotes: () => ( { beforeWrite: async ( file: string ) => file.endsWith( 'refused.php' ) ? `Not run: ${ file }` : undefined, - afterWrite: async ( file: string ) => [ `Note: wrote ${ file }` ], - afterResult: async ( site: string ) => [ `Note: result on ${ site }` ], + afterWrite: async ( file: string ) => [ `wrote ${ file }` ], + afterResult: async ( site: string ) => [ `result on ${ site }` ], } ), } ) ); @@ -534,7 +534,7 @@ describe( 'pi runtime', () => { ); expect( await text( { path: 'a/style.css', content: '' } ) ).toEqual( [ '', - `Note: wrote ${ file }\nNote: result on /sites/a`, + `\nwrote ${ file }\nresult on /sites/a\n`, ] ); const remote = await run( { ...local!, @@ -544,7 +544,7 @@ describe( 'pi runtime', () => { ( await remote.execute( 'w', { path: 'a/style.css', content: '' } ) ).content.map( ( part ) => part.text ) - ).toEqual( [ '', `Note: wrote ${ file }` ] ); + ).toEqual( [ '', `\nwrote ${ file }\n` ] ); } ); it( 'reports the post content a call changed, from a snapshot taken before it', async () => { @@ -556,13 +556,15 @@ describe( 'pi runtime', () => { expect( mocks.steps ).toEqual( [ 'snapshot', 'Read', 'settle' ] ); expect( result.content.map( ( part ) => part.text ) ).toEqual( [ '', - 'Block validation: page 9 changed\npage 9 problem', + '\nBlock validation: page 9 changed\npage 9 problem\n', ] ); // A failed call reports what it changed too. mocks.failNext = true; await expect( getCreatedTool( 'Read' ).execute( 'r', { path: 'a/style.css' } ) - ).rejects.toThrow( 'exit 1\nBlock validation: page 9 changed\npage 9 problem' ); + ).rejects.toThrow( + 'exit 1\n\nBlock validation: page 9 changed\npage 9 problem\n' + ); mocks.postChanges = { summaries: [], problems: [] }; } ); diff --git a/apps/cli/ai/tests/site-facts.test.ts b/apps/cli/ai/tests/site-facts.test.ts index 856aaa7a75..7649a6203e 100644 --- a/apps/cli/ai/tests/site-facts.test.ts +++ b/apps/cli/ai/tests/site-facts.test.ts @@ -101,7 +101,7 @@ it( 'adds to a tool result the facts the agent does not have yet and the ones no const site = createSite( 'wp_' ); const siteNotes = createSiteNotes( { stated: new Map( [ [ site.root, await site.facts() ] ] ) } ); const notes = ( session = siteNotes ) => session.afterResult( site.root, '' ); - const said = ( ...lines: string[] ) => lines.map( ( line ) => `Note: ${ line }` ); + const said = ( ...lines: string[] ) => lines; // The facts the Active site section stated. expect( await notes() ).toEqual( [] ); From c1296afe30900a79dc214727f6d1a03ab9879a3b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 10:48:23 -0400 Subject: [PATCH 056/199] Check what a call changes in an updated theme's files, by any route SiteChanges takes the snapshot/settle pattern from PostContentCheck and runs a list of sources: post content, and now the files of themes WordPress updates. An update replaces the theme's folder (Theme_Upgrader), so a change made by Bash, a script or a file tool is reported with the next step: move it to a child theme and restore the released files. Installs and updates by WordPress are not reported. A change stays open until the released file is back. isUpdatableTheme asks WordPress.org's update check, as wp_update_themes() does, for a theme the site's last check has not seen yet. --- apps/cli/ai/post-content-check.ts | 81 ++++-------- apps/cli/ai/runtimes/hooks/index.ts | 16 +-- apps/cli/ai/runtimes/mcp/index.ts | 6 +- apps/cli/ai/runtimes/mcp/served-tool.ts | 8 +- .../ai/runtimes/mcp/tests/served-tool.test.ts | 6 +- apps/cli/ai/runtimes/pi/index.ts | 14 +-- apps/cli/ai/site-changes.ts | 87 +++++++++++++ apps/cli/ai/tests/file-write-refusal.test.ts | 25 +++- apps/cli/ai/tests/fixtures/wordpress-site.ts | 2 +- apps/cli/ai/tests/pi-runtime.test.ts | 4 +- apps/cli/ai/tests/post-content-check.test.ts | 18 +-- apps/cli/ai/tests/theme-file-changes.test.ts | 84 +++++++++++++ apps/cli/ai/theme-file-changes.ts | 118 ++++++++++++++++++ apps/cli/ai/themes.ts | 52 ++++++-- 14 files changed, 415 insertions(+), 106 deletions(-) create mode 100644 apps/cli/ai/site-changes.ts create mode 100644 apps/cli/ai/tests/theme-file-changes.test.ts create mode 100644 apps/cli/ai/theme-file-changes.ts diff --git a/apps/cli/ai/post-content-check.ts b/apps/cli/ai/post-content-check.ts index fe0b18b24c..f064f244ee 100644 --- a/apps/cli/ai/post-content-check.ts +++ b/apps/cli/ai/post-content-check.ts @@ -2,18 +2,15 @@ * Block validation of the content WordPress renders from the database: posts, * pages, templates, template parts, patterns and navigation menus. Content * reaches those rows by any route (WP-CLI with inline or file content, `wp - * eval`, REST, an import), so it is checked where it lands, not on the way. - * - * Surfaces take a snapshot before a call and check after it, so what a call - * changed is reported on that call's result. + * eval`, REST, an import), so it is checked where it lands (site-changes.ts). */ import { mkdir, writeFile } from 'fs/promises'; import path from 'path'; import { validateBlockMarkup } from 'cli/ai/block-validation-gate'; import { readWordPressSite } from 'cli/ai/site-database'; -import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; import { getSiteUrl } from 'cli/lib/cli-config/sites'; -import type { OpenProblems } from 'cli/ai/open-problems'; +import type { ChangeReport, SiteChangeSource, Versions } from 'cli/ai/site-changes'; +import type { SiteData } from 'cli/lib/cli-config/core'; const RENDERED_TYPES = [ 'post', @@ -31,17 +28,13 @@ interface Row { content: string; } -/** - * Each rendered post's version, by post ID: its modified time and content - * length, so a check reads the content of changed posts only. - */ -export type PostSnapshot = Record< string, string >; - const RENDERED = `post_type IN (${ RENDERED_TYPES.map( () => '?' ).join( ', ' ) }) AND post_status NOT IN ('auto-draft', 'inherit', 'trash')`; -async function readVersions( sitePath: string ): Promise< PostSnapshot > { +// Each rendered post's version, by post ID: its modified time and content +// length, so a check reads the content of changed posts only. +async function readVersions( sitePath: string ): Promise< Versions > { const rows = await readWordPressSite( sitePath, ( site ) => site.query< { id: number; version: string } >( `SELECT ID AS id, post_modified_gmt || ' ' || length(post_content) AS version @@ -62,10 +55,6 @@ async function readPosts( sitePath: string, ids: string[] ): Promise< Row[] > { ); } -async function localSites(): Promise< SiteData[] > { - return ( await readCliConfig() ).sites; -} - // A lossless fix is saved in the site's tmp/ for the agent to apply, since the // row belongs to the running site; named by its absolute path, which holds // whatever folder the agent's shell is in. @@ -84,45 +73,21 @@ async function validatePost( site: SiteData, post: Row ) { }; } -export class PostContentCheck { - constructor( - private readonly snapshots = new Map< string, PostSnapshot >(), - private readonly open?: OpenProblems, - private readonly sites: () => Promise< SiteData[] > = localSites, - private readonly validate: typeof validatePost = validatePost - ) {} - - /** Records every local site's rendered content as it is now. */ - async snapshot(): Promise< void > { - for ( const site of await this.sites() ) { - this.snapshots.set( site.path, await readVersions( site.path ) ); +export const postContentChanges: SiteChangeSource = { + name: 'posts', + read: readVersions, + async check( site, before, after ) { + const changed = Object.keys( after ).filter( ( id ) => before[ id ] !== after[ id ] ); + const reports: ChangeReport[] = []; + for ( const post of changed.length ? await readPosts( site.path, changed ) : [] ) { + const result = await validatePost( site, post ).catch( ( error: unknown ) => ( { + summary: `${ site.name } ${ post.type } ${ post.id }: not validated (${ + error instanceof Error ? error.message : String( error ) + })`, + problem: undefined, + } ) ); + reports.push( { key: String( post.id ), ...result } ); } - } - - /** Validates the content changed since the snapshot, and snapshots it. */ - async settle(): Promise< { summaries: string[]; problems: string[] } > { - const summaries: string[] = []; - const problems: string[] = []; - for ( const site of await this.sites() ) { - const before = this.snapshots.get( site.path ); - const after = await readVersions( site.path ); - this.snapshots.set( site.path, after ); - // A site first seen now has no changes yet. - const changed = before - ? Object.keys( after ).filter( ( id ) => before[ id ] !== after[ id ] ) - : []; - for ( const post of changed.length ? await readPosts( site.path, changed ) : [] ) { - const result = await this.validate( site, post ).catch( ( error: unknown ) => ( { - summary: `${ site.name } ${ post.type } ${ post.id }: not validated (${ - error instanceof Error ? error.message : String( error ) - })`, - problem: undefined, - } ) ); - this.open?.record( site.path, `${ site.path }#${ post.id }`, result.problem ); - summaries.push( result.summary ); - problems.push( ...( result.problem ? [ result.problem ] : [] ) ); - } - } - return { summaries, problems }; - } -} + return { reports }; + }, +}; diff --git a/apps/cli/ai/runtimes/hooks/index.ts b/apps/cli/ai/runtimes/hooks/index.ts index 4e9e57418a..b645625ac5 100644 --- a/apps/cli/ai/runtimes/hooks/index.ts +++ b/apps/cli/ai/runtimes/hooks/index.ts @@ -10,7 +10,7 @@ * hookSpecificOutput denies the call or adds context to its result. * * On a shell call they also report the content the call changed in the sites' - * databases (post-content-check.ts). When the agent stops, the Stop hook + * databases and the files of themes WordPress updates (site-changes.ts). When the agent stops, the Stop hook * continues its turn with the problems still open (open-problems.ts). Each * hook is its own process, so the session's snapshots and open problems are * kept in a file between hooks. @@ -21,7 +21,7 @@ import os from 'os'; import path from 'path'; import { BlockValidationGate } from 'cli/ai/block-validation-gate'; import { describeOpenProblems, OpenProblems, type OpenProblemsState } from 'cli/ai/open-problems'; -import { PostContentCheck, type PostSnapshot } from 'cli/ai/post-content-check'; +import { SiteChanges, type Versions } from 'cli/ai/site-changes'; import { createSiteNotes } from 'cli/ai/site-notes'; import { studioMessage, validationLines } from 'cli/ai/studio-message'; @@ -62,7 +62,7 @@ export function getWrittenFiles( { tool_input: toolInput = {}, cwd = '' }: HookI } interface SessionState { - snapshots: Record< string, PostSnapshot >; + snapshots: Record< string, Versions >; open?: OpenProblemsState; } @@ -75,7 +75,7 @@ function stateFile( sessionId: string ): string { // a call's own. async function withSession< T >( sessionId: string | undefined, - run: ( checks: { postContent: PostContentCheck; open: OpenProblems } ) => Promise< T > + run: ( checks: { siteChanges: SiteChanges; open: OpenProblems } ) => Promise< T > ): Promise< T > { const file = sessionId ? stateFile( sessionId ) : undefined; const kept: Partial< SessionState > = file @@ -85,7 +85,7 @@ async function withSession< T >( : {}; const snapshots = new Map( Object.entries( kept.snapshots ?? {} ) ); const open = new OpenProblems( kept.open ); - const result = await run( { postContent: new PostContentCheck( snapshots, open ), open } ); + const result = await run( { siteChanges: new SiteChanges( snapshots, open ), open } ); if ( file ) { const state: SessionState = { snapshots: Object.fromEntries( snapshots ), open: open.toJSON() }; await writeFile( file, JSON.stringify( state ), 'utf8' ); @@ -108,7 +108,7 @@ export async function runFileToolHook( const files = getWrittenFiles( input ); if ( input.hook_event_name === 'PreToolUse' ) { const siteNotes = createSiteNotes( { gate: makeGate() } ); - await withSession( input.session_id, ( { postContent } ) => postContent.snapshot() ).catch( + await withSession( input.session_id, ( { siteChanges } ) => siteChanges.snapshot() ).catch( () => undefined ); for ( const file of files ) { @@ -128,14 +128,14 @@ export async function runFileToolHook( if ( input.hook_event_name !== 'PostToolUse' ) { return undefined; } - const checked = await withSession( input.session_id, async ( { postContent, open } ) => { + const checked = await withSession( input.session_id, async ( { siteChanges, open } ) => { const gate = makeGate( open ); const siteNotes = createSiteNotes( { gate } ); const notes: string[] = []; for ( const file of files ) { notes.push( ...( await siteNotes.afterWrite( file ) ) ); } - return { notes, files: await gate.settle(), content: await postContent.settle() }; + return { notes, files: await gate.settle(), content: await siteChanges.settle() }; } ).catch( () => undefined ); const context = checked && diff --git a/apps/cli/ai/runtimes/mcp/index.ts b/apps/cli/ai/runtimes/mcp/index.ts index 571b53e6ee..349e855de5 100644 --- a/apps/cli/ai/runtimes/mcp/index.ts +++ b/apps/cli/ai/runtimes/mcp/index.ts @@ -14,7 +14,6 @@ import { Type, type TSchema } from 'typebox'; import { Value } from 'typebox/value'; import { type DesignTracksContext } from 'cli/ai/design-tracks'; import { isImageGenerationAvailable } from 'cli/ai/image-generation'; -import { PostContentCheck } from 'cli/ai/post-content-check'; import { DESIGN_PICKER_HTML, DESIGN_PICKER_URI, @@ -22,6 +21,7 @@ import { } from 'cli/ai/runtimes/mcp/design-picker'; import { type AppPage, createLibraryTools, libraryPage } from 'cli/ai/runtimes/mcp/library'; import { runServedTool } from 'cli/ai/runtimes/mcp/served-tool'; +import { SiteChanges } from 'cli/ai/site-changes'; import { createSiteNotes } from 'cli/ai/site-notes'; import { loadSkills } from 'cli/ai/skills'; import { buildSystemPrompt } from 'cli/ai/system-prompt'; @@ -131,7 +131,7 @@ export async function startMcpStdioServer(): Promise< void > { process.env.STUDIO_TRACKS_ORIGIN = 'studio-mcp'; const sessionId = randomUUID(); const siteNotes = createSiteNotes(); - const postContent = new PostContentCheck(); + const siteChanges = new SiteChanges(); const imageGeneration = await isImageGenerationAvailable(); const server = new Server( { name: 'studio', version: '1.0.0' }, @@ -189,7 +189,7 @@ export async function startMcpStdioServer(): Promise< void > { isError: true, }; } - return runServedTool( tool, args, siteNotes, postContent ); + return runServedTool( tool, args, siteNotes, siteChanges ); } ); // Only clients that render MCP Apps get the pages listed. diff --git a/apps/cli/ai/runtimes/mcp/served-tool.ts b/apps/cli/ai/runtimes/mcp/served-tool.ts index 95f3838661..fc44d347c5 100644 --- a/apps/cli/ai/runtimes/mcp/served-tool.ts +++ b/apps/cli/ai/runtimes/mcp/served-tool.ts @@ -1,6 +1,6 @@ import { studioMessage, validationLines } from 'cli/ai/studio-message'; import { resolveSite } from 'cli/ai/tools/utils'; -import type { PostContentCheck } from 'cli/ai/post-content-check'; +import type { SiteChanges } from 'cli/ai/site-changes'; import type { SiteNotes } from 'cli/ai/site-notes'; import type { AnyStudioAgentTool } from 'cli/ai/tools/define-tool'; @@ -22,12 +22,12 @@ export async function runServedTool( tool: AnyStudioAgentTool, args: Record< string, unknown >, siteNotes: SiteNotes, - postContent?: PostContentCheck + siteChanges?: SiteChanges ) { - await postContent?.snapshot().catch( () => undefined ); + await siteChanges?.snapshot().catch( () => undefined ); // A call that fails can still have changed content. const changedContent = async () => { - const report = await postContent?.settle().catch( () => undefined ); + const report = await siteChanges?.settle().catch( () => undefined ); return report ? validationLines( report ) : []; }; try { diff --git a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts index 7dd4204108..5d645e16af 100644 --- a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts +++ b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'os'; import path from 'path'; import { Type } from 'typebox'; import { expect, it, vi } from 'vitest'; -import { PostContentCheck } from 'cli/ai/post-content-check'; import { runServedTool } from 'cli/ai/runtimes/mcp/served-tool'; +import { SiteChanges } from 'cli/ai/site-changes'; import { createSiteNotes } from 'cli/ai/site-notes'; import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; import { defineTool, LOCAL_CHANGE, READ_ONLY } from 'cli/ai/tools/define-tool'; @@ -69,7 +69,7 @@ it( 'reports the block validation of post content the call changed, on any site' }, { annotations: LOCAL_CHANGE } ); - const result = await runServedTool( tool, {}, createSiteNotes(), new PostContentCheck() ); + const result = await runServedTool( tool, {}, createSiteNotes(), new SiteChanges() ); expect( result.content.map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) ).toEqual( [ 'Success: Updated post 9.', expect.stringMatching( @@ -92,7 +92,7 @@ it( 'reports the block validation of post content the call changed, on any site' }, { annotations: LOCAL_CHANGE } ); - const failed = await runServedTool( failing, {}, createSiteNotes(), new PostContentCheck() ); + const failed = await runServedTool( failing, {}, createSiteNotes(), new SiteChanges() ); expect( failed.isError ).toBe( true ); expect( JSON.stringify( failed.content ) ).toMatch( /Command exited with code 1\\n\\nBlock validation: Bakery page 9 \\"\\": core\/html policy failed/ diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index a3c3f93b4e..0a760f2787 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -46,7 +46,7 @@ import { withDesignSystemPreview } from 'cli/ai/chat-artifacts'; import { isImageGenerationAvailable } from 'cli/ai/image-generation'; import { emitEvent } from 'cli/ai/json-events'; import { describeOpenProblems, getOpenProblems } from 'cli/ai/open-problems'; -import { PostContentCheck } from 'cli/ai/post-content-check'; +import { SiteChanges } from 'cli/ai/site-changes'; import { readActiveSiteFacts } from 'cli/ai/site-facts'; import { createSiteNotes, type SiteNotes } from 'cli/ai/site-notes'; import { studioMessage, validationLines } from 'cli/ai/studio-message'; @@ -412,14 +412,14 @@ async function createStudioAgentSession( gate: blockValidationGate, } ); // What a call changed in the content the sites render, reported on its result. - const postContent = new PostContentCheck( undefined, getOpenProblems( config.session ) ); + const siteChanges = new SiteChanges( undefined, getOpenProblems( config.session ) ); const toolDefinitions = [ ...tools, ...mcpTools ].map( ( tool ) => toToolDefinition( tool, payloadGuardState, pendingWork, blockValidationGate, { confirm, sitePin, siteNotes, localSite, - postContent, + siteChanges, } ) ); const modelRuntime = await createModelRuntime( model, family, creds ); @@ -704,12 +704,12 @@ function toToolDefinition( sitePin, siteNotes, localSite, - postContent, + siteChanges, }: { confirm: Confirmer; sitePin: SitePin; siteNotes: SiteNotes; - postContent: PostContentCheck; + siteChanges: SiteChanges; // Studio's tools report on the active site when it is a local one. localSite?: string; } @@ -754,12 +754,12 @@ function toToolDefinition( const validationReport = BLOCK_VALIDATION_GATED_TOOLS.has( tool.name ) ? validationLines( await blockValidationGate.settle() ) : []; - await postContent.snapshot().catch( () => undefined ); + await siteChanges.snapshot().catch( () => undefined ); // A call that fails can still have changed content, as a shell // command does that exits non-zero after saving a page. const changedContent = async () => { return validationLines( - await postContent.settle().catch( () => ( { summaries: [], problems: [] } ) ) + await siteChanges.settle().catch( () => ( { summaries: [], problems: [] } ) ) ); }; // The validation report was taken from the gate, so it rides on a failure too. diff --git a/apps/cli/ai/site-changes.ts b/apps/cli/ai/site-changes.ts new file mode 100644 index 0000000000..d71f0ebfa1 --- /dev/null +++ b/apps/cli/ai/site-changes.ts @@ -0,0 +1,87 @@ +/** + * What a call changed on the sites, checked where WordPress reads it, not on + * the way there: content reaches a post by WP-CLI, `wp eval`, REST or an + * import, and a theme's files by any tool or script. + * + * Surfaces take a snapshot before a call and settle after it, so what a call + * changed is reported on that call's result. Each source reads one kind of + * thing WordPress reads, as versions a check compares. + */ +import { postContentChanges } from 'cli/ai/post-content-check'; +import { updatableThemeChanges } from 'cli/ai/theme-file-changes'; +import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; +import type { OpenProblems } from 'cli/ai/open-problems'; + +/** Each thing's version on a site, by key. */ +export type Versions = Record< string, string >; + +/** What a change means: a line for the result, and the problem it leaves, if any. */ +export interface ChangeReport { + key: string; + summary?: string; + problem?: string; +} + +export interface SiteChangeSource { + name: string; + /** The versions on a site now; the last ones read let a source reuse work. */ + read( sitePath: string, last?: Versions ): Promise< Versions >; + /** The changes from `before` to `after`, and the versions the next call is compared with. */ + check( + site: SiteData, + before: Versions, + after: Versions + ): Promise< { reports: ChangeReport[]; next?: Versions } >; +} + +export const SITE_CHANGE_SOURCES: SiteChangeSource[] = [ + postContentChanges, + updatableThemeChanges, +]; + +async function localSites(): Promise< SiteData[] > { + return ( await readCliConfig() ).sites; +} + +export class SiteChanges { + constructor( + private readonly snapshots = new Map< string, Versions >(), + private readonly open?: OpenProblems, + private readonly sites: () => Promise< SiteData[] > = localSites, + private readonly sources: SiteChangeSource[] = SITE_CHANGE_SOURCES + ) {} + + /** Records every local site as it is now. */ + async snapshot(): Promise< void > { + for ( const site of await this.sites() ) { + for ( const source of this.sources ) { + const key = `${ source.name } ${ site.path }`; + this.snapshots.set( key, await source.read( site.path, this.snapshots.get( key ) ) ); + } + } + } + + /** Checks what changed since the snapshot, and snapshots it. */ + async settle(): Promise< { summaries: string[]; problems: string[] } > { + const summaries: string[] = []; + const problems: string[] = []; + for ( const site of await this.sites() ) { + for ( const source of this.sources ) { + const key = `${ source.name } ${ site.path }`; + const before = this.snapshots.get( key ); + const after = await source.read( site.path, before ); + // A site first seen now has no changes yet. + const { reports, next } = before + ? await source.check( site, before, after ) + : { reports: [], next: undefined }; + this.snapshots.set( key, next ?? after ); + for ( const report of reports ) { + this.open?.record( site.path, `${ site.path }#${ report.key }`, report.problem ); + summaries.push( ...( report.summary ? [ report.summary ] : [] ) ); + problems.push( ...( report.problem ? [ report.problem ] : [] ) ); + } + } + } + return { summaries, problems }; + } +} diff --git a/apps/cli/ai/tests/file-write-refusal.test.ts b/apps/cli/ai/tests/file-write-refusal.test.ts index fbf65358f8..8a3069e41f 100644 --- a/apps/cli/ai/tests/file-write-refusal.test.ts +++ b/apps/cli/ai/tests/file-write-refusal.test.ts @@ -31,6 +31,18 @@ beforeEach( () => { } } ); +// WordPress.org's theme update check, which updates fresh. +const updateCheck = vi.fn( async ( _url: string, init: { body: URLSearchParams } ) => { + const themes = Object.keys( JSON.parse( init.body.get( 'themes' ) ?? '{}' ).themes ); + return Response.json( { + themes: [], + no_update: Object.fromEntries( + themes.filter( ( theme ) => theme === 'fresh' ).map( ( theme ) => [ theme, {} ] ) + ), + } ); +} ); +vi.stubGlobal( 'fetch', updateCheck ); + afterEach( () => { rmSync( root, { recursive: true, force: true } ); vi.mocked( findSiteByFolder ).mockReset(); @@ -63,7 +75,7 @@ describe( 'the file write refusal', () => { } } ); - it( 'refuses a theme WordPress updates, by its last update check, naming a child theme', async () => { + it( 'refuses a theme WordPress updates, by its last update check or WordPress.org, naming a child theme', async () => { for ( const theme of [ 'premium', 'client-theme', 'café', 'fresh' ] ) { const dir = path.join( studioSite(), 'wp-content', 'themes', theme ); mkdirSync( dir, { recursive: true } ); @@ -87,11 +99,12 @@ describe( 'the file write refusal', () => { isStudioSite ); - for ( const updatable of [ 'twentytwentyfive', 'premium' ] ) { + // Checked with an update source, or installed since and on WordPress.org. + for ( const updatable of [ 'twentytwentyfive', 'premium', 'fresh' ] ) { expect( await refusal( updatable ) ).toContain( `Template: ${ updatable }` ); } - // Checked, with no update source, or not checked yet: the user's own themes. - for ( const own of [ 'client-theme', 'café', 'fresh' ] ) { + // Checked, with no update source: the user's own themes. + for ( const own of [ 'client-theme', 'café' ] ) { expect( await refusal( own ) ).toBeUndefined(); } } ); @@ -105,8 +118,8 @@ describe( 'the file write refusal', () => { it.each( [ 'my-site/wp-config.php', - // A theme WordPress has no update check for. - 'my-site/wp-content/themes/twentytwentyfive/functions.php', + // A theme not checked yet that WordPress.org does not update. + 'my-site/wp-content/themes/client-work/functions.php', 'my-site/wp-content/plugins/wp-includes/x.php', 'my-site/DESIGN.md', 'elsewhere/wp-includes/functions.php', diff --git a/apps/cli/ai/tests/fixtures/wordpress-site.ts b/apps/cli/ai/tests/fixtures/wordpress-site.ts index ad1e642ea3..55274ea58d 100644 --- a/apps/cli/ai/tests/fixtures/wordpress-site.ts +++ b/apps/cli/ai/tests/fixtures/wordpress-site.ts @@ -30,7 +30,7 @@ export function createSite( prefix = 'wp_' ) { CREATE TABLE ${ prefix }options ( option_name TEXT PRIMARY KEY, option_value TEXT ); INSERT INTO ${ prefix }options VALUES ( 'active_plugins', 'a:0:{}' ); INSERT INTO ${ prefix }options VALUES ( 'stylesheet', 'bakery' ), ( 'template', 'bakery-parent' ); - INSERT INTO ${ prefix }options VALUES ( '_site_transient_update_themes', 'O:8:"stdClass":3:{s:7:"checked";a:2:{s:13:"bakery-parent";s:3:"1.0";s:16:"twentytwentyfive";s:3:"1.5";}s:8:"response";a:0:{}s:9:"no_update";a:2:{s:13:"bakery-parent";a:2:{s:5:"theme";s:13:"bakery-parent";s:11:"new_version";s:3:"1.0";}s:16:"twentytwentyfive";a:2:{s:5:"theme";s:16:"twentytwentyfive";s:11:"new_version";s:3:"1.5";}}}' ); + INSERT INTO ${ prefix }options VALUES ( '_site_transient_update_themes', 'O:8:"stdClass":3:{s:7:"checked";a:3:{s:6:"bakery";s:3:"1.0";s:13:"bakery-parent";s:3:"1.0";s:16:"twentytwentyfive";s:3:"1.5";}s:8:"response";a:0:{}s:9:"no_update";a:2:{s:13:"bakery-parent";a:2:{s:5:"theme";s:13:"bakery-parent";s:11:"new_version";s:3:"1.0";}s:16:"twentytwentyfive";a:2:{s:5:"theme";s:16:"twentytwentyfive";s:11:"new_version";s:3:"1.5";}}}' ); INSERT INTO ${ prefix }terms VALUES ( 1, 'bakery' ), ( 2, 'other-theme' ); INSERT INTO ${ prefix }term_taxonomy VALUES ( 1, 1, 'wp_theme' ), ( 2, 2, 'wp_theme' ); ` ); diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index 7305ead731..cc6c42e776 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -80,8 +80,8 @@ vi.mock( 'cli/ai/site-notes', () => ( { } ), } ) ); -vi.mock( 'cli/ai/post-content-check', () => ( { - PostContentCheck: class { +vi.mock( 'cli/ai/site-changes', () => ( { + SiteChanges: class { async snapshot() { mocks.steps.push( 'snapshot' ); } diff --git a/apps/cli/ai/tests/post-content-check.test.ts b/apps/cli/ai/tests/post-content-check.test.ts index 84fd7b1b96..2c89ff7f2c 100644 --- a/apps/cli/ai/tests/post-content-check.test.ts +++ b/apps/cli/ai/tests/post-content-check.test.ts @@ -2,7 +2,8 @@ import { existsSync, readFileSync, rmSync } from 'fs'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { validateBlocks, type ValidationReport } from 'cli/ai/block-validator'; -import { PostContentCheck } from 'cli/ai/post-content-check'; +import { postContentChanges } from 'cli/ai/post-content-check'; +import { SiteChanges } from 'cli/ai/site-changes'; import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; import type { SiteData } from 'cli/lib/cli-config/core'; @@ -37,7 +38,10 @@ beforeEach( () => { afterEach( () => rmSync( fixture.root, { recursive: true, force: true } ) ); -describe( 'PostContentCheck', () => { +const checkPosts = () => + new SiteChanges( new Map(), undefined, async () => [ site ], [ postContentChanges ] ); + +describe( 'post content changes', () => { it.each( [ { name: 'an unchanged site', change: () => {}, reported: [] }, { @@ -60,7 +64,7 @@ describe( 'PostContentCheck', () => { reported: [], }, ] )( 'reports what a call changed: $name', async ( { change, reported } ) => { - const check = new PostContentCheck( new Map(), undefined, async () => [ site ] ); + const check = checkPosts(); await check.snapshot(); change(); expect( ( await check.settle() ).summaries ).toEqual( reported ); @@ -69,13 +73,13 @@ describe( 'PostContentCheck', () => { } ); it( 'reports nothing for a site it has not seen before', async () => { - const check = new PostContentCheck( new Map(), undefined, async () => [ site ] ); + const check = checkPosts(); fixture.edit( 9, HTML_ONLY ); expect( await check.settle() ).toEqual( { summaries: [], problems: [] } ); } ); it( 'asks for editable blocks in place of core/html', async () => { - const check = new PostContentCheck( new Map(), undefined, async () => [ site ] ); + const check = checkPosts(); await check.snapshot(); fixture.edit( 9, HTML_ONLY ); const { problems } = await check.settle(); @@ -85,7 +89,7 @@ describe( 'PostContentCheck', () => { it( 'saves a lossless editor fix next to the site, with the command that applies it', async () => { vi.mocked( validateBlocks ).mockResolvedValue( losslessFix ); - const check = new PostContentCheck( new Map(), undefined, async () => [ site ] ); + const check = checkPosts(); await check.snapshot(); fixture.edit( 9, UNWRAPPED_GROUP ); const { summaries, problems } = await check.settle(); @@ -103,7 +107,7 @@ describe( 'PostContentCheck', () => { it( 'reports a validation that cannot run', async () => { vi.mocked( validateBlocks ).mockRejectedValue( new Error( 'site is not running' ) ); - const check = new PostContentCheck( new Map(), undefined, async () => [ site ] ); + const check = checkPosts(); await check.snapshot(); fixture.edit( 9, UNWRAPPED_GROUP ); expect( ( await check.settle() ).summaries ).toEqual( [ diff --git a/apps/cli/ai/tests/theme-file-changes.test.ts b/apps/cli/ai/tests/theme-file-changes.test.ts new file mode 100644 index 0000000000..9f4d68bcfd --- /dev/null +++ b/apps/cli/ai/tests/theme-file-changes.test.ts @@ -0,0 +1,84 @@ +import { mkdirSync, rmSync, writeFileSync } from 'fs'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { SiteChanges } from 'cli/ai/site-changes'; +import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; +import { updatableThemeChanges } from 'cli/ai/theme-file-changes'; +import type { SiteData } from 'cli/lib/cli-config/core'; + +let fixture: ReturnType< typeof createSite >; +let site: SiteData; + +// Writes a theme file as any tool or script would. +const write = ( file: string, content: string ) => { + const target = path.join( fixture.root, 'wp-content/themes', file ); + mkdirSync( path.dirname( target ), { recursive: true } ); + writeFileSync( target, content ); +}; + +beforeEach( () => { + fixture = createSite(); + write( 'twentytwentyfive/theme.json', '{"version":3}' ); + site = { id: '1', name: 'Bakery', path: fixture.root } as SiteData; +} ); + +afterEach( () => rmSync( fixture.root, { recursive: true, force: true } ) ); + +const checkThemes = () => + new SiteChanges( new Map(), undefined, async () => [ site ], [ updatableThemeChanges ] ); + +describe( 'updatable theme file changes', () => { + it.each( [ + { name: 'an unchanged site', change: () => {}, problem: undefined }, + { + name: "an edit to an updated theme's file", + change: () => write( 'twentytwentyfive/theme.json', '{"version":3,"settings":{}}' ), + problem: 'it loses the changes to theme.json', + }, + { + name: 'a new file in an updated theme', + change: () => write( 'bakery-parent/parts/header.html', '' ), + problem: 'it loses the changes to parts/header.html', + }, + { + name: "an edit to the user's own theme", + change: () => write( 'bakery/style.css', '/*\nTheme Name: Bakery\n*/' ), + problem: undefined, + }, + { + name: 'an update of the theme, by WordPress', + change: () => { + write( 'twentytwentyfive/style.css', '/*\nVersion: 1.6\n*/' ); + write( 'twentytwentyfive/theme.json', '{"version":3,"new":true}' ); + }, + problem: undefined, + }, + ] )( 'reports $name', async ( { change, problem } ) => { + const check = checkThemes(); + await check.snapshot(); + change(); + const { problems } = await check.settle(); + expect( problems ).toHaveLength( problem ? 1 : 0 ); + if ( problem ) { + expect( problems[ 0 ] ).toContain( problem ); + expect( problems[ 0 ] ).toContain( 'Template: ' ); + } + } ); + + it( 'keeps a change open until the released file is back', async () => { + const check = checkThemes(); + await check.snapshot(); + write( 'twentytwentyfive/theme.json', '{"version":3,"settings":{}}' ); + expect( ( await check.settle() ).problems ).toHaveLength( 1 ); + + // A later call that changes the theme again still names the first change. + await check.snapshot(); + write( 'twentytwentyfive/functions.php', ' version?.split( ' ' )[ 1 ] ?? ''; + +async function readFiles( dir: string, slug: string, last: Versions ): Promise< Versions > { + const versions: Versions = {}; + for ( const entry of await readdir( dir, { recursive: true, withFileTypes: true } ) ) { + if ( ! entry.isFile() ) { + continue; + } + const file = path.join( entry.parentPath, entry.name ); + const key = `${ slug }/${ path.relative( dir, file ).split( path.sep ).join( '/' ) }`; + const { mtimeMs, size } = await stat( file ); + const stamp = `${ mtimeMs }:${ size }`; + const known = last[ key ]?.startsWith( `${ stamp } ` ) ? hashOf( last[ key ] ) : undefined; + versions[ key ] = `${ stamp } ${ + known ?? + createHash( 'sha1' ) + .update( await readFile( file ) ) + .digest( 'hex' ) + }`; + } + return versions; +} + +function problem( slug: string, files: string[] ): string { + const named = files.length > 5 ? `${ files.slice( 0, 5 ).join( ', ' ) }, …` : files.join( ', ' ); + return `${ slug } is a theme WordPress updates, and an update replaces its files, so it loses the changes to ${ named }. Make them in a child theme of ${ slug }, whose style.css names \`Template: ${ slug }\`, then restore ${ slug }'s released files, as \`wp theme install ${ slug } --force\` does for a theme from WordPress.org.`; +} + +export const updatableThemeChanges: SiteChangeSource = { + name: 'themes', + async read( sitePath, last = {} ) { + const themesDir = path.join( sitePath, 'wp-content', 'themes' ); + const slugs = ( await readdir( themesDir, { withFileTypes: true } ).catch( () => [] ) ) + .filter( ( entry ) => entry.isDirectory() ) + .map( ( entry ) => entry.name ); + const versions: Versions = {}; + for ( const slug of slugs ) { + const release = await readThemeVersion( sitePath, slug ).catch( () => '' ); + // Whether WordPress updates a theme is asked once per release. + const updated = last[ slug ]?.endsWith( ` ${ release }` ) + ? last[ slug ].startsWith( UPDATED ) + : await isUpdatableTheme( sitePath, slug ); + versions[ slug ] = `${ updated ? UPDATED : 'own ' }${ release }`; + if ( updated ) { + Object.assign( versions, await readFiles( path.join( themesDir, slug ), slug, last ) ); + } + // The released files a call changed, kept while that release is installed. + if ( updated && last[ slug ] === versions[ slug ] ) { + for ( const [ key, base ] of Object.entries( last ) ) { + if ( key.startsWith( `${ BASE }${ slug }/` ) ) { + versions[ key ] = base; + } + } + } + } + return versions; + }, + async check( _site, before, after ) { + const next: Versions = { ...after }; + for ( const key of Object.keys( next ) ) { + if ( key.startsWith( BASE ) ) { + delete next[ key ]; + } + } + const reports = []; + for ( const [ slug, release ] of Object.entries( after ) ) { + // Files of an own theme, or of one this call installed or updated, are not reported. + if ( ! release.startsWith( UPDATED ) || before[ slug ] !== release ) { + continue; + } + const prefix = `${ slug }/`; + const files = new Set( + [ ...Object.keys( before ), ...Object.keys( after ) ] + .map( ( key ) => ( key.startsWith( BASE ) ? key.slice( BASE.length ) : key ) ) + .filter( ( key ) => key.startsWith( prefix ) ) + ); + let changed = false; + const changes: string[] = []; + for ( const file of files ) { + const now = hashOf( after[ file ] ); + const was = hashOf( before[ file ] ); + const base = before[ `${ BASE }${ file }` ] ?? was; + changed ||= now !== was; + if ( now !== base ) { + next[ `${ BASE }${ file }` ] = base; + changes.push( file.slice( prefix.length ) ); + } + } + if ( changed ) { + reports.push( { + key: `theme ${ slug }`, + problem: changes.length ? problem( slug, changes ) : undefined, + } ); + } + } + return { reports, next }; + }, +}; diff --git a/apps/cli/ai/themes.ts b/apps/cli/ai/themes.ts index 4f3772da51..adf0850146 100644 --- a/apps/cli/ai/themes.ts +++ b/apps/cli/ai/themes.ts @@ -1,24 +1,62 @@ +import { readFile } from 'fs/promises'; +import path from 'path'; import { readWordPressSite, type WordPressSite } from 'cli/ai/site-database'; // Whether WordPress updates a theme, and so overwrites changes to its files. // WordPress's last update check (wp_update_themes() in wp-includes/update.php) // stores in the update_themes transient the themes it checked and those it // found an update source for: WordPress.org, or the theme's Update URI. A -// theme it has not checked yet, such as one created or installed since, is not -// known to be updated until WordPress next looks for updates. +// theme installed since that check is asked of WordPress.org the way +// wp_update_themes() asks, by slug and version. type Keyed = Record< string, unknown > | undefined; +type UpdateCheck = Record< 'checked' | 'response' | 'no_update', Keyed > | undefined; function isUpdated( site: WordPressSite, theme: string ): boolean { - const updates = site.option( '_site_transient_update_themes' ) as - | Record< 'response' | 'no_update', Keyed > - | undefined; + const updates = site.option( '_site_transient_update_themes' ) as UpdateCheck; return theme in ( updates?.response ?? {} ) || theme in ( updates?.no_update ?? {} ); } +const UPDATE_CHECK_URL = 'https://api.wordpress.org/themes/update-check/1.1/'; +const asked = new Map< string, Promise< boolean > >(); + +/** The Version a theme's style.css header names. */ +export async function readThemeVersion( root: string, theme: string ): Promise< string > { + const css = await readFile( path.join( root, 'wp-content/themes', theme, 'style.css' ), 'utf8' ); + return css.match( /^[ \t/*#@]*Version:(.*)$/im )?.[ 1 ].trim() ?? ''; +} + +// One answer per theme release; an unanswered check is asked again. +function askWordPressOrg( theme: string, version: string ): Promise< boolean > { + const key = `${ theme }@${ version }`; + let answer = asked.get( key ); + if ( ! answer ) { + answer = fetch( UPDATE_CHECK_URL, { + method: 'POST', + body: new URLSearchParams( { + themes: JSON.stringify( { themes: { [ theme ]: { Version: version } } } ), + } ), + } ) + .then( ( response ) => ( response.ok ? response.json() : Promise.reject() ) ) + .then( + ( body: { themes?: Keyed; no_update?: Keyed } ) => + theme in ( body.themes ?? {} ) || theme in ( body.no_update ?? {} ) + ); + answer.catch( () => asked.delete( key ) ); + asked.set( key, answer ); + } + return answer.catch( () => false ); +} + /** Whether a theme update would overwrite changes to this theme's files. */ -export function isUpdatableTheme( root: string, theme: string ): Promise< boolean > { - return readWordPressSite( root, ( site ) => isUpdated( site, theme ) ); +export async function isUpdatableTheme( root: string, theme: string ): Promise< boolean > { + const checked = await readWordPressSite( root, ( site ) => { + const updates = site.option( '_site_transient_update_themes' ) as UpdateCheck; + return theme in ( updates?.checked ?? {} ) ? isUpdated( site, theme ) : undefined; + } ); + return ( + checked ?? askWordPressOrg( theme, await readThemeVersion( root, theme ).catch( () => '' ) ) + ); } /** Site fact: the active theme, its parent, and which of them WordPress updates. */ From da60239e9ced7a04232e044f3f2e2ac7061c795a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:06:03 -0400 Subject: [PATCH 057/199] State a site's presets and homepage, on the result of a call that names the site A tool names the site its call is about by nameOrPath, or by the argument it declares (siteParam: site_create's name). Studio Code and MCP both attach the site's notes and facts to that site, so a site created mid-session has its facts on site_create's result rather than only an active site's. Two facts the agent looked up by hand: - The presets of the active theme as WordPress resolves them (wp_get_global_settings(): core, parent, child and user Global Styles), by the slug blocks use. Resolved by WP-CLI, again only when the themes' JSON, Global Styles, active plugins or WordPress change. - What Settings -> Reading shows at the homepage and the posts page, including a page that is gone or unpublished, as WP_Query does. A site fact can now state several lines. --- apps/cli/ai/front-page-template.ts | 33 ++++ apps/cli/ai/runtimes/mcp/served-tool.ts | 15 +- .../ai/runtimes/mcp/tests/served-tool.test.ts | 11 ++ apps/cli/ai/runtimes/pi/index.ts | 6 +- apps/cli/ai/site-facts.ts | 19 +- apps/cli/ai/tests/fixtures/wordpress-site.ts | 7 +- apps/cli/ai/tests/pi-runtime.test.ts | 13 ++ apps/cli/ai/tests/site-facts.test.ts | 33 ++++ apps/cli/ai/tests/theme-presets.test.ts | 167 ++++++++++++++++++ apps/cli/ai/theme-presets.ts | 153 ++++++++++++++++ apps/cli/ai/tools/create-site.ts | 1 + apps/cli/ai/tools/define-tool.ts | 5 + apps/cli/ai/tools/utils.ts | 8 + 13 files changed, 445 insertions(+), 26 deletions(-) create mode 100644 apps/cli/ai/tests/theme-presets.test.ts create mode 100644 apps/cli/ai/theme-presets.ts diff --git a/apps/cli/ai/front-page-template.ts b/apps/cli/ai/front-page-template.ts index b72c8048f9..158688e544 100644 --- a/apps/cli/ai/front-page-template.ts +++ b/apps/cli/ai/front-page-template.ts @@ -7,6 +7,7 @@ */ import { readdir, readFile } from 'fs/promises'; import path from 'path'; +import type { WordPressSite } from 'cli/ai/site-database'; const REQUIREMENT = 'https://make.wordpress.org/themes/handbook/review/required/'; const POST_CONTENT = / in the template, or remove the template so page.html renders the page.`; } + +/** + * What Settings → Reading shows at the homepage and the posts page, as a site + * fact. WP_Query::parse_query() shows page_on_front whenever show_on_front is + * `page` and it names one, so a page that is gone or unpublished is stated + * as such. + */ +export function homepageFact( site: WordPressSite ): string | undefined { + const page = ( option: string ) => { + const id = Number( site.option( option ) ); + if ( ! id ) { + return undefined; + } + const row = site.query< { title: string; status: string } >( + `SELECT post_title AS title, post_status AS status FROM ${ site.prefix }posts WHERE ID = ? AND post_type = 'page'`, + id + )[ 0 ]; + if ( ! row ) { + return `page ${ id }, which does not exist`; + } + return `page ${ id } "${ row.title }"${ row.status === 'publish' ? '' : ` (${ row.status })` }`; + }; + const showOnFront = site.option( 'show_on_front' ); + const front = showOnFront === 'page' ? page( 'page_on_front' ) : undefined; + if ( ! front ) { + return showOnFront === undefined ? undefined : 'Homepage: the latest posts (show_on_front).'; + } + const posts = page( 'page_for_posts' ); + return `Homepage: ${ front } (show_on_front, page_on_front)${ + posts ? `; posts page: ${ posts } (page_for_posts)` : '' + }.`; +} diff --git a/apps/cli/ai/runtimes/mcp/served-tool.ts b/apps/cli/ai/runtimes/mcp/served-tool.ts index fc44d347c5..2445fb6c33 100644 --- a/apps/cli/ai/runtimes/mcp/served-tool.ts +++ b/apps/cli/ai/runtimes/mcp/served-tool.ts @@ -1,21 +1,10 @@ import { studioMessage, validationLines } from 'cli/ai/studio-message'; -import { resolveSite } from 'cli/ai/tools/utils'; +import { siteOfCall } from 'cli/ai/tools/utils'; import type { SiteChanges } from 'cli/ai/site-changes'; import type { SiteNotes } from 'cli/ai/site-notes'; import type { AnyStudioAgentTool } from 'cli/ai/tools/define-tool'; // The site an MCP call works on is the one it names. -async function siteNamed( args: Record< string, unknown > ) { - if ( typeof args.nameOrPath !== 'string' ) { - return undefined; - } - try { - return await resolveSite( args.nameOrPath ); - } catch { - return undefined; - } -} - // Runs a served tool for an MCP call. MCP wants `{content, isError}`; pi-native // handlers throw — translate. export async function runServedTool( @@ -36,7 +25,7 @@ export async function runServedTool( const resultText = result.content .map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) .join( '\n' ); - const site = await siteNamed( args ); + const site = await siteOfCall( tool, args ); const notes = studioMessage( 'notes', [ ...changed, ...( site ? await siteNotes.afterResult( site.path, resultText ) : [] ), diff --git a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts index 5d645e16af..5fdce4522c 100644 --- a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts +++ b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts @@ -47,6 +47,17 @@ it( 'adds the declared settings of the core blocks a result shows, from the site ] ); // Once per MCP session, as once per Studio Code turn. expect( await notes( { nameOrPath: 'My Site' } ) ).toEqual( [] ); + + // A tool that names its site by another argument, such as the site it creates. + const create = defineTool( + 'site_create', + 'Creates a site.', + { name: Type.String() }, + async () => ( { content: [ { type: 'text', text: 'Created.' } ] } ), + { annotations: LOCAL_CHANGE, siteParam: 'name' } + ); + const created = await runServedTool( create, { name: 'My Site' }, createSiteNotes() ); + expect( JSON.stringify( created.content ) ).toContain( 'Address: http://localhost:8881' ); } ); it( 'reports the block validation of post content the call changed, on any site', async () => { diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 0a760f2787..d69c78c8f3 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -55,6 +55,7 @@ import { resolveStudioToolDefinitions } from 'cli/ai/tools'; import { createAskUserQuestionTool } from 'cli/ai/tools/ask-user-question'; import { createPresentDesignOptionsTool } from 'cli/ai/tools/present-design-options'; import { createSkillTool } from 'cli/ai/tools/skill'; +import { siteOfCall } from 'cli/ai/tools/utils'; import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths'; import { createConfirmer, describeDestructiveCall, type Confirmer } from './confirmation'; import { withLeakedToolCallMarkupRemoved } from './leaked-tool-call-markup'; @@ -710,7 +711,7 @@ function toToolDefinition( sitePin: SitePin; siteNotes: SiteNotes; siteChanges: SiteChanges; - // Studio's tools report on the active site when it is a local one. + // Studio's tools report on the site a call names, else the active site when it is a local one. localSite?: string; } ): ToolDefinition { @@ -786,6 +787,7 @@ function toToolDefinition( delete details.pending; } const changed = await changedContent(); + const resultSite = ( await siteOfCall( tool, params ) )?.path ?? localSite; const resultText = result.content .map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) .join( '\n' ); @@ -793,7 +795,7 @@ function toToolDefinition( ...validationReport, ...changed, ...( writtenFile ? await siteNotes.afterWrite( writtenFile ) : [] ), - ...( localSite ? await siteNotes.afterResult( localSite, resultText ) : [] ), + ...( resultSite ? await siteNotes.afterResult( resultSite, resultText ) : [] ), pendingWork.takeReports(), ] ); return reports diff --git a/apps/cli/ai/site-facts.ts b/apps/cli/ai/site-facts.ts index c098e2a01d..3d7ba9da7e 100644 --- a/apps/cli/ai/site-facts.ts +++ b/apps/cli/ai/site-facts.ts @@ -1,6 +1,8 @@ +import { homepageFact } from 'cli/ai/front-page-template'; import { activePluginsFact, comingSoonFact } from 'cli/ai/plugins'; import { savedCopiesFact } from 'cli/ai/saved-copies'; import { readWordPressSite, type WordPressSite } from 'cli/ai/site-database'; +import { themePresetsFact } from 'cli/ai/theme-presets'; import { activeThemeFact } from 'cli/ai/themes'; import { findSiteByFolder, getSiteUrl } from 'cli/lib/cli-config/sites'; import { getWordPressVersionFromInstallation } from 'cli/lib/dependency-management/wordpress'; @@ -12,11 +14,10 @@ import type { SiteInfo } from 'cli/ai/types'; // for Studio Code and MCP agents. // // A fact is a function of the site, owned by the module that knows that part -// of WordPress; it returns undefined when it has nothing to state. Add one by -// adding it here, in the order the facts read. -export type SiteFact = ( - site: WordPressSite -) => string | undefined | Promise< string | undefined >; +// of WordPress; it returns a line, or several, and nothing when it has nothing +// to state. Add one by adding it here, in the order the facts read. +type FactLines = string | string[] | undefined; +export type SiteFact = ( site: WordPressSite ) => FactLines | Promise< FactLines >; async function wordPressVersionFact( site: WordPressSite ): Promise< string | undefined > { const version = await getWordPressVersionFromInstallation( site.root ); @@ -34,6 +35,8 @@ const SITE_FACTS: SiteFact[] = [ siteAddressFact, wordPressVersionFact, activeThemeFact, + themePresetsFact, + homepageFact, activePluginsFact, savedCopiesFact, comingSoonFact, @@ -42,9 +45,9 @@ const SITE_FACTS: SiteFact[] = [ /** The facts about the local site at `sitePath`. */ export function readSiteFacts( sitePath: string ): Promise< string[] > { return readWordPressSite( sitePath, async ( site ) => - ( await Promise.all( SITE_FACTS.map( ( fact ) => fact( site ) ) ) ).filter( - ( fact ): fact is string => Boolean( fact ) - ) + ( await Promise.all( SITE_FACTS.map( ( fact ) => fact( site ) ) ) ) + .flat() + .filter( ( fact ): fact is string => Boolean( fact ) ) ); } diff --git a/apps/cli/ai/tests/fixtures/wordpress-site.ts b/apps/cli/ai/tests/fixtures/wordpress-site.ts index 55274ea58d..79e6e39a1d 100644 --- a/apps/cli/ai/tests/fixtures/wordpress-site.ts +++ b/apps/cli/ai/tests/fixtures/wordpress-site.ts @@ -40,11 +40,12 @@ export function createSite( prefix = 'wp_' ) { slug: string, content = '', theme = 1, - status = 'publish' + status = 'publish', + title = '' ) => { db.prepare( - `INSERT INTO ${ prefix }posts ( ID, post_type, post_status, post_name, post_content, post_date ) VALUES ( ?, ?, ?, ?, ?, '2026-10-22' )` - ).run( id, type, status, slug, content ); + `INSERT INTO ${ prefix }posts ( ID, post_type, post_status, post_name, post_content, post_date, post_title ) VALUES ( ?, ?, ?, ?, ?, '2026-10-22', ? )` + ).run( id, type, status, slug, content, title ); db.prepare( `INSERT INTO ${ prefix }term_relationships VALUES ( ?, ? )` ).run( id, theme ); }; const setOptions = ( options: Record< string, string > ) => { diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index cc6c42e776..33918ac308 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -71,6 +71,11 @@ vi.mock( '@earendil-works/pi-coding-agent', async ( importOriginal ) => { } ); // Each trigger answers with what it was fired with. +vi.mock( 'cli/ai/tools/utils', async ( importOriginal ) => ( { + ...( await importOriginal< typeof import('cli/ai/tools/utils') >() ), + siteOfCall: async ( _tool: unknown, args: { nameOrPath?: string } ) => + args?.nameOrPath ? { path: `/sites/${ args.nameOrPath }` } : undefined, +} ) ); vi.mock( 'cli/ai/site-notes', () => ( { createSiteNotes: () => ( { beforeWrite: async ( file: string ) => @@ -545,6 +550,14 @@ describe( 'pi runtime', () => { ( part ) => part.text ) ).toEqual( [ '', `\nwrote ${ file }\n` ] ); + + // A call that names a local site reports on it, as one creating a site does. + const none = await run( undefined ); + expect( + ( + await none.execute( 'w', { path: 'a/style.css', content: '', nameOrPath: 'b' } ) + ).content.map( ( part ) => part.text ) + ).toEqual( [ '', `\nwrote ${ file }\nresult on /sites/b\n` ] ); } ); it( 'reports the post content a call changed, from a snapshot taken before it', async () => { diff --git a/apps/cli/ai/tests/site-facts.test.ts b/apps/cli/ai/tests/site-facts.test.ts index 7649a6203e..b3303f65ef 100644 --- a/apps/cli/ai/tests/site-facts.test.ts +++ b/apps/cli/ai/tests/site-facts.test.ts @@ -43,6 +43,39 @@ it( 'states the WordPress version, the active theme and whether WordPress update ] ); } ); +it.each( [ + { reading: { show_on_front: 'posts' }, fact: 'Homepage: the latest posts (show_on_front).' }, + { + reading: { show_on_front: 'page', page_on_front: '9' }, + fact: 'Homepage: page 9 "Home" (show_on_front, page_on_front).', + }, + { + reading: { show_on_front: 'page', page_on_front: '9', page_for_posts: '10' }, + fact: 'Homepage: page 9 "Home" (show_on_front, page_on_front); posts page: page 10 "News" (page_for_posts).', + }, + // WordPress queries the page Reading names, whatever became of it. + { + reading: { show_on_front: 'page', page_on_front: '99' }, + fact: 'Homepage: page 99, which does not exist (show_on_front, page_on_front).', + }, + { + reading: { show_on_front: 'page', page_on_front: '11' }, + fact: 'Homepage: page 11 "Old home" (trash) (show_on_front, page_on_front).', + }, + { + reading: { show_on_front: 'page', page_on_front: '0' }, + fact: 'Homepage: the latest posts (show_on_front).', + }, +] )( 'states what Settings → Reading shows at the homepage: $fact', async ( { reading, fact } ) => { + const site = createSite( 'wp_' ); + site.save( 9, 'page', 'home', '', 1, 'publish', 'Home' ); + site.save( 10, 'page', 'news', '', 1, 'publish', 'News' ); + site.save( 11, 'page', 'old-home', '', 1, 'trash', 'Old home' ); + site.setOptions( reading as Record< string, string > ); + const titled = ( await site.facts() ).find( ( line ) => line.startsWith( 'Homepage:' ) ); + expect( titled ).toBe( fact ); +} ); + // Imported sites can name their tables with a prefix other than wp_. it.each( [ 'wp_', 'site2_' ] )( 'states the copies saved in the Site Editor that WordPress renders instead of theme files (%s tables)', diff --git a/apps/cli/ai/tests/theme-presets.test.ts b/apps/cli/ai/tests/theme-presets.test.ts new file mode 100644 index 0000000000..41e3c269a7 --- /dev/null +++ b/apps/cli/ai/tests/theme-presets.test.ts @@ -0,0 +1,167 @@ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readWordPressSite } from 'cli/ai/site-database'; +import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; +import { themePresetsFact } from 'cli/ai/theme-presets'; +import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; + +vi.mock( 'cli/lib/daemon-client', () => ( { + connectToDaemon: vi.fn(), + disconnectFromDaemon: vi.fn(), +} ) ); +vi.mock( 'cli/ai/tools/utils', () => ( { + resolveSite: async ( root: string ) => ( { id: 'site', name: 'Site', path: root } ), +} ) ); +vi.mock( 'cli/lib/run-wp-cli-command', async ( importOriginal ) => ( { + runWpCliCommand: vi.fn( + ( await importOriginal< typeof import('cli/lib/run-wp-cli-command') >() ).runWpCliCommand + ), +} ) ); + +const presetsOf = ( root: string ) => readWordPressSite( root, themePresetsFact ); + +// A real WordPress install to resolve presets with, when one is given. +const WORDPRESS = process.env.STUDIO_TEST_WORDPRESS; + +describe( 'the theme presets fact', () => { + let fixture: ReturnType< typeof createSite >; + const resolved = { + colors: [ [ 'base', '#FFFFFF' ] ], + fontFamilies: [ [ 'inter', 'Inter' ] ], + fontSizes: [], + spacingSizes: [ [ '40', '30px' ] ], + layout: { contentSize: '645px' }, + }; + let output: () => string; + + beforeEach( () => { + fixture = createSite(); + output = () => `PHP Notice: noise\n${ JSON.stringify( resolved ) }`; + vi.mocked( runWpCliCommand ) + .mockReset() + .mockImplementation( async () => { + const text = output(); + return { + response: { + stdoutText: Promise.resolve( text ), + exitCode: Promise.resolve( text ? 0 : 1 ), + }, + [ Symbol.asyncDispose ]: async () => {}, + } as never; + } ); + } ); + afterEach( () => rmSync( fixture.root, { recursive: true, force: true } ) ); + + it( "states WordPress's resolved presets, by slug", async () => { + expect( await presetsOf( fixture.root ) ).toEqual( [ + 'Color presets (wp_get_global_settings()): base #FFFFFF.', + 'Font family presets (wp_get_global_settings()): inter "Inter".', + 'Spacing size presets (wp_get_global_settings()): 40 30px.', + 'Layout (wp_get_global_settings()): content width 645px, wide width none.', + ] ); + } ); + + it.each( [ + { name: 'nothing changed', change: () => {}, again: false }, + { + name: "a theme's theme.json", + change: () => + writeFileSync( + path.join( fixture.root, 'wp-content/themes/bakery-parent/theme.json' ), + '{"version":3}' + ), + again: true, + }, + { + name: 'a style variation of the child theme', + change: () => { + mkdirSync( path.join( fixture.root, 'wp-content/themes/bakery/styles' ) ); + writeFileSync( + path.join( fixture.root, 'wp-content/themes/bakery/styles/dark.json' ), + '{"version":3}' + ); + }, + again: true, + }, + { + name: "the user's Global Styles", + change: () => fixture.save( 20, 'wp_global_styles', 'wp-global-styles-bakery', '{}' ), + again: true, + }, + { + name: 'the active theme', + change: () => + fixture.setOptions( { stylesheet: 'twentytwentyfive', template: 'twentytwentyfive' } ), + again: true, + }, + { + name: "an inactive theme's theme.json", + change: () => + writeFileSync( + path.join( fixture.root, 'wp-content/themes/twentytwentyfive/theme.json' ), + '{"version":3}' + ), + again: false, + }, + ] )( 'runs WordPress again when $name changes: $again', async ( { change, again } ) => { + await presetsOf( fixture.root ); + change(); + await presetsOf( fixture.root ); + expect( runWpCliCommand ).toHaveBeenCalledTimes( again ? 2 : 1 ); + } ); + + it( 'states nothing when WordPress cannot run, and asks it again next time', async () => { + output = () => ''; + expect( await presetsOf( fixture.root ) ).toEqual( [] ); + output = () => JSON.stringify( resolved ); + expect( await presetsOf( fixture.root ) ).toHaveLength( 4 ); + } ); +} ); + +describe.runIf( WORDPRESS )( 'the theme presets fact, resolved by WordPress', () => { + let root: string; + beforeEach( () => { + vi.mocked( runWpCliCommand ).mockRestore(); + root = mkdtempSync( path.join( os.tmpdir(), 'theme-presets-' ) ); + cpSync( WORDPRESS!, root, { recursive: true } ); + } ); + afterEach( () => rmSync( root, { recursive: true, force: true } ) ); + + it( "merges the parent and child themes' presets as WordPress does", async () => { + const theme = ( slug: string, header: string, json: object ) => { + const dir = path.join( root, 'wp-content/themes', slug ); + mkdirSync( dir, { recursive: true } ); + writeFileSync( path.join( dir, 'style.css' ), `/*\nTheme Name: ${ slug }\n${ header }*/` ); + writeFileSync( path.join( dir, 'theme.json' ), JSON.stringify( { version: 3, ...json } ) ); + }; + theme( 'presets-parent', '', { + settings: { + color: { defaultPalette: false, palette: [ { slug: 'ink', color: '#000', name: 'Ink' } ] }, + typography: { fontSizes: [ { slug: 'big', size: '3rem', name: 'Big' } ] }, + layout: { contentSize: '600px', wideSize: '1200px' }, + }, + } ); + theme( 'presets-child', 'Template: presets-parent\n', { + settings: { color: { palette: [ { slug: 'paper', color: '#fff', name: 'Paper' } ] } }, + } ); + const { DatabaseSync } = await import( 'node:sqlite' ); + const db = new DatabaseSync( path.join( root, 'wp-content/database/.ht.sqlite' ) ); + db.prepare( + "UPDATE wp_options SET option_value = ? WHERE option_name IN ( 'stylesheet', 'template' )" + ).run( 'presets-child' ); + db.prepare( + "UPDATE wp_options SET option_value = 'presets-parent' WHERE option_name = 'template'" + ).run(); + db.close(); + + const presets = await presetsOf( root ); + // The child's palette replaces the parent's; the rest is inherited. + expect( presets ).toContain( 'Color presets (wp_get_global_settings()): paper #fff.' ); + expect( presets.find( ( line ) => line.startsWith( 'Font size' ) ) ).toContain( 'big 3rem' ); + expect( presets ).toContain( + 'Layout (wp_get_global_settings()): content width 600px, wide width 1200px.' + ); + }, 60000 ); +} ); diff --git a/apps/cli/ai/theme-presets.ts b/apps/cli/ai/theme-presets.ts new file mode 100644 index 0000000000..ec2c87216a --- /dev/null +++ b/apps/cli/ai/theme-presets.ts @@ -0,0 +1,153 @@ +/** + * The presets a site's blocks and styles use, as WordPress resolves them: + * wp_get_global_settings() merges core's theme.json, the parent and child + * themes' and the user's Global Styles (WP_Theme_JSON_Resolver), the same data + * the editor shows. Each preset is named by the slug a block attribute or a + * `var(--wp--preset--…)` takes. + * + * Resolving them runs WordPress, so they are resolved again only when what + * they are resolved from changes: the active themes' files, the user's Global + * Styles, the active plugins (which can filter theme.json) and WordPress. + */ +import { readdir, stat } from 'fs/promises'; +import path from 'path'; +import { resolveSite } from 'cli/ai/tools/utils'; +import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; +import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; +import type { WordPressSite } from 'cli/ai/site-database'; + +interface Presets { + colors: Array< [ string, string ] >; + fontFamilies: Array< [ string, string ] >; + fontSizes: Array< [ string, string ] >; + spacingSizes: Array< [ string, string ] >; + layout: { contentSize?: string; wideSize?: string }; +} + +// The presets of each origin the editor offers, in WordPress's order: core's +// unless the theme turns them off, then the theme's, then the user's. +const RESOLVE = ` +$settings = wp_get_global_settings(); +$presets = function ( $path, $default_flag, $value ) use ( $settings ) { + $by_origin = _wp_array_get( $settings, $path, array() ); + $origins = array( 'theme', 'custom' ); + if ( $default_flag && _wp_array_get( $settings, $default_flag, true ) ) { + array_unshift( $origins, 'default' ); + } + $presets = array(); + foreach ( $origins as $origin ) { + foreach ( $by_origin[ $origin ] ?? array() as $preset ) { + $presets[ $preset['slug'] ] = array( $preset['slug'], (string) ( is_array( $preset[ $value ] ?? '' ) ? wp_json_encode( $preset[ $value ] ) : ( $preset[ $value ] ?? '' ) ) ); + } + } + return array_values( $presets ); +}; +echo wp_json_encode( array( + 'colors' => $presets( array( 'color', 'palette' ), array( 'color', 'defaultPalette' ), 'color' ), + 'fontFamilies' => $presets( array( 'typography', 'fontFamilies' ), null, 'name' ), + 'fontSizes' => $presets( array( 'typography', 'fontSizes' ), array( 'typography', 'defaultFontSizes' ), 'size' ), + 'spacingSizes' => $presets( array( 'spacing', 'spacingSizes' ), array( 'spacing', 'defaultSpacingSizes' ), 'size' ), + 'layout' => array_filter( array( + 'contentSize' => $settings['layout']['contentSize'] ?? null, + 'wideSize' => $settings['layout']['wideSize'] ?? null, + ) ), +) ); +`; + +const resolved = new Map< string, { inputs: string; lines: Promise< string[] > } >(); + +async function filesVersion( dir: string ): Promise< string > { + const entries = await readdir( dir, { recursive: true, withFileTypes: true } ).catch( () => [] ); + const versions = await Promise.all( + entries + .filter( ( entry ) => entry.isFile() && /\.json$/.test( entry.name ) ) + .map( async ( entry ) => { + const file = path.join( entry.parentPath, entry.name ); + const { mtimeMs, size } = await stat( file ); + return `${ path.relative( dir, file ) }:${ mtimeMs }:${ size }`; + } ) + ); + return versions.sort().join( ',' ); +} + +// What WordPress resolves the presets from, as one version. +async function inputsOf( site: WordPressSite, themes: string[] ): Promise< string > { + const userStyles = site.query< { modified: string; size: number } >( + `SELECT post_modified_gmt AS modified, LENGTH( post_content ) AS size FROM ${ site.prefix }posts WHERE post_type = 'wp_global_styles' ORDER BY ID` + ); + const themeFiles = await Promise.all( + themes.map( ( theme ) => filesVersion( path.join( site.root, 'wp-content', 'themes', theme ) ) ) + ); + const wordpress = await stat( path.join( site.root, 'wp-includes', 'version.php' ) ).catch( + () => undefined + ); + return JSON.stringify( [ + themes, + themeFiles, + userStyles, + site.option( 'active_plugins' ), + wordpress?.mtimeMs, + ] ); +} + +async function resolve( root: string ): Promise< Presets > { + const site = await resolveSite( root ); + try { + await connectToDaemon(); + await using command = await runWpCliCommand( site, [ 'eval', RESOLVE ] ); + const [ output, exitCode ] = await Promise.all( [ + command.response.stdoutText, + command.response.exitCode, + ] ); + if ( exitCode !== 0 ) { + throw new Error( `wp eval exited ${ exitCode }` ); + } + return JSON.parse( output.slice( output.indexOf( '{' ) ) ) as Presets; + } finally { + await disconnectFromDaemon(); + } +} + +// The presets as fact lines, one per kind of preset the site has. +function describePresets( presets: Presets ): string[] { + const list = ( name: string, items: Array< [ string, string ] >, quoted = false ) => + items.length + ? `${ name } presets (wp_get_global_settings()): ${ items + .map( ( [ slug, value ] ) => `${ slug } ${ quoted ? JSON.stringify( value ) : value }` ) + .join( ', ' ) }.` + : undefined; + const { contentSize, wideSize } = presets.layout; + return [ + list( 'Color', presets.colors ), + list( 'Font family', presets.fontFamilies, true ), + list( 'Font size', presets.fontSizes ), + list( 'Spacing size', presets.spacingSizes ), + contentSize || wideSize + ? `Layout (wp_get_global_settings()): content width ${ contentSize ?? 'none' }, wide width ${ + wideSize ?? 'none' + }.` + : undefined, + ].filter( ( line ): line is string => Boolean( line ) ); +} + +/** The active theme's presets, as WordPress resolves them, as site facts. */ +export async function themePresetsFact( site: WordPressSite ): Promise< string[] > { + const themes = [ + ...new Set( [ site.option( 'stylesheet' ), site.option( 'template' ) ] ), + ].filter( ( theme ): theme is string => typeof theme === 'string' && theme !== '' ); + if ( ! themes.length ) { + return []; + } + const inputs = await inputsOf( site, themes ); + let entry = resolved.get( site.root ); + if ( entry?.inputs !== inputs ) { + entry = { + inputs, + lines: resolve( site.root ).then( describePresets ), + }; + resolved.set( site.root, entry ); + // A site WordPress could not run for states no presets, and is asked again next time. + entry.lines.catch( () => resolved.delete( site.root ) ); + } + return entry.lines.catch( () => [] ); +} diff --git a/apps/cli/ai/tools/create-site.ts b/apps/cli/ai/tools/create-site.ts index e6c822d25d..bb06151bfc 100644 --- a/apps/cli/ai/tools/create-site.ts +++ b/apps/cli/ai/tools/create-site.ts @@ -88,5 +88,6 @@ export const createSiteTool = defineTool( }, { annotations: LOCAL_CHANGE, + siteParam: 'name', } ); diff --git a/apps/cli/ai/tools/define-tool.ts b/apps/cli/ai/tools/define-tool.ts index 42789da034..f3c883454a 100644 --- a/apps/cli/ai/tools/define-tool.ts +++ b/apps/cli/ai/tools/define-tool.ts @@ -115,6 +115,9 @@ export interface ToolOptions { // The tool renders the site, so it runs only once the pending work of // earlier tools, such as images still being generated, has settled. settlesPendingWork?: boolean; + // The argument that names the local site a call is about, when it is not + // `nameOrPath`: notes on the result, such as the site's facts, are about it. + siteParam?: string; } export type StudioAgentTool< TProps extends TProperties = TProperties > = AgentTool< @@ -146,6 +149,7 @@ export interface AnyStudioAgentTool { settlesPendingWork?: boolean; annotations: ToolAnnotations; forHost?: ( host: StudioToolHost ) => AnyStudioAgentTool | undefined; + siteParam?: string; } export function defineTool< TProps extends TProperties >( @@ -164,6 +168,7 @@ export function defineTool< TProps extends TProperties >( label: name, ...( options.settlesPendingWork ? { settlesPendingWork: true } : {} ), ...( options.forHost ? { forHost: options.forHost } : {} ), + ...( options.siteParam ? { siteParam: options.siteParam } : {} ), annotations: options.annotations, rawHandler: ( args, context ) => handler( args, context ?? NOOP_TOOL_CONTEXT ), execute: async ( _toolCallId, params, _signal, onUpdate ) => { diff --git a/apps/cli/ai/tools/utils.ts b/apps/cli/ai/tools/utils.ts index f11497059f..f204352e2d 100644 --- a/apps/cli/ai/tools/utils.ts +++ b/apps/cli/ai/tools/utils.ts @@ -3,6 +3,7 @@ import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; import { Logger, type ProgressCallback } from 'cli/logger'; +import type { AnyStudioAgentTool } from './define-tool'; async function findSiteByName( name: string ): Promise< SiteData | undefined > { const config = await readCliConfig(); @@ -35,6 +36,13 @@ export async function resolveSite( nameOrPath: string ): Promise< SiteData > { return getSiteByFolder( nameOrPath ); } +/** The local site a call names, by the tool's site argument; undefined when it names none. */ +export async function siteOfCall( tool: object, args: unknown ): Promise< SiteData | undefined > { + const { siteParam = 'nameOrPath' } = tool as Pick< AnyStudioAgentTool, 'siteParam' >; + const named = ( args as Record< string, unknown > | undefined )?.[ siteParam ]; + return typeof named === 'string' ? resolveSite( named ).catch( () => undefined ) : undefined; +} + // Callers connect to the daemon first, like runWpCliCommand requires. export async function runWpCli( site: SiteData, args: string[] ): Promise< string > { await using command = await runWpCliCommand( site, args ); From 6fb6ecf10392917a57e5c5bbed9a5108e94b19fb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:20:43 -0400 Subject: [PATCH 058/199] Remove the plugin's hooks on Claude Code's and Codex's file tools Claude Code and Codex use Studio through studio mcp, which reports on its tools' results. The hooks on their own file tools, the hidden _file-tool-hook command, and the open problems state kept between hook processes go. --- apps/cli/ai/open-problems.ts | 9 +- apps/cli/ai/runtimes/hooks/index.ts | 150 --------- .../hooks/tests/file-tool-hook.test.ts | 309 ------------------ apps/cli/ai/site-notes.ts | 5 +- apps/cli/ai/tests/open-problems.test.ts | 5 +- apps/cli/index.ts | 18 - plugins/wp-studio/.codex-plugin/plugin.json | 1 - plugins/wp-studio/README.md | 8 - plugins/wp-studio/hooks/hooks.json | 43 --- plugins/wp-studio/scripts/bootstrap-mcp.mjs | 35 -- plugins/wp-studio/scripts/launch-mcp | 6 +- plugins/wp-studio/scripts/launch-mcp.cmd | 6 +- 12 files changed, 11 insertions(+), 584 deletions(-) delete mode 100644 apps/cli/ai/runtimes/hooks/index.ts delete mode 100644 apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts delete mode 100644 plugins/wp-studio/hooks/hooks.json diff --git a/apps/cli/ai/open-problems.ts b/apps/cli/ai/open-problems.ts index 3a7648eca3..ff1df0ebc7 100644 --- a/apps/cli/ai/open-problems.ts +++ b/apps/cli/ai/open-problems.ts @@ -18,7 +18,7 @@ import { readSavedCopies } from 'cli/ai/saved-copies'; import { readWordPressSite } from 'cli/ai/site-database'; import { studioMessage } from 'cli/ai/studio-message'; -export interface OpenProblemsState { +interface OpenProblemsState { // The sites changed, whose state is checked at the end of the turn. sites: string[]; // The latest problem of each file or post validated, while it has one. @@ -27,8 +27,9 @@ export interface OpenProblemsState { } export class OpenProblems { + private readonly state: OpenProblemsState = { sites: [], open: {}, raised: [] }; + constructor( - private readonly state: OpenProblemsState = { sites: [], open: {}, raised: [] }, private readonly siteChecks: Array< ( sitePath: string ) => Promise< string | undefined > > = [ unrenderedFrontPage, ] @@ -59,10 +60,6 @@ export class OpenProblems { this.state.raised.push( ...problems ); return problems; } - - toJSON(): OpenProblemsState { - return this.state; - } } /** diff --git a/apps/cli/ai/runtimes/hooks/index.ts b/apps/cli/ai/runtimes/hooks/index.ts deleted file mode 100644 index b645625ac5..0000000000 --- a/apps/cli/ai/runtimes/hooks/index.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Hooks on other agents' file tools, for agents that run Studio's tools over - * MCP and write files with their own, such as Claude Code and Codex with the - * WordPress Studio plugin. They fire the same file triggers as Studio Code's - * Write and Edit (site-notes.ts), and report the block validation Studio Code - * runs before the markup reaches the site. - * - * The input and output are the hook JSON both agents use: tool_input names - * the file (file_path) or carries a patch (apply_patch), and - * hookSpecificOutput denies the call or adds context to its result. - * - * On a shell call they also report the content the call changed in the sites' - * databases and the files of themes WordPress updates (site-changes.ts). When the agent stops, the Stop hook - * continues its turn with the problems still open (open-problems.ts). Each - * hook is its own process, so the session's snapshots and open problems are - * kept in a file between hooks. - */ -import { createHash } from 'crypto'; -import { readFile, writeFile } from 'fs/promises'; -import os from 'os'; -import path from 'path'; -import { BlockValidationGate } from 'cli/ai/block-validation-gate'; -import { describeOpenProblems, OpenProblems, type OpenProblemsState } from 'cli/ai/open-problems'; -import { SiteChanges, type Versions } from 'cli/ai/site-changes'; -import { createSiteNotes } from 'cli/ai/site-notes'; -import { studioMessage, validationLines } from 'cli/ai/studio-message'; - -export interface HookInput { - hook_event_name?: string; - tool_name?: string; - tool_input?: Record< string, unknown >; - cwd?: string; - session_id?: string; -} - -// A tool hook's output, or a Stop hook's decision to continue the turn. -interface HookOutput { - hookSpecificOutput?: - | { - hookEventName: 'PreToolUse'; - permissionDecision: 'deny'; - permissionDecisionReason: string; - } - | { hookEventName: 'PostToolUse'; additionalContext: string }; - decision?: 'block'; - reason?: string; -} - -const PATCH_FILE = /^\*\*\* (?:Add File|Update File|Move to): (.+)$/gm; - -/** The files a file tool call writes, as absolute paths. */ -export function getWrittenFiles( { tool_input: toolInput = {}, cwd = '' }: HookInput ): string[] { - if ( typeof toolInput.file_path === 'string' ) { - return [ path.resolve( cwd, toolInput.file_path ) ]; - } - const patch = [ toolInput.command, toolInput.patch, toolInput.input ].find( - ( value ): value is string => typeof value === 'string' - ); - return [ ...( patch ?? '' ).matchAll( PATCH_FILE ) ].map( ( match ) => - path.resolve( cwd, match[ 1 ].trim() ) - ); -} - -interface SessionState { - snapshots: Record< string, Versions >; - open?: OpenProblemsState; -} - -function stateFile( sessionId: string ): string { - const key = createHash( 'sha1' ).update( sessionId ).digest( 'hex' ).slice( 0, 16 ); - return path.join( os.tmpdir(), `studio-hook-session-${ key }.json` ); -} - -// The session's checks, with the state its last hook kept; without a session, -// a call's own. -async function withSession< T >( - sessionId: string | undefined, - run: ( checks: { siteChanges: SiteChanges; open: OpenProblems } ) => Promise< T > -): Promise< T > { - const file = sessionId ? stateFile( sessionId ) : undefined; - const kept: Partial< SessionState > = file - ? await readFile( file, 'utf8' ) - .then( ( text ) => JSON.parse( text ) ) - .catch( () => ( {} ) ) - : {}; - const snapshots = new Map( Object.entries( kept.snapshots ?? {} ) ); - const open = new OpenProblems( kept.open ); - const result = await run( { siteChanges: new SiteChanges( snapshots, open ), open } ); - if ( file ) { - const state: SessionState = { snapshots: Object.fromEntries( snapshots ), open: open.toJSON() }; - await writeFile( file, JSON.stringify( state ), 'utf8' ); - } - return result; -} - -export async function runFileToolHook( - input: HookInput, - makeGate = ( open?: OpenProblems ) => new BlockValidationGate( undefined, open ) -): Promise< HookOutput | undefined > { - if ( input.hook_event_name === 'Stop' ) { - const problems = await withSession( input.session_id, ( { open } ) => open.raise() ).catch( - () => [] - ); - return problems.length - ? { decision: 'block', reason: describeOpenProblems( problems ) } - : undefined; - } - const files = getWrittenFiles( input ); - if ( input.hook_event_name === 'PreToolUse' ) { - const siteNotes = createSiteNotes( { gate: makeGate() } ); - await withSession( input.session_id, ( { siteChanges } ) => siteChanges.snapshot() ).catch( - () => undefined - ); - for ( const file of files ) { - const reason = await siteNotes.beforeWrite( file ); - if ( reason ) { - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: reason, - }, - }; - } - } - return undefined; - } - if ( input.hook_event_name !== 'PostToolUse' ) { - return undefined; - } - const checked = await withSession( input.session_id, async ( { siteChanges, open } ) => { - const gate = makeGate( open ); - const siteNotes = createSiteNotes( { gate } ); - const notes: string[] = []; - for ( const file of files ) { - notes.push( ...( await siteNotes.afterWrite( file ) ) ); - } - return { notes, files: await gate.settle(), content: await siteChanges.settle() }; - } ).catch( () => undefined ); - const context = - checked && - studioMessage( 'notes', [ - ...validationLines( checked.files ), - ...validationLines( checked.content ), - ...checked.notes, - ] ); - return context - ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } } - : undefined; -} diff --git a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts b/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts deleted file mode 100644 index 51ac07295f..0000000000 --- a/apps/cli/ai/runtimes/hooks/tests/file-tool-hook.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import path from 'path'; -import { arePathsEqual } from '@studio/common/lib/fs-utils'; -import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -import { BlockValidationGate } from 'cli/ai/block-validation-gate'; -import { getWrittenFiles, runFileToolHook } from 'cli/ai/runtimes/hooks'; -import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; -import { readCliConfig } from 'cli/lib/cli-config/core'; -import { findSiteByFolder } from 'cli/lib/cli-config/sites'; - -vi.mock( 'cli/lib/cli-config/sites', () => ( { findSiteByFolder: vi.fn(), getSiteUrl: vi.fn() } ) ); -vi.mock( 'cli/lib/cli-config/core', () => ( { readCliConfig: vi.fn() } ) ); - -let site: ReturnType< typeof createSite >; -beforeEach( () => { - site = createSite(); - vi.mocked( findSiteByFolder ).mockImplementation( async ( folder ) => - arePathsEqual( folder, site.root ) - ? ( { id: 'site', name: 'Bakery', path: site.root } as Awaited< - ReturnType< typeof findSiteByFolder > - > ) - : undefined - ); - vi.mocked( readCliConfig ).mockResolvedValue( { - sites: [ { id: 'site', name: 'Bakery', path: site.root } ], - } as Awaited< ReturnType< typeof readCliConfig > > ); -} ); - -// Claude Code's Write and Edit name the file; Codex's apply_patch carries a patch. -it.each( [ - [ 'Write', { file_path: '/site/wp-content/themes/a/style.css', content: '' }, [ 'style.css' ] ], - [ - 'Edit', - { file_path: 'parts/header.html', old_string: '', new_string: '' }, - [ 'parts/header.html' ], - ], - [ - 'apply_patch', - { - command: - '*** Begin Patch\n*** Add File: parts/footer.html\n+x\n*** Update File: style.css\n*** Move to: theme.css\n@@\n*** Delete File: old.css\n*** End Patch', - }, - [ 'parts/footer.html', 'style.css', 'theme.css' ], - ], -] )( 'finds the files %s writes', ( toolName, toolInput, files ) => { - const cwd = '/site/wp-content/themes/a'; - expect( - getWrittenFiles( { tool_name: toolName, tool_input: toolInput, cwd } ).map( ( file ) => - path.relative( cwd, file ).split( path.sep ).join( '/' ) - ) - ).toEqual( files ); -} ); - -it( 'refuses a write to a file a WordPress or theme update replaces, naming where it belongs', async () => { - const pre = ( file: string ) => - runFileToolHook( { - hook_event_name: 'PreToolUse', - tool_name: 'Edit', - tool_input: { file_path: path.join( site.root, file ) }, - } ); - expect( await pre( 'wp-includes/version.php' ) ).toEqual( { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: expect.stringContaining( 'Make the change in wp-content/' ), - }, - } ); - expect( - ( await pre( 'wp-content/themes/bakery-parent/style.css' ) )?.hookSpecificOutput - ).toMatchObject( { permissionDecisionReason: expect.stringContaining( 'child theme' ) } ); - expect( await pre( 'wp-content/themes/bakery/style.css' ) ).toBeUndefined(); -} ); - -it( 'adds to a write the block validation and what WordPress renders instead of the file', async () => { - site.save( 14, 'wp_template_part', 'header' ); - const header = path.join( site.root, 'wp-content/themes/bakery/parts/header.html' ); - mkdirSync( path.dirname( header ), { recursive: true } ); - writeFileSync( header, '

Hi

' ); - const gate = new BlockValidationGate( async () => ( { - contentHash: 'x', - summary: 'parts/header.html: 1/1 blocks valid', - } ) ); - const post = ( file: string ) => - runFileToolHook( - { hook_event_name: 'PostToolUse', tool_name: 'Write', tool_input: { file_path: file } }, - () => gate - ); - - expect( await post( header ) ).toEqual( { - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: [ - '', - 'Block validation: parts/header.html: 1/1 blocks valid', - 'WordPress renders the template part header from its copy saved in the Site Editor (post 14), not from this file.', - '', - ].join( '\n' ), - }, - } ); - expect( await post( '/elsewhere/src/index.ts' ) ).toBeUndefined(); -} ); - -afterEach( () => vi.unstubAllGlobals() ); - -// A theme.json font family without font files gets them from Google Fonts, in -// the weights the theme uses. -const FACE = { fontFamily: 'Inter', src: [ 'file:./assets/fonts/inter.woff2' ] }; -it.each( [ - { - theme: 'bakery', - family: 'Inter', - fontFace: undefined, - note: 'Studio downloaded the font files of Inter from Google Fonts into wp-content/themes/bakery/assets/fonts and declared them as their fontFace in theme.json.', - }, - { - theme: 'bakery', - family: 'Nope Sans', - fontFace: undefined, - note: 'Nope Sans has no font files in wp-content/themes/bakery/theme.json, and Google Fonts did not provide them (https://fonts.googleapis.com/css2?family=Nope+Sans:wght@400;600&display=swap responded with HTTP 400). Put its files in wp-content/themes/bakery/assets/fonts and declare them as its fontFace, or choose a Google Fonts family.', - }, - { theme: 'bakery', family: 'Inter', fontFace: [ FACE ], note: undefined }, -] )( - 'self-hosts $family in $theme after a theme.json write, with font files: $fontFace', - async ( { theme, family, fontFace, note } ) => { - const fetch = vi.fn( async ( url: string ) => - url.includes( 'Nope' ) - ? new Response( '', { status: 400 } ) - : url.startsWith( 'https://fonts.googleapis.com/' ) - ? new Response( - "/* latin */\n@font-face {\n font-family: 'Inter';\n font-style: normal;\n font-weight: 400;\n src: url(https://fonts.gstatic.com/inter.woff2) format('woff2');\n}\n" - ) - : new Response( new Uint8Array( [ 1 ] ) ) - ); - vi.stubGlobal( 'fetch', fetch ); - const themeDir = path.join( site.root, 'wp-content/themes', theme ); - const start = { - version: 3, - settings: { - typography: { - fontFamilies: [ - { slug: 'body', name: family, fontFamily: `"${ family }", sans-serif`, fontFace }, - ], - }, - }, - styles: { - typography: { fontFamily: 'var:preset|font-family|body' }, - elements: { h1: { typography: { fontWeight: '600' } } }, - }, - }; - writeFileSync( path.join( themeDir, 'theme.json' ), JSON.stringify( start ) ); - - const output = await runFileToolHook( { - hook_event_name: 'PostToolUse', - tool_name: 'Write', - tool_input: { file_path: path.join( themeDir, 'theme.json' ) }, - } ); - - const themeJson = JSON.parse( readFileSync( path.join( themeDir, 'theme.json' ), 'utf8' ) ); - const hosted = note?.startsWith( 'Studio downloaded' ); - expect( themeJson.settings.typography.fontFamilies[ 0 ].fontFace ).toEqual( - hosted - ? [ - { - fontFamily: 'Inter', - fontStyle: 'normal', - fontWeight: '400', - src: [ 'file:./assets/fonts/inter/inter-400-latin.woff2' ], - }, - ] - : fontFace - ); - expect( existsSync( path.join( themeDir, 'assets/fonts/inter' ) ) ).toBe( !! hosted ); - if ( hosted ) { - expect( fetch ).toHaveBeenCalledWith( - 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap', - expect.anything() - ); - } - expect( output?.hookSpecificOutput ).toEqual( - note && { - hookEventName: 'PostToolUse', - additionalContext: `\n${ note }\n`, - } - ); - } -); - -// A theme.json that names a palette color after a class WordPress already -// gives blocks gets a note; a theme without a thumbnail gets its design board. -it.each( [ - { slug: 'text', screenshot: undefined, note: true, thumbnail: true }, - { slug: 'ink', screenshot: undefined, note: false, thumbnail: true }, - { slug: 'ink', screenshot: 'screenshot.jpg', note: false, thumbnail: false }, -] )( - 'after a theme.json write with palette $slug and $screenshot, notes the clash: $note, saves a thumbnail: $thumbnail', - async ( { slug, screenshot, note, thumbnail } ) => { - const themeDir = path.join( site.root, 'wp-content/themes/bakery' ); - if ( screenshot ) { - writeFileSync( path.join( themeDir, screenshot ), '' ); - } - writeFileSync( - path.join( themeDir, 'theme.json' ), - JSON.stringify( { - version: 3, - settings: { - color: { - palette: [ - { slug: 'paper', color: '#ffffff' }, - { slug, color: '#111111' }, - ], - }, - }, - styles: { typography: { fontFamily: 'Georgia, serif' } }, - } ) - ); - - const output = await runFileToolHook( { - hook_event_name: 'PostToolUse', - tool_name: 'Write', - tool_input: { file_path: path.join( themeDir, 'theme.json' ) }, - } ); - - const hook = output?.hookSpecificOutput; - const context = hook && 'additionalContext' in hook ? hook.additionalContext : ''; - expect( context.includes( 'names a palette color `text`' ) ).toBe( note ); - expect( context.includes( 'screenshot.png' ) ).toBe( thumbnail ); - const png = path.join( themeDir, 'screenshot.png' ); - expect( existsSync( png ) ).toBe( thumbnail ); - if ( thumbnail ) { - // The PNG header: width and height are big-endian at bytes 16 and 20. - const header = readFileSync( png ); - expect( [ header.readUInt32BE( 16 ), header.readUInt32BE( 20 ) ] ).toEqual( [ 1200, 900 ] ); - } - }, - 60_000 -); - -// An image shown from a host other than the site's, in block markup or theme CSS. -it.each( [ - { - file: 'templates/index.html', - content: - '
', - host: 'images.example.com', - }, - { - file: 'templates/index.html', - content: '', - host: undefined, - }, - { - file: 'style.css', - content: - '/*\nTheme Name: Bakery\n*/\n.hero { background: #000 url("https://cdn.example.org/b.jpg"); }', - host: 'cdn.example.org', - }, - { - file: 'style.css', - content: - '/*\nTheme Name: Bakery\n*/\n@font-face { src: url(https://fonts.example.com/a.woff2); }', - host: undefined, - }, -] )( 'notes images in $file from $host', async ( { file, content, host } ) => { - site.setOptions( { siteurl: 'http://localhost:8881' } ); - const written = path.join( site.root, 'wp-content/themes/bakery', file ); - mkdirSync( path.dirname( written ), { recursive: true } ); - writeFileSync( written, content ); - const output = await runFileToolHook( { - hook_event_name: 'PostToolUse', - tool_name: 'Write', - tool_input: { file_path: written }, - } ); - const notes = JSON.stringify( output ?? {} ); - expect( notes.includes( 'which this site does not hold' ) ).toBe( Boolean( host ) ); - expect( - notes.includes( `wp-content/themes/bakery/${ file } shows images from ${ host },` ) - ).toBe( Boolean( host ) ); -} ); - -// A shell call can save post content by any route; the hook pair reports what -// it changed, keeping PreToolUse's snapshot for PostToolUse. -it( 'reports the block validation of post content a shell call changed', async () => { - site.save( 9, 'page', 'home', '\n

Hi

\n' ); - const hook = ( event: string ) => - runFileToolHook( { - hook_event_name: event, - tool_name: 'Bash', - tool_input: { command: 'wp post update 9 tmp/home.txt' }, - session_id: `session-${ site.root }`, - } ); - await hook( 'PreToolUse' ); - site.edit( 9, '\n

Hi

\n' ); - expect( await hook( 'PostToolUse' ) ).toEqual( { - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: expect.stringContaining( - 'Bakery page 9 "": 1 core/html block(s) must become editable blocks' - ), - }, - } ); - // Stopping continues the turn with the problem still open, once. - expect( await hook( 'Stop' ) ).toEqual( { - decision: 'block', - reason: expect.stringMatching( - /^\nStudio checked the sites this turn changed:[^]*1 core\/html block\(s\) must become editable blocks/ - ), - } ); - expect( await hook( 'Stop' ) ).toBeUndefined(); -} ); diff --git a/apps/cli/ai/site-notes.ts b/apps/cli/ai/site-notes.ts index 3e16722860..a66848b221 100644 --- a/apps/cli/ai/site-notes.ts +++ b/apps/cli/ai/site-notes.ts @@ -12,9 +12,8 @@ * - `result`: one of Studio's tools returned a result about a site. * * A new note is one more source in SOURCES, and reaches every surface that - * fires its trigger: Studio Code fires all three; `studio mcp` fires `result`; - * the WordPress Studio plugin's hooks on Claude Code's and Codex's own file - * tools fire `beforeWrite` and `write`. + * fires its trigger: Studio Code fires all three; `studio mcp`, which Claude + * Code and Codex use, fires `result`. */ import path from 'path'; import { createBlockSettingsNotes } from 'cli/ai/block-settings'; diff --git a/apps/cli/ai/tests/open-problems.test.ts b/apps/cli/ai/tests/open-problems.test.ts index 358a465cd5..1bfdc19c0a 100644 --- a/apps/cli/ai/tests/open-problems.test.ts +++ b/apps/cli/ai/tests/open-problems.test.ts @@ -4,7 +4,7 @@ import { describeOpenProblems, OpenProblems } from 'cli/ai/open-problems'; describe( 'OpenProblems', () => { it( 'raises each problem still open once, and the problems of the sites changed', async () => { const checked: string[] = []; - const open = new OpenProblems( undefined, [ + const open = new OpenProblems( [ async ( site ) => { checked.push( site ); return `${ site } has a problem`; @@ -19,8 +19,7 @@ describe( 'OpenProblems', () => { expect( await open.raise() ).toEqual( [] ); open.record( '/a', '/a/page-1', 'page 1 is still invalid' ); - const kept = new OpenProblems( open.toJSON(), [] ); - expect( await kept.raise() ).toEqual( [ 'page 1 is still invalid' ] ); + expect( await open.raise() ).toEqual( [ 'page 1 is still invalid' ] ); } ); it( "tags the problems as Studio's checks", () => { diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 89bf76f741..ae7f73f870 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -311,24 +311,6 @@ async function main() { sitesYargs.version( false ).demandCommand( 1, __( 'You must provide a valid command' ) ); } ) - .command( { - // The WordPress Studio plugin's hooks on the agent's own tools and its stop. - command: '_file-tool-hook', - describe: false, // Hidden command - handler: async () => { - const { runFileToolHook } = await import( 'cli/ai/runtimes/hooks' ); - const { closeSharedBrowser } = await import( 'cli/ai/browser-utils' ); - let input = ''; - for await ( const chunk of process.stdin ) { - input += chunk; - } - const output = await runFileToolHook( JSON.parse( input ) ); - await closeSharedBrowser(); - if ( output ) { - process.stdout.write( JSON.stringify( output ) ); - } - }, - } ) .command( { command: '_events', describe: false, // Hidden command diff --git a/plugins/wp-studio/.codex-plugin/plugin.json b/plugins/wp-studio/.codex-plugin/plugin.json index e441580cf2..13a35ff4e9 100644 --- a/plugins/wp-studio/.codex-plugin/plugin.json +++ b/plugins/wp-studio/.codex-plugin/plugin.json @@ -25,7 +25,6 @@ ], "skills": "./skills/", "mcpServers": "./.mcp.json", - "hooks": "./hooks/hooks.json", "interface": { "displayName": "WordPress Studio", "shortDescription": "Build & manage WordPress sites", diff --git a/plugins/wp-studio/README.md b/plugins/wp-studio/README.md index 3740207170..b32ba88e38 100644 --- a/plugins/wp-studio/README.md +++ b/plugins/wp-studio/README.md @@ -37,14 +37,6 @@ In the ChatGPT app, open **WordPress** from the sidebar to see your local Studio site has its own page: start or stop it, open WP Admin, run a speed or SEO audit, pull a live site into Studio, or log in to WordPress.com. -## Checks on file edits - -When the agent edits a Studio site's files with its own file tools, the plugin's hooks run the same -checks as Studio Code: they refuse edits to WordPress core and to themes WordPress updates, which an -update would overwrite, and after an edit they report block validation and what WordPress renders -instead of the file, such as a template saved in the Site Editor. Edits outside a WordPress site -don't start Studio. - ## WordPress.com sites The plugin also lists WordPress.com's own MCP server (`wordpress-com`), so the agent can read and diff --git a/plugins/wp-studio/hooks/hooks.json b/plugins/wp-studio/hooks/hooks.json deleted file mode 100644 index ff21590e04..0000000000 --- a/plugins/wp-studio/hooks/hooks.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "description": "Studio's checks on writes to a WordPress site's files with the agent's own file tools: refuses files a WordPress or theme update replaces, and reports block validation and what WordPress renders differently from the file; on a shell call, reports block validation of the post content the call changed; when the agent stops, continues its turn with the problems still open on the sites.", - "hooks": { - "PreToolUse": [ - { - "matcher": "Write|Edit|MultiEdit|apply_patch|Bash", - "hooks": [ - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/launch-mcp\" hook", - "commandWindows": "\"${CLAUDE_PLUGIN_ROOT}\\scripts\\launch-mcp.cmd\" hook", - "timeout": 60 - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Write|Edit|MultiEdit|apply_patch|Bash", - "hooks": [ - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/launch-mcp\" hook", - "commandWindows": "\"${CLAUDE_PLUGIN_ROOT}\\scripts\\launch-mcp.cmd\" hook", - "timeout": 120 - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/launch-mcp\" hook", - "commandWindows": "\"${CLAUDE_PLUGIN_ROOT}\\scripts\\launch-mcp.cmd\" hook", - "timeout": 120 - } - ] - } - ] - } -} diff --git a/plugins/wp-studio/scripts/bootstrap-mcp.mjs b/plugins/wp-studio/scripts/bootstrap-mcp.mjs index 694d087cf4..e8482556b4 100644 --- a/plugins/wp-studio/scripts/bootstrap-mcp.mjs +++ b/plugins/wp-studio/scripts/bootstrap-mcp.mjs @@ -3,9 +3,6 @@ // agent app removes it with this plugin version), or from an installed Studio // CLI at least as recent, such as the desktop app's. // -// With `hook`, it runs the plugin's hooks on the agent's own file tools -// (hooks/hooks.json) with the same Studio CLI: `studio _file-tool-hook`. -// // stdout carries the MCP messages: anything else must go to stderr. import { execFile, spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; @@ -312,38 +309,6 @@ async function standIn() { return child; } -// A hook starts the Studio CLI only for a call that may write a WordPress -// install's files; the CLI decides. Without a CLI yet, the call goes ahead. -const MAY_TOUCH_WORDPRESS = /wp-content[\\/]|wp-includes[\\/]|wp-admin[\\/]|\.php\b/; -async function runHook() { - let input = ''; - for await ( const chunk of process.stdin ) { - input += chunk; - } - const { tool_input: toolInput, cwd } = JSON.parse( input ); - if ( ! MAY_TOUCH_WORDPRESS.test( JSON.stringify( [ toolInput, cwd ] ) ) ) { - return; - } - const studio = - ( await findStudio() ) ?? - ( existsSync( BUNDLED_MAIN ) ? [ process.execPath, BUNDLED_MAIN ] : null ); - if ( ! studio ) { - return; - } - const [ command, ...args ] = studio; - const hook = spawn( - ...commandArgs( command, [ ...args, '_file-tool-hook' ], { - stdio: [ 'pipe', 'inherit', 'inherit' ], - } ) - ); - hook.stdin.end( input ); - await once( hook, 'exit' ); -} -if ( process.argv[ 2 ] === 'hook' ) { - await runHook(); - process.exit( 0 ); -} - let child; const studio = await findStudio(); if ( studio ) { diff --git a/plugins/wp-studio/scripts/launch-mcp b/plugins/wp-studio/scripts/launch-mcp index e4e02f37e5..446fbca527 100755 --- a/plugins/wp-studio/scripts/launch-mcp +++ b/plugins/wp-studio/scripts/launch-mcp @@ -3,8 +3,7 @@ # Studio or the ChatGPT app, recent enough to run the Studio CLI the plugin # carries, then looks past the user's shell PATH, which desktop agents often # start plugin servers without. Without a Node.js, it runs an installed Studio -# CLI directly. With `hook`, it runs the plugin's hooks on the agent's own file -# tools (hooks/hooks.json), which need a Node.js. +# CLI directly. # # stdout carries the MCP messages: anything else must go to stderr. @@ -24,9 +23,8 @@ find_node() { NODE="$(find_node)" if [ -n "$NODE" ] && [ -x "$NODE" ]; then - exec "$NODE" "$(dirname "$0")/bootstrap-mcp.mjs" "$@" + exec "$NODE" "$(dirname "$0")/bootstrap-mcp.mjs" fi -[ "$1" = hook ] && exit 0 if command -v studio >/dev/null 2>&1; then exec studio mcp diff --git a/plugins/wp-studio/scripts/launch-mcp.cmd b/plugins/wp-studio/scripts/launch-mcp.cmd index 8b5de409eb..75e9be0835 100644 --- a/plugins/wp-studio/scripts/launch-mcp.cmd +++ b/plugins/wp-studio/scripts/launch-mcp.cmd @@ -3,8 +3,7 @@ rem launch-mcp for Windows, where Codex runs this file for ./scripts/launch-mcp. rem It starts bootstrap-mcp.mjs with the Node.js the ChatGPT app gives plugins, rem or the one of its Codex runtime, recent enough to run the Studio CLI the rem plugin carries, then with the one on PATH. Without a Node.js, it runs an -rem installed Studio CLI directly. With `hook`, it runs the plugin's hooks on the -rem agent's own file tools (hooks/hooks.json), which need a Node.js. +rem installed Studio CLI directly. rem rem stdout carries the MCP messages: anything else must go to stderr. setlocal @@ -15,10 +14,9 @@ if not defined NODE if exist "%CODEX_MCP_NODE_PATH%" set "NODE=%CODEX_MCP_NODE_P if not defined NODE if exist "%RUNTIME_NODE%" set "NODE=%RUNTIME_NODE%" if not defined NODE for /f "delims=" %%n in ('where node 2^>nul') do if not defined NODE set "NODE=%%n" if defined NODE ( - "%NODE%" "%~dp0bootstrap-mcp.mjs" %* + "%NODE%" "%~dp0bootstrap-mcp.mjs" exit /b ) -if "%~1"=="hook" exit /b 0 for /f "delims=" %%s in ('where studio 2^>nul') do ( call "%%s" mcp From 22f98d5db9d9dd6709a884e697d95693d8ef1c34 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:20:43 -0400 Subject: [PATCH 059/199] Save a block editor fix only once validation settles, and note in-page links Validation started in the background wrote its fix into the file, over an edit made while the editor ran. The gate now saves the fix when it settles, only while the file still holds the content validated. Bare #anchor links in parts and shared templates are noted rather than rewritten to /#anchor: a Back to top link points at the page it is on. --- apps/cli/ai/block-validation-gate.ts | 83 +++++++++++++------ .../ai/tests/block-validation-gate.test.ts | 39 ++++++--- apps/cli/ai/tests/site-file-guards.test.ts | 18 ++-- 3 files changed, 93 insertions(+), 47 deletions(-) diff --git a/apps/cli/ai/block-validation-gate.ts b/apps/cli/ai/block-validation-gate.ts index 6d7db6274a..d75e10ca4d 100644 --- a/apps/cli/ai/block-validation-gate.ts +++ b/apps/cli/ai/block-validation-gate.ts @@ -6,8 +6,10 @@ * site's tmp/) queues that file. Validation starts in the background at once, * and what puts content on the site or renders it (a shell command, which * runs WP-CLI, or take_screenshot) waits for it first. A fix the block editor - * can make losslessly is applied to the file silently; the model hears only - * about what it has to change itself. + * can make losslessly is applied to the file silently, once validation has + * settled and only while the file still holds the content validated, so an + * edit made meanwhile is never overwritten; the model hears only about what it + * has to change itself. */ import { createHash } from 'crypto'; import { readFile, writeFile } from 'fs/promises'; @@ -29,20 +31,18 @@ export function isBlockMarkupFile( filePath: string ): boolean { } /** - * Template parts and shared templates render on every URL that uses them, - * where a bare `#anchor` points at the current page rather than the home - * page section it names. + * The bare `#anchor` links in block markup. In a template part or shared + * template, which renders on every URL that uses it, such a link points at the + * page it is on: right for a "Back to top" link, wrong for a home page section. */ -export function makeAnchorsHomeRelative( content: string ): { content: string; count: number } { - let count = 0; - const rewrite = ( _match: string, prefix: string ) => { - count++; - return `${ prefix }/#`; - }; - const rewritten = content - .replace( /(href=")#(?=[A-Za-z])/g, rewrite ) - .replace( /("url"\s*:\s*")#(?=[A-Za-z])/g, rewrite ); - return { content: rewritten, count }; +export function inPageAnchors( content: string ): string[] { + return [ + ...new Set( + [ ...content.matchAll( /(?:href="|"url"\s*:\s*")(#[A-Za-z][^"]*)"/g ) ].map( + ( match ) => match[ 1 ] + ) + ), + ]; } function hash( content: string ): string { @@ -106,9 +106,12 @@ function describeInvalidBlocks( report: ValidationReportBase ): string[] { } interface FileValidation { + /** The hash of the content validated. */ contentHash: string; summary: string; problem?: string; + /** What the block editor would save instead, losing nothing. */ + fixedContent?: string; } export interface MarkupValidation { @@ -119,33 +122,33 @@ export interface MarkupValidation { invalidBlocks?: number; } -/** Validates one block markup file, fixing it in place when that is lossless. */ +/** Validates one block markup file, with the lossless fix for the caller to save. */ export async function validateBlockMarkupFile( filePath: string, siteUrl: string ): Promise< FileValidation > { const label = filePath.split( path.sep ).slice( -2 ).join( '/' ); - let content = await readFile( filePath, 'utf8' ); + const content = await readFile( filePath, 'utf8' ); const notes: string[] = []; - if ( SHARED_THEME_MARKUP.test( filePath ) ) { - const homeRelative = makeAnchorsHomeRelative( content ); - if ( homeRelative.count > 0 ) { - content = homeRelative.content; - await writeFile( filePath, content, 'utf8' ); - notes.push( `${ homeRelative.count } in-page link(s) made home-relative (/#anchor)` ); - } + const anchors = SHARED_THEME_MARKUP.test( filePath ) ? inPageAnchors( content ) : []; + if ( anchors.length > 0 ) { + notes.push( + `${ anchors.join( + ', ' + ) } link(s) to the page they render on, and this file renders on every page that uses it; a link to a home page section is written /#anchor` + ); } const result = await validateBlockMarkup( content, label, siteUrl, notes ); if ( result.fixedContent === undefined ) { return { contentHash: hash( content ), summary: result.summary, problem: result.problem }; } - await writeFile( filePath, result.fixedContent, 'utf8' ); notes.push( `saved through the block editor, fixing ${ result.invalidBlocks } block(s)` ); return { - contentHash: hash( result.fixedContent ), + contentHash: hash( content ), summary: `${ result.summary }; ${ notes.join( '; ' ) }`, + fixedContent: result.fixedContent, }; } @@ -260,7 +263,9 @@ export class BlockValidationGate { const summaries: string[] = []; const problems: string[] = []; for ( const [ filePath, tracked ] of this.files ) { - const result = await this.current( filePath, tracked.siteRoot ); + const result = await this.current( filePath, tracked.siteRoot ).then( + ( current ) => current && this.applyFix( filePath, current ) + ); this.open?.record( tracked.siteRoot, filePath, result?.problem ); if ( ! result ) { this.files.delete( filePath ); @@ -276,6 +281,30 @@ export class BlockValidationGate { return { summaries, problems }; } + /** + * Saves a validation's lossless fix, now that it holds the file's current + * content, and returns the validation of the fixed file. + */ + private async applyFix( + filePath: string, + result: FileValidation + ): Promise< FileValidation | undefined > { + const { fixedContent, ...validation } = result; + if ( fixedContent === undefined ) { + return result; + } + const content = await readFile( filePath, 'utf8' ).catch( () => undefined ); + if ( content === undefined || hash( content ) !== result.contentHash ) { + // Written again while it was validated: validate what it holds now. + const current = await this.current( filePath, this.files.get( filePath )!.siteRoot ); + return current && this.applyFix( filePath, current ); + } + await writeFile( filePath, fixedContent, 'utf8' ); + const fixed = { ...validation, contentHash: hash( fixedContent ) }; + this.files.get( filePath )!.result = Promise.resolve( fixed ); + return fixed; + } + /** * The validation of a file's current content, reusing the last one while * the file is unchanged; undefined once the file is gone. diff --git a/apps/cli/ai/tests/block-validation-gate.test.ts b/apps/cli/ai/tests/block-validation-gate.test.ts index 75e67ef6ae..ae28515765 100644 --- a/apps/cli/ai/tests/block-validation-gate.test.ts +++ b/apps/cli/ai/tests/block-validation-gate.test.ts @@ -69,7 +69,7 @@ describe( 'validating a file', () => { const fixed = '\n

Hello

\n'; - it( 'applies a lossless editor fix silently', async () => { + it( 'returns a lossless editor fix without writing it', async () => { const file = write( 'tmp/home.html', original ); vi.mocked( validateBlocks ).mockResolvedValue( invalidWithFix( fixed ) ); const result = await validateBlockMarkupFile( file, 'http://localhost:8881' ); @@ -77,7 +77,8 @@ describe( 'validating a file', () => { expect( result.summary ).toContain( '1/1 blocks valid; saved through the block editor, fixing 1 block(s)' ); - expect( readFileSync( file, 'utf8' ) ).toBe( fixed ); + expect( result.fixedContent ).toBe( fixed ); + expect( readFileSync( file, 'utf8' ) ).toBe( original ); } ); it( 'refuses a fix that would drop classes, and says what to change', async () => { @@ -141,11 +142,10 @@ describe( 'validating a file', () => { expect( validateBlocks ).not.toHaveBeenCalled(); } ); - it( 'makes in-page links home-relative in template parts', async () => { - const file = write( - 'wp-content/themes/t/parts/header.html', - '' - ); + it( 'notes in-page links in template parts, and leaves them as written', async () => { + const markup = + 'TopAbout'; + const file = write( 'wp-content/themes/t/parts/footer.html', markup ); vi.mocked( validateBlocks ).mockResolvedValue( { totalBlocks: 1, validBlocks: 1, @@ -153,8 +153,8 @@ describe( 'validating a file', () => { results: [], } ); const result = await validateBlockMarkupFile( file, 'http://localhost:8881' ); - expect( result.summary ).toContain( 'home-relative' ); - expect( readFileSync( file, 'utf8' ) ).toContain( '"url":"/#menu"' ); + expect( result.summary ).toContain( '#menu, #top link(s) to the page they render on' ); + expect( readFileSync( file, 'utf8' ) ).toBe( markup ); } ); } ); @@ -176,9 +176,28 @@ describe( 'the gate', () => { expect( ( await gate.settle() ).summaries ).toEqual( [] ); } ); + it( 'saves a lossless fix once settled, never over a write made while it validated', async () => { + const file = write( 'tmp/home.html', 'v1' ); + let finish: () => void = () => {}; + const validateFile = vi.fn( async ( filePath: string ) => { + const content = readFileSync( filePath, 'utf8' ); + if ( content === 'v1' ) { + await new Promise< void >( ( resolve ) => ( finish = resolve ) ); + } + return { ...ok( `validated ${ content }`, content ), fixedContent: `${ content } fixed` }; + } ); + const gate = new BlockValidationGate( validateFile ); + gate.track( file, site ); + writeFileSync( file, 'v2' ); + finish(); + expect( ( await gate.settle() ).summaries ).toEqual( [ 'validated v2' ] ); + expect( readFileSync( file, 'utf8' ) ).toBe( 'v2 fixed' ); + expect( ( await gate.settle() ).summaries ).toEqual( [] ); + } ); + it( 'keeps a file open while its latest validation has a problem', async () => { const file = write( 'wp-content/themes/t/templates/home.html', 'bad' ); - const open = new OpenProblems( undefined, [] ); + const open = new OpenProblems( [] ); const gate = new BlockValidationGate( async ( filePath ) => { const content = readFileSync( filePath, 'utf8' ); return { diff --git a/apps/cli/ai/tests/site-file-guards.test.ts b/apps/cli/ai/tests/site-file-guards.test.ts index 4f5f858a54..680753766f 100644 --- a/apps/cli/ai/tests/site-file-guards.test.ts +++ b/apps/cli/ai/tests/site-file-guards.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { makeAnchorsHomeRelative } from 'cli/ai/block-validation-gate'; +import { inPageAnchors } from 'cli/ai/block-validation-gate'; import { clearThemePatternCache, lintThemeCss, resolveSiteFile } from 'cli/ai/site-file-guards'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; @@ -76,15 +76,13 @@ describe( 'theme CSS lint', () => { } ); } ); -describe( 'home-relative anchors', () => { - it( 'rewrites bare in-page links in block markup', () => { - const { content, count } = makeAnchorsHomeRelative( - 'VisitAbout' - ); - expect( content ).toContain( '"url":"/#menu"' ); - expect( content ).toContain( 'href="/#visit"' ); - expect( content ).toContain( 'href="/about"' ); - expect( count ).toBe( 2 ); +describe( 'in-page anchors', () => { + it( 'lists the bare in-page links in block markup', () => { + expect( + inPageAnchors( + 'VisitAgainAbout' + ) + ).toEqual( [ '#menu', '#visit' ] ); } ); } ); From 1893d0d1e954341d2648b9c1cbd12f6ddfe9cff0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:21:38 -0400 Subject: [PATCH 060/199] Leave out MCP tools named like the harness's own, and skip a server that did not answer A tool from a server is named _, so a user's server could serve site_delete and take on the confirmation and site pin the harness gives that name. A tool named like the harness's own or an earlier server's is left out. A server that did not answer is left out for five minutes, so each turn does not wait for its connection timeout again. --- apps/cli/ai/runtimes/pi/index.ts | 14 ++++-- apps/cli/ai/runtimes/pi/mcp-tools.ts | 46 +++++++++++++++---- .../ai/runtimes/pi/tests/mcp-tools.test.ts | 9 ++++ 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index d69c78c8f3..871b818903 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -375,14 +375,13 @@ async function createStudioAgentSession( const chatArtifactsEnabled = typeof process.send === 'function'; const visionEnabled = aiModelSupportsImages( config.model ); const confirm = createConfirmer( config.onConfirm ?? config.onAskUser ); - const [ userInstructions, imageGenerationEnabled, activeSiteFacts, mcpTools ] = await Promise.all( - [ + const [ userInstructions, imageGenerationEnabled, activeSiteFacts, mcpServers ] = + await Promise.all( [ readGlobalInstructions(), isImageGenerationAvailable(), readActiveSiteFacts( config.activeSite ), - readMcpServers().then( ( servers ) => createMcpTools( servers, confirm ) ), - ] - ); + readMcpServers(), + ] ); const skillScope = new SkillToolScope( config.session ); let activateTools: () => void = () => {}; @@ -396,6 +395,11 @@ async function createStudioAgentSession( activateTools(); } ); + const mcpTools = await createMcpTools( + mcpServers, + confirm, + tools.map( ( tool ) => tool.name ) + ); const systemPrompt = buildSystemPrompt( { userInstructions, activeSite: config.activeSite, diff --git a/apps/cli/ai/runtimes/pi/mcp-tools.ts b/apps/cli/ai/runtimes/pi/mcp-tools.ts index 389d7df043..0168596e4a 100644 --- a/apps/cli/ai/runtimes/pi/mcp-tools.ts +++ b/apps/cli/ai/runtimes/pi/mcp-tools.ts @@ -88,12 +88,19 @@ export async function readMcpServers(): Promise< McpServerSpec[] > { } const CONNECT_TIMEOUT_MS = 15_000; +// How long a server that did not answer is left out, so a turn does not wait +// for its connection timeout again. +const RETRY_AFTER_MS = 5 * 60_000; const clients = new Map< string, Promise< Client > >(); +const failedUntil = new Map< string, number >(); + +const serverKey = ( server: McpServerSpec ) => + JSON.stringify( [ server.name, server.url, server.headers, server.command ] ); // One connection per server for the life of the process; a failed one is -// dropped so the next turn connects again. +// dropped so a later turn connects again. function connect( server: McpServerSpec ): Promise< Client > { - const key = JSON.stringify( [ server.name, server.url, server.headers, server.command ] ); + const key = serverKey( server ); let client = clients.get( key ); if ( ! client ) { client = ( async () => { @@ -165,35 +172,58 @@ export function isUserMcpTool( name: string ): boolean { return userToolNames.has( name ); } +type ListedTool = Awaited< ReturnType< Client[ 'listTools' ] > >[ 'tools' ][ number ]; + +const toolName = ( server: McpServerSpec, tool: ListedTool ) => + `${ server.name }_${ tool.name }`.replace( /[^A-Za-z0-9_-]/g, '_' ).slice( 0, 64 ); + /** * The tools of every server that answers. A server that doesn't connect is - * left out of this turn. + * left out for a while. A tool whose name is `reserved` (the harness's own + * tools) or an earlier server's is left out, so it never takes on the + * behavior the harness gives that name. */ export async function createMcpTools( servers: McpServerSpec[], - confirm: Confirmer + confirm: Confirmer, + reserved: Iterable< string > = [] ): Promise< McpTool[] > { const lists = await Promise.all( servers.map( async ( server ) => { + const key = serverKey( server ); + if ( ( failedUntil.get( key ) ?? 0 ) > Date.now() ) { + return []; + } try { const client = await connect( server ); const { tools } = await client.listTools( undefined, { timeout: CONNECT_TIMEOUT_MS } ); - return tools.map( ( tool ) => createMcpTool( server, client, tool, confirm ) ); + failedUntil.delete( key ); + return tools.map( ( tool ) => ( { server, client, tool } ) ); } catch { + failedUntil.set( key, Date.now() + RETRY_AFTER_MS ); return []; } } ) ); - return lists.flat(); + const taken = new Set( reserved ); + return lists + .flat() + .filter( ( { server, tool } ) => { + const name = toolName( server, tool ); + const free = ! taken.has( name ); + taken.add( name ); + return free; + } ) + .map( ( { server, client, tool } ) => createMcpTool( server, client, tool, confirm ) ); } function createMcpTool( server: McpServerSpec, client: Client, - tool: Awaited< ReturnType< Client[ 'listTools' ] > >[ 'tools' ][ number ], + tool: ListedTool, confirm: Confirmer ): McpTool { - const name = `${ server.name }_${ tool.name }`.replace( /[^A-Za-z0-9_-]/g, '_' ).slice( 0, 64 ); + const name = toolName( server, tool ); const parameters = structuredClone( tool.inputSchema ) as { properties?: Record< string, unknown >; required?: string[]; diff --git a/apps/cli/ai/runtimes/pi/tests/mcp-tools.test.ts b/apps/cli/ai/runtimes/pi/tests/mcp-tools.test.ts index 367d02a599..5a2669da7a 100644 --- a/apps/cli/ai/runtimes/pi/tests/mcp-tools.test.ts +++ b/apps/cli/ai/runtimes/pi/tests/mcp-tools.test.ts @@ -64,6 +64,15 @@ describe( 'MCP tools', () => { } ); } ); + it( "leaves out a tool named like the harness's own or an earlier server's", async () => { + const tools = await createMcpTools( + [ FIXTURE, { ...FIXTURE, name: 'shop' } ], + async () => undefined, + [ 'shop_write' ] + ); + expect( tools.map( ( tool ) => tool.name ) ).toEqual( [ 'shop_read' ] ); + } ); + it( "fails with the server's own error", async () => { const confirm = vi.fn( async () => undefined ); const tools = await toolsOf( confirm ); From 34a125df9a4a8ca3c9826ffd06a85d63fc90a588 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:22:05 -0400 Subject: [PATCH 061/199] Ask before WP-CLI changes a site other than the request's wp_cli names its site, and can change another site without the user's yes. It joins the tools the site pin covers; a subcommand that only reads, such as option get or post list, reads other sites freely. --- apps/cli/ai/runtimes/pi/site-pin.ts | 16 ++++++++++++++-- apps/cli/ai/runtimes/pi/tests/site-pin.test.ts | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/cli/ai/runtimes/pi/site-pin.ts b/apps/cli/ai/runtimes/pi/site-pin.ts index 8e7d4738a7..ae3068c7a3 100644 --- a/apps/cli/ai/runtimes/pi/site-pin.ts +++ b/apps/cli/ai/runtimes/pi/site-pin.ts @@ -12,8 +12,16 @@ import type { SiteInfo } from 'cli/ai/types'; // brand for that site or a second site, and only the user can say which. // Tools that change the local site named by `nameOrPath`. -const LOCAL_SITE_CHANGES = new Set( [ 'site_import', 'site_pull', 'install_taxonomy_scripts' ] ); +const LOCAL_SITE_CHANGES = new Set( [ + 'site_import', + 'site_pull', + 'install_taxonomy_scripts', + 'wp_cli', +] ); const FILE_CHANGES = new Set( [ 'Write', 'Edit' ] ); +// WP-CLI subcommands that only read, such as `option get` or `post list`. +const WP_CLI_READS = + /^\s*(?:wp\s+)?(?:[\w-]+\s+){0,2}(?:get|list|status|is-active|is-installed|exists|path|version|count|pluck)(?:\s|$)/; interface SiteTarget { key: string; @@ -43,7 +51,11 @@ async function targetOf( toolName: string, params: unknown ): Promise< SiteTarge .sort( ( a, b ) => b.path.length - a.path.length )[ 0 ]; return site ? localTarget( site.path, site.name ) : undefined; } - if ( ! LOCAL_SITE_CHANGES.has( toolName ) || typeof args.nameOrPath !== 'string' ) { + if ( + ! LOCAL_SITE_CHANGES.has( toolName ) || + typeof args.nameOrPath !== 'string' || + ( toolName === 'wp_cli' && WP_CLI_READS.test( String( args.command ?? '' ) ) ) + ) { return undefined; } try { diff --git a/apps/cli/ai/runtimes/pi/tests/site-pin.test.ts b/apps/cli/ai/runtimes/pi/tests/site-pin.test.ts index b5a43ea997..4679bd2273 100644 --- a/apps/cli/ai/runtimes/pi/tests/site-pin.test.ts +++ b/apps/cli/ai/runtimes/pi/tests/site-pin.test.ts @@ -42,6 +42,23 @@ describe( 'SitePin', () => { ).toBeUndefined(); } ); + it( 'asks before WP-CLI changes another site, and lets it read one', async () => { + const pin = new SitePin( shop ); + expect( + ( await pin.confirmationFor( 'wp_cli', { nameOrPath: 'Blog', command: 'post delete 4' } ) ) + ?.confirmation.question + ).toBe( 'Also change "Blog"? This request is about "Shop".' ); + for ( const command of [ + 'option get blogname', + 'post list --post_type=page', + 'theme status', + ] ) { + expect( + await pin.confirmationFor( 'wp_cli', { nameOrPath: 'Blog', command } ) + ).toBeUndefined(); + } + } ); + it( 'reads other sites freely', async () => { const pin = new SitePin( shop ); expect( From 3511d94a087820b77feaab8e546e9152454c0993 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:25:14 -0400 Subject: [PATCH 062/199] Check only the request's sites for changes, and skip tools that only read Before and after every tool call, the post content and theme checks read every local site's database and theme files, Read and Grep included. They now skip tools that only read, and once the request has a site, check only the sites it may change. --- apps/cli/ai/runtimes/pi/index.ts | 30 ++++++++++++++----- apps/cli/ai/runtimes/pi/site-pin.ts | 5 ++++ .../cli/ai/runtimes/pi/tests/site-pin.test.ts | 1 + apps/cli/ai/site-changes.ts | 5 ++-- apps/cli/ai/tests/pi-runtime.test.ts | 12 ++++---- apps/cli/ai/tests/post-content-check.test.ts | 3 +- apps/cli/ai/tests/theme-file-changes.test.ts | 2 +- 7 files changed, 40 insertions(+), 18 deletions(-) diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 871b818903..b7bbef4239 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -56,6 +56,7 @@ import { createAskUserQuestionTool } from 'cli/ai/tools/ask-user-question'; import { createPresentDesignOptionsTool } from 'cli/ai/tools/present-design-options'; import { createSkillTool } from 'cli/ai/tools/skill'; import { siteOfCall } from 'cli/ai/tools/utils'; +import { readCliConfig } from 'cli/lib/cli-config/core'; import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths'; import { createConfirmer, describeDestructiveCall, type Confirmer } from './confirmation'; import { withLeakedToolCallMarkupRemoved } from './leaked-tool-call-markup'; @@ -416,8 +417,15 @@ async function createStudioAgentSession( stated: new Map( localSite ? [ [ localSite, activeSiteFacts ] ] : [] ), gate: blockValidationGate, } ); - // What a call changed in the content the sites render, reported on its result. - const siteChanges = new SiteChanges( undefined, getOpenProblems( config.session ) ); + // What a call changed on the request's sites, or on every local site until + // the request has one, reported on its result. + const siteChanges = new SiteChanges( getOpenProblems( config.session ), async () => { + const { sites } = await readCliConfig(); + const pinned = sitePin.localSitePaths(); + return pinned.length + ? sites.filter( ( { path: sitePath } ) => pinned.includes( path.resolve( sitePath ) ) ) + : sites; + } ); const toolDefinitions = [ ...tools, ...mcpTools ].map( ( tool ) => toToolDefinition( tool, payloadGuardState, pendingWork, blockValidationGate, { confirm, @@ -698,6 +706,8 @@ function createSettingsManager( _env: Record< string, string > ): SettingsManage // A shell command can put content on the site through WP-CLI. const BLOCK_VALIDATION_GATED_TOOLS = new Set( [ 'Bash', 'take_screenshot' ] ); +// Tools that change nothing on a site, so there is nothing to check after them. +const READ_ONLY_TOOLS = new Set( [ 'Read', 'Grep', 'Glob', 'Ls' ] ); function toToolDefinition( tool: AgentToolAny, @@ -759,14 +769,18 @@ function toToolDefinition( const validationReport = BLOCK_VALIDATION_GATED_TOOLS.has( tool.name ) ? validationLines( await blockValidationGate.settle() ) : []; - await siteChanges.snapshot().catch( () => undefined ); + const checksChanges = ! READ_ONLY_TOOLS.has( tool.name ); + if ( checksChanges ) { + await siteChanges.snapshot().catch( () => undefined ); + } // A call that fails can still have changed content, as a shell // command does that exits non-zero after saving a page. - const changedContent = async () => { - return validationLines( - await siteChanges.settle().catch( () => ( { summaries: [], problems: [] } ) ) - ); - }; + const changedContent = async () => + checksChanges + ? validationLines( + await siteChanges.settle().catch( () => ( { summaries: [], problems: [] } ) ) + ) + : []; // The validation report was taken from the gate, so it rides on a failure too. const result = await tool .execute( toolCallId, params, signal, onUpdate ) diff --git a/apps/cli/ai/runtimes/pi/site-pin.ts b/apps/cli/ai/runtimes/pi/site-pin.ts index ae3068c7a3..487b6676d2 100644 --- a/apps/cli/ai/runtimes/pi/site-pin.ts +++ b/apps/cli/ai/runtimes/pi/site-pin.ts @@ -124,6 +124,11 @@ export class SitePin { }; } + // The local sites the request may change, as absolute paths: none until it has one. + localSitePaths(): string[] { + return [ ...this.allowed ].filter( ( key ) => ! key.startsWith( 'wpcom:' ) ); + } + // Sites the turn creates belong to the request. async created( siteName: string ): Promise< void > { try { diff --git a/apps/cli/ai/runtimes/pi/tests/site-pin.test.ts b/apps/cli/ai/runtimes/pi/tests/site-pin.test.ts index 4679bd2273..26ea39b93a 100644 --- a/apps/cli/ai/runtimes/pi/tests/site-pin.test.ts +++ b/apps/cli/ai/runtimes/pi/tests/site-pin.test.ts @@ -75,6 +75,7 @@ describe( 'SitePin', () => { expect( ( await pin.confirmationFor( 'site_import', { nameOrPath: 'Blog' } ) )?.confirmation.question ).toBe( 'Also change "Blog"? This request is about "Shop".' ); + expect( pin.localSitePaths() ).toEqual( [ '/sites/shop', '/sites/new' ] ); } ); it( 'asks before creating a site while the request has one, once', async () => { diff --git a/apps/cli/ai/site-changes.ts b/apps/cli/ai/site-changes.ts index d71f0ebfa1..e8444cb21e 100644 --- a/apps/cli/ai/site-changes.ts +++ b/apps/cli/ai/site-changes.ts @@ -44,14 +44,15 @@ async function localSites(): Promise< SiteData[] > { } export class SiteChanges { + private readonly snapshots = new Map< string, Versions >(); + constructor( - private readonly snapshots = new Map< string, Versions >(), private readonly open?: OpenProblems, private readonly sites: () => Promise< SiteData[] > = localSites, private readonly sources: SiteChangeSource[] = SITE_CHANGE_SOURCES ) {} - /** Records every local site as it is now. */ + /** Records each site it checks as it is now. */ async snapshot(): Promise< void > { for ( const site of await this.sites() ) { for ( const source of this.sources ) { diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index 33918ac308..f7dbd96a91 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -565,19 +565,21 @@ describe( 'pi runtime', () => { await runRuntime( { prompt: 'hello', env: WPCOM_ENV, session: newSession() } ); mocks.steps.length = 0; mocks.postChanges = { summaries: [ 'page 9 changed' ], problems: [ 'page 9 problem' ] }; - const result = await getCreatedTool( 'Read' ).execute( 'r', { path: 'a/style.css' } ); - expect( mocks.steps ).toEqual( [ 'snapshot', 'Read', 'settle' ] ); + const result = await getCreatedTool( 'Bash' ).execute( 'b', { command: 'true' } ); + expect( mocks.steps ).toEqual( [ 'snapshot', 'Bash', 'settle' ] ); expect( result.content.map( ( part ) => part.text ) ).toEqual( [ '', '\nBlock validation: page 9 changed\npage 9 problem\n', ] ); // A failed call reports what it changed too. mocks.failNext = true; - await expect( - getCreatedTool( 'Read' ).execute( 'r', { path: 'a/style.css' } ) - ).rejects.toThrow( + await expect( getCreatedTool( 'Bash' ).execute( 'b', { command: 'false' } ) ).rejects.toThrow( 'exit 1\n\nBlock validation: page 9 changed\npage 9 problem\n' ); + // A tool that only reads changes nothing to check. + mocks.steps.length = 0; + await getCreatedTool( 'Read' ).execute( 'r', { path: 'a/style.css' } ); + expect( mocks.steps ).toEqual( [ 'Read' ] ); mocks.postChanges = { summaries: [], problems: [] }; } ); diff --git a/apps/cli/ai/tests/post-content-check.test.ts b/apps/cli/ai/tests/post-content-check.test.ts index 2c89ff7f2c..0515ee8fb8 100644 --- a/apps/cli/ai/tests/post-content-check.test.ts +++ b/apps/cli/ai/tests/post-content-check.test.ts @@ -38,8 +38,7 @@ beforeEach( () => { afterEach( () => rmSync( fixture.root, { recursive: true, force: true } ) ); -const checkPosts = () => - new SiteChanges( new Map(), undefined, async () => [ site ], [ postContentChanges ] ); +const checkPosts = () => new SiteChanges( undefined, async () => [ site ], [ postContentChanges ] ); describe( 'post content changes', () => { it.each( [ diff --git a/apps/cli/ai/tests/theme-file-changes.test.ts b/apps/cli/ai/tests/theme-file-changes.test.ts index 9f4d68bcfd..63682cb1f0 100644 --- a/apps/cli/ai/tests/theme-file-changes.test.ts +++ b/apps/cli/ai/tests/theme-file-changes.test.ts @@ -25,7 +25,7 @@ beforeEach( () => { afterEach( () => rmSync( fixture.root, { recursive: true, force: true } ) ); const checkThemes = () => - new SiteChanges( new Map(), undefined, async () => [ site ], [ updatableThemeChanges ] ); + new SiteChanges( undefined, async () => [ site ], [ updatableThemeChanges ] ); describe( 'updatable theme file changes', () => { it.each( [ From 7c9c35ee02911afdb3249e8f8d1376f1479b53e5 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:26:51 -0400 Subject: [PATCH 063/199] Give a shell command's writes to the request's sites the write notes A file a shell command wrote, as a heredoc or sed does, got none of the write notes a Write or Edit gets: fonts self-hosted, block markup validated, theme.json and theme CSS checked. The theme and tmp files of the request's sites are compared before and after each Bash call, and each one written gets those notes. --- apps/cli/ai/runtimes/pi/index.ts | 12 ++++++- apps/cli/ai/site-notes.ts | 49 ++++++++++++++++++++++++++-- apps/cli/ai/tests/pi-runtime.test.ts | 14 ++++++++ apps/cli/ai/tests/site-notes.test.ts | 30 +++++++++++++++++ 4 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 apps/cli/ai/tests/site-notes.test.ts diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index b7bbef4239..1fe876c510 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -48,7 +48,7 @@ import { emitEvent } from 'cli/ai/json-events'; import { describeOpenProblems, getOpenProblems } from 'cli/ai/open-problems'; import { SiteChanges } from 'cli/ai/site-changes'; import { readActiveSiteFacts } from 'cli/ai/site-facts'; -import { createSiteNotes, type SiteNotes } from 'cli/ai/site-notes'; +import { createSiteNotes, writtenFiles, type SiteNotes } from 'cli/ai/site-notes'; import { studioMessage, validationLines } from 'cli/ai/studio-message'; import { buildSystemPrompt } from 'cli/ai/system-prompt'; import { resolveStudioToolDefinitions } from 'cli/ai/tools'; @@ -757,6 +757,9 @@ function toToolDefinition( } crossSite?.allow(); const writtenFile = getWrittenFile( tool.name, params, STUDIO_SITES_ROOT ); + // A shell command can write the request's site files too. + const shellWrites = + tool.name === 'Bash' ? await writtenFiles( sitePin.localSitePaths() ) : undefined; const fileWriteRefusal = writtenFile && ( await siteNotes.beforeWrite( writtenFile ) ); if ( fileWriteRefusal ) { throw new Error( fileWriteRefusal ); @@ -813,6 +816,13 @@ function toToolDefinition( ...validationReport, ...changed, ...( writtenFile ? await siteNotes.afterWrite( writtenFile ) : [] ), + ...( shellWrites + ? ( + await Promise.all( + ( await shellWrites() ).map( ( file ) => siteNotes.afterWrite( file ) ) + ) + ).flat() + : [] ), ...( resultSite ? await siteNotes.afterResult( resultSite, resultText ) : [] ), pendingWork.takeReports(), ] ); diff --git a/apps/cli/ai/site-notes.ts b/apps/cli/ai/site-notes.ts index a66848b221..9751c127e1 100644 --- a/apps/cli/ai/site-notes.ts +++ b/apps/cli/ai/site-notes.ts @@ -6,15 +6,17 @@ * * - `beforeWrite`: a file tool is about to write a site's file. A note here * refuses the write, and says where the change belongs. - * - `write`: a file tool wrote a site's file. Sources may act on the file - * (self-host its fonts, give its theme a thumbnail) and say what they did, - * or say what WordPress will render differently from it. + * - `write`: a file tool, or a shell command (writtenFiles), wrote a site's + * file. Sources may act on the file (self-host its fonts, give its theme a + * thumbnail) and say what they did, or say what WordPress will render + * differently from it. * - `result`: one of Studio's tools returned a result about a site. * * A new note is one more source in SOURCES, and reaches every surface that * fires its trigger: Studio Code fires all three; `studio mcp`, which Claude * Code and Codex use, fires `result`. */ +import { readdir, stat } from 'fs/promises'; import path from 'path'; import { createBlockSettingsNotes } from 'cli/ai/block-settings'; import { readSiteFacts } from 'cli/ai/site-facts'; @@ -150,3 +152,44 @@ export function createSiteNotes( options: SiteNotesOptions = {} ): SiteNotes { }, }; } + +// The folders of a site whose files the write sources have notes on. +const NOTED_FOLDERS = [ path.join( 'wp-content', 'themes' ), 'tmp' ]; +const SKIPPED_FOLDERS = new Set( [ 'node_modules', '.git' ] ); + +async function stamps( sitePaths: string[] ): Promise< Map< string, string > > { + const found = new Map< string, string >(); + for ( const folder of sitePaths.flatMap( ( site ) => + NOTED_FOLDERS.map( ( noted ) => path.join( site, noted ) ) + ) ) { + const entries = await readdir( folder, { recursive: true, withFileTypes: true } ).catch( + () => [] + ); + for ( const entry of entries ) { + const file = path.join( entry.parentPath, entry.name ); + if ( + entry.isFile() && + ! path + .relative( folder, file ) + .split( path.sep ) + .some( ( part ) => SKIPPED_FOLDERS.has( part ) ) + ) { + const { mtimeMs, size } = await stat( file ).catch( () => ( { mtimeMs: 0, size: -1 } ) ); + found.set( file, `${ mtimeMs }:${ size }` ); + } + } + } + return found; +} + +/** + * Stamps the noted files of these sites, and returns what lists the ones + * written since: how a shell command's writes reach the `write` notes. + */ +export async function writtenFiles( sitePaths: string[] ): Promise< () => Promise< string[] > > { + const before = await stamps( sitePaths ); + return async () => + [ ...( await stamps( sitePaths ) ) ] + .filter( ( [ file, stamp ] ) => before.get( file ) !== stamp ) + .map( ( [ file ] ) => file ); +} diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index f7dbd96a91..74243fd9a0 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -83,6 +83,9 @@ vi.mock( 'cli/ai/site-notes', () => ( { afterWrite: async ( file: string ) => [ `wrote ${ file }` ], afterResult: async ( site: string ) => [ `result on ${ site }` ], } ), + // A shell command writes one theme file on each site it is given. + writtenFiles: async ( sites: string[] ) => async () => + sites.map( ( site ) => `${ site }/wp-content/themes/t/style.css` ), } ) ); vi.mock( 'cli/ai/site-changes', () => ( { @@ -558,6 +561,17 @@ describe( 'pi runtime', () => { await none.execute( 'w', { path: 'a/style.css', content: '', nameOrPath: 'b' } ) ).content.map( ( part ) => part.text ) ).toEqual( [ '', `\nwrote ${ file }\nresult on /sites/b\n` ] ); + + // A shell command's writes to the request's site get the same notes. + await run( local ); + expect( + ( await getCreatedTool( 'Bash' ).execute( 'b', { command: 'true' } ) ).content.map( + ( part ) => part.text + ) + ).toEqual( [ + '', + '\nwrote /sites/a/wp-content/themes/t/style.css\nresult on /sites/a\n', + ] ); } ); it( 'reports the post content a call changed, from a snapshot taken before it', async () => { diff --git a/apps/cli/ai/tests/site-notes.test.ts b/apps/cli/ai/tests/site-notes.test.ts new file mode 100644 index 0000000000..bbdc9e971f --- /dev/null +++ b/apps/cli/ai/tests/site-notes.test.ts @@ -0,0 +1,30 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { writtenFiles } from 'cli/ai/site-notes'; + +let site: string; + +afterEach( () => rmSync( site, { recursive: true, force: true } ) ); + +function write( relativePath: string, content = '' ) { + const file = path.join( site, relativePath ); + mkdirSync( path.dirname( file ), { recursive: true } ); + writeFileSync( file, content ); + return file; +} + +describe( 'writtenFiles', () => { + it( 'lists the theme and tmp files written since, and none of the rest', async () => { + site = mkdtempSync( path.join( os.tmpdir(), 'studio-written-' ) ); + const style = write( 'wp-content/themes/t/style.css', 'a' ); + write( 'wp-content/themes/t/theme.json', '{}' ); + const written = await writtenFiles( [ site ] ); + write( 'wp-content/themes/t/style.css', 'ab' ); + const page = write( 'tmp/page-home.html' ); + write( 'wp-content/plugins/p/p.php' ); + write( 'wp-content/themes/t/node_modules/x/index.js' ); + expect( ( await written() ).sort() ).toEqual( [ style, page ].sort() ); + } ); +} ); From 96e282dbc9d3ef3d998beeb5c2a83da2f3e32bbf Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:29:12 -0400 Subject: [PATCH 064/199] Write the fix file's path with forward slashes, and pass the site tests on Windows On Windows the command that applies a lossless fix named the file with backslashes, which the agent's shell, Git Bash, reads as escapes. The path is written with forward slashes. The site fixture kept its database open, which Windows will not remove, and the runtime test compared paths as POSIX strings. --- apps/cli/ai/post-content-check.ts | 6 ++- apps/cli/ai/tests/fixtures/wordpress-site.ts | 41 +++++++++++++++----- apps/cli/ai/tests/pi-runtime.test.ts | 5 ++- apps/cli/ai/tests/post-content-check.test.ts | 7 ++-- 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/apps/cli/ai/post-content-check.ts b/apps/cli/ai/post-content-check.ts index f064f244ee..0f6a757ed7 100644 --- a/apps/cli/ai/post-content-check.ts +++ b/apps/cli/ai/post-content-check.ts @@ -57,7 +57,8 @@ async function readPosts( sitePath: string, ids: string[] ): Promise< Row[] > { // A lossless fix is saved in the site's tmp/ for the agent to apply, since the // row belongs to the running site; named by its absolute path, which holds -// whatever folder the agent's shell is in. +// whatever folder the agent's shell is in, with forward slashes, which the +// shell keeps where it reads a Windows backslash as an escape. async function validatePost( site: SiteData, post: Row ) { const label = `${ site.name } ${ post.type } ${ post.id } "${ post.title }"`; const result = await validateBlockMarkup( post.content, label, getSiteUrl( site ) ); @@ -67,9 +68,10 @@ async function validatePost( site: SiteData, post: Row ) { const fixFile = path.join( site.path, 'tmp', `${ post.type }-${ post.id }.html` ); await mkdir( path.dirname( fixFile ), { recursive: true } ); await writeFile( fixFile, result.fixedContent, 'utf8' ); + const shellPath = fixFile.split( path.sep ).join( '/' ); return { summary: `${ label }: ${ result.invalidBlocks } invalid block(s)`, - problem: `${ label }: ${ result.invalidBlocks } block(s) do not match what the block saves; the block editor's fix loses nothing and is saved in ${ fixFile }. Apply it in the site's folder with \`wp post update ${ post.id } ${ fixFile }\`.`, + problem: `${ label }: ${ result.invalidBlocks } block(s) do not match what the block saves; the block editor's fix loses nothing and is saved in ${ shellPath }. Apply it in the site's folder with \`wp post update ${ post.id } ${ shellPath }\`.`, }; } diff --git a/apps/cli/ai/tests/fixtures/wordpress-site.ts b/apps/cli/ai/tests/fixtures/wordpress-site.ts index 79e6e39a1d..4c9e1ca9e1 100644 --- a/apps/cli/ai/tests/fixtures/wordpress-site.ts +++ b/apps/cli/ai/tests/fixtures/wordpress-site.ts @@ -21,8 +21,20 @@ export function createSite( prefix = 'wp_' ) { `/*\n${ header }\n*/` ); } - const db = new DatabaseSync( path.join( root, 'wp-content/database/.ht.sqlite' ) ); - db.exec( ` + // Each write opens and closes the database, as Windows cannot remove an open file. + const write = ( sql: string, ...params: Array< string | number > ) => { + const db = new DatabaseSync( path.join( root, 'wp-content/database/.ht.sqlite' ) ); + try { + if ( params.length ) { + db.prepare( sql ).run( ...params ); + } else { + db.exec( sql ); + } + } finally { + db.close(); + } + }; + write( ` CREATE TABLE ${ prefix }posts ( ID INTEGER PRIMARY KEY, post_type TEXT, post_status TEXT, post_name TEXT, post_content TEXT, post_date TEXT, post_title TEXT DEFAULT '', post_modified_gmt TEXT DEFAULT '2026-10-22 00:00:00' ); CREATE TABLE ${ prefix }terms ( term_id INTEGER PRIMARY KEY, name TEXT ); CREATE TABLE ${ prefix }term_taxonomy ( term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER, taxonomy TEXT ); @@ -43,20 +55,29 @@ export function createSite( prefix = 'wp_' ) { status = 'publish', title = '' ) => { - db.prepare( - `INSERT INTO ${ prefix }posts ( ID, post_type, post_status, post_name, post_content, post_date, post_title ) VALUES ( ?, ?, ?, ?, ?, '2026-10-22', ? )` - ).run( id, type, status, slug, content, title ); - db.prepare( `INSERT INTO ${ prefix }term_relationships VALUES ( ?, ? )` ).run( id, theme ); + write( + `INSERT INTO ${ prefix }posts ( ID, post_type, post_status, post_name, post_content, post_date, post_title ) VALUES ( ?, ?, ?, ?, ?, '2026-10-22', ? )`, + id, + type, + status, + slug, + content, + title + ); + write( `INSERT INTO ${ prefix }term_relationships VALUES ( ?, ? )`, id, theme ); }; const setOptions = ( options: Record< string, string > ) => { for ( const [ name, value ] of Object.entries( options ) ) { - db.prepare( `INSERT OR REPLACE INTO ${ prefix }options VALUES ( ?, ? )` ).run( name, value ); + write( `INSERT OR REPLACE INTO ${ prefix }options VALUES ( ?, ? )`, name, value ); } }; // An edit as WordPress saves it: new content and a new modified time. const edit = ( id: number, content: string, modified = '2026-10-23 00:00:00' ) => - db - .prepare( `UPDATE ${ prefix }posts SET post_content = ?, post_modified_gmt = ? WHERE ID = ?` ) - .run( content, modified, id ); + write( + `UPDATE ${ prefix }posts SET post_content = ?, post_modified_gmt = ? WHERE ID = ?`, + content, + modified, + id + ); return { root, save, setOptions, edit }; } diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index 74243fd9a0..4d2dce3d21 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -1,3 +1,4 @@ +import path from 'path'; import { ANTHROPIC_MODELS } from '@earendil-works/pi-ai/providers/anthropic.models'; import { ModelRegistry, SessionManager } from '@earendil-works/pi-coding-agent'; import { AI_MODELS } from '@studio/common/ai/models'; @@ -533,12 +534,12 @@ describe( 'pi runtime', () => { running: false, } as StudioAgentTurnConfig[ 'activeSite' ]; const write = await run( local ); - const file = `${ mocks.studioRoot }/a/style.css`; + const file = path.resolve( mocks.studioRoot, 'a/style.css' ); const text = async ( params: Record< string, unknown > ) => ( await write.execute( 'w', params ) ).content.map( ( part ) => part.text ); await expect( write.execute( 'w', { path: 'a/refused.php', content: '' } ) ).rejects.toThrow( - `Not run: ${ mocks.studioRoot }/a/refused.php` + `Not run: ${ path.resolve( mocks.studioRoot, 'a/refused.php' ) }` ); expect( await text( { path: 'a/style.css', content: '' } ) ).toEqual( [ '', diff --git a/apps/cli/ai/tests/post-content-check.test.ts b/apps/cli/ai/tests/post-content-check.test.ts index 0515ee8fb8..5aa8913792 100644 --- a/apps/cli/ai/tests/post-content-check.test.ts +++ b/apps/cli/ai/tests/post-content-check.test.ts @@ -94,10 +94,9 @@ describe( 'post content changes', () => { const { summaries, problems } = await check.settle(); expect( summaries ).toEqual( [ 'Bakery page 9 "": 1 invalid block(s)' ] ); expect( problems[ 0 ] ).toContain( - `Apply it in the site's folder with \`wp post update 9 ${ path.join( - fixture.root, - 'tmp/page-9.html' - ) }\`` + `Apply it in the site's folder with \`wp post update 9 ${ fixture.root + .split( path.sep ) + .join( '/' ) }/tmp/page-9.html\`` ); expect( readFileSync( path.join( fixture.root, 'tmp/page-9.html' ), 'utf8' ) ).toBe( WRAPPED_GROUP From 5561de4e8927dc83f192a4948f5f2a1950671499 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:35:30 -0400 Subject: [PATCH 065/199] State that WordPress is not installed until a site first starts A site created without starting has no database: WP-CLI fails and the other facts are empty. is_blog_installed() reads siteurl; Studio installs WordPress on the site's first start (installWordPress()). --- apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts | 2 +- apps/cli/ai/site-facts.ts | 10 ++++++++++ apps/cli/ai/tests/fixtures/wordpress-site.ts | 2 +- apps/cli/ai/tests/site-facts.test.ts | 11 +++++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts index 5fdce4522c..eaac03b0bb 100644 --- a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts +++ b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts @@ -43,7 +43,7 @@ it( 'adds the declared settings of the core blocks a result shows, from the site .split( path.sep ) .join( '/' - ) }: submenuVisibility ("hover"|"click").\nAddress: http://localhost:8881 (home_url()).\n
`, + ) }: submenuVisibility ("hover"|"click").\nAddress: http://localhost:8881 (home_url()).\nWordPress is not installed yet (is_blog_installed()): Studio installs it, with its database, when the site first starts (site_start).\n
`, ] ); // Once per MCP session, as once per Studio Code turn. expect( await notes( { nameOrPath: 'My Site' } ) ).toEqual( [] ); diff --git a/apps/cli/ai/site-facts.ts b/apps/cli/ai/site-facts.ts index 3d7ba9da7e..503c6931a6 100644 --- a/apps/cli/ai/site-facts.ts +++ b/apps/cli/ai/site-facts.ts @@ -31,9 +31,19 @@ async function siteAddressFact( site: WordPressSite ): Promise< string | undefin return studioSite ? `Address: ${ getSiteUrl( studioSite ) } (home_url()).` : undefined; } +// WordPress is installed once its options hold siteurl (is_blog_installed()); +// Studio installs it the first time the site starts (installWordPress() in +// lib/native-php/site-setup.ts), so a site never started has no database yet. +function installedFact( site: WordPressSite ): string | undefined { + return site.option( 'siteurl' ) === undefined + ? 'WordPress is not installed yet (is_blog_installed()): Studio installs it, with its database, when the site first starts (site_start).' + : undefined; +} + const SITE_FACTS: SiteFact[] = [ siteAddressFact, wordPressVersionFact, + installedFact, activeThemeFact, themePresetsFact, homepageFact, diff --git a/apps/cli/ai/tests/fixtures/wordpress-site.ts b/apps/cli/ai/tests/fixtures/wordpress-site.ts index 79e6e39a1d..3054fa789b 100644 --- a/apps/cli/ai/tests/fixtures/wordpress-site.ts +++ b/apps/cli/ai/tests/fixtures/wordpress-site.ts @@ -28,7 +28,7 @@ export function createSite( prefix = 'wp_' ) { CREATE TABLE ${ prefix }term_taxonomy ( term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER, taxonomy TEXT ); CREATE TABLE ${ prefix }term_relationships ( object_id INTEGER, term_taxonomy_id INTEGER ); CREATE TABLE ${ prefix }options ( option_name TEXT PRIMARY KEY, option_value TEXT ); - INSERT INTO ${ prefix }options VALUES ( 'active_plugins', 'a:0:{}' ); + INSERT INTO ${ prefix }options VALUES ( 'siteurl', 'http://localhost:8881' ), ( 'active_plugins', 'a:0:{}' ); INSERT INTO ${ prefix }options VALUES ( 'stylesheet', 'bakery' ), ( 'template', 'bakery-parent' ); INSERT INTO ${ prefix }options VALUES ( '_site_transient_update_themes', 'O:8:"stdClass":3:{s:7:"checked";a:3:{s:6:"bakery";s:3:"1.0";s:13:"bakery-parent";s:3:"1.0";s:16:"twentytwentyfive";s:3:"1.5";}s:8:"response";a:0:{}s:9:"no_update";a:2:{s:13:"bakery-parent";a:2:{s:5:"theme";s:13:"bakery-parent";s:11:"new_version";s:3:"1.0";}s:16:"twentytwentyfive";a:2:{s:5:"theme";s:16:"twentytwentyfive";s:11:"new_version";s:3:"1.5";}}}' ); INSERT INTO ${ prefix }terms VALUES ( 1, 'bakery' ), ( 2, 'other-theme' ); diff --git a/apps/cli/ai/tests/site-facts.test.ts b/apps/cli/ai/tests/site-facts.test.ts index b3303f65ef..c70ee75dba 100644 --- a/apps/cli/ai/tests/site-facts.test.ts +++ b/apps/cli/ai/tests/site-facts.test.ts @@ -1,3 +1,5 @@ +import { rmSync } from 'fs'; +import path from 'path'; import { expect, it, vi } from 'vitest'; import { findSiteByFolder } from 'cli/lib/cli-config/sites'; import { readActiveSiteFacts } from '../site-facts'; @@ -43,6 +45,15 @@ it( 'states the WordPress version, the active theme and whether WordPress update ] ); } ); +it( 'states that WordPress is not installed until the site first starts', async () => { + const site = createSite( 'wp_' ); + rmSync( path.join( site.root, 'wp-content/database' ), { recursive: true } ); + expect( await site.facts() ).toEqual( [ + 'WordPress 7.1.3.', + 'WordPress is not installed yet (is_blog_installed()): Studio installs it, with its database, when the site first starts (site_start).', + ] ); +} ); + it.each( [ { reading: { show_on_front: 'posts' }, fact: 'Homepage: the latest posts (show_on_front).' }, { From 180702f3c908bc832d870c591c6cafd7aa33349f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:35:30 -0400 Subject: [PATCH 066/199] Note a stat not bumped on stderr, outside a command's output In E2E and development, the note went to stdout, so it was captured as part of a command's output, such as studio wp ... --porcelain. --- packages/common/lib/bump-stat.ts | 3 ++- packages/common/lib/tests/bump-stat.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/common/lib/bump-stat.ts b/packages/common/lib/bump-stat.ts index 91343b93d9..63def45b7a 100644 --- a/packages/common/lib/bump-stat.ts +++ b/packages/common/lib/bump-stat.ts @@ -26,7 +26,8 @@ export function __bumpStat( group: string, stat: string, bumpInDev = false ) { } if ( process.env.E2E || ( process.env.NODE_ENV === 'development' && ! bumpInDev ) ) { - console.info( `Would have bumped stat: ${ group }=${ stat }` ); + // On stderr, so a command's own output, such as `studio wp … --porcelain`, stays parseable. + console.error( `Would have bumped stat: ${ group }=${ stat }` ); return false; } diff --git a/packages/common/lib/tests/bump-stat.test.ts b/packages/common/lib/tests/bump-stat.test.ts index 1f0842b0b5..367982a1e9 100644 --- a/packages/common/lib/tests/bump-stat.test.ts +++ b/packages/common/lib/tests/bump-stat.test.ts @@ -18,6 +18,16 @@ describe( '__bumpStat', () => { process.env = { ...originalEnv }; } ); + it( "notes a stat it would have bumped on stderr, outside a command's output", () => { + process.env.E2E = '1'; + const stdout = vi.spyOn( console, 'info' ).mockImplementation( () => {} ); + const stderr = vi.spyOn( console, 'error' ).mockImplementation( () => {} ); + expect( __bumpStat( 'studio-cli', 'mac-arm64' ) ).toBe( false ); + expect( stdout ).not.toHaveBeenCalled(); + expect( stderr ).toHaveBeenCalledWith( 'Would have bumped stat: studio-cli=mac-arm64' ); + expect( fetch ).not.toHaveBeenCalled(); + } ); + it( 'bounds the fire-and-forget request', () => { expect( __bumpStat( 'studio-cli', 'mac-arm64' ) ).toBe( true ); expect( fetch ).toHaveBeenCalledWith( From 7df7f3ed850731aae5d68dbd50397c4f4e368292 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:35:30 -0400 Subject: [PATCH 067/199] Check for a child theme's unchanged copies of its parent's files WordPress reads a file a child theme lacks from its parent (get_theme_file_path(), get_theme_file_uri() for theme.json fonts, the template and pattern resolvers), merges the child's theme.json over the parent's, and loads both functions.php files. A copy only stops following the parent's updates. Reported on the call that makes it, by any tool, and open until the copy is changed or deleted. The screenshot, which WordPress reads only from the child, is its own. --- apps/cli/ai/child-theme-copies.ts | 113 +++++++++++++++++++ apps/cli/ai/site-changes.ts | 2 + apps/cli/ai/tests/child-theme-copies.test.ts | 109 ++++++++++++++++++ apps/cli/ai/themes.ts | 15 ++- 4 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 apps/cli/ai/child-theme-copies.ts create mode 100644 apps/cli/ai/tests/child-theme-copies.test.ts diff --git a/apps/cli/ai/child-theme-copies.ts b/apps/cli/ai/child-theme-copies.ts new file mode 100644 index 0000000000..be42292e64 --- /dev/null +++ b/apps/cli/ai/child-theme-copies.ts @@ -0,0 +1,113 @@ +/** + * Files a child theme holds unchanged from its parent, by any tool or script. + * WordPress already reads a file the child lacks from the parent + * (get_theme_file_path(), get_theme_file_uri() for theme.json's `file:./` + * fonts, locate_template(), the block template and pattern resolvers) and merges the child's theme.json over the parent's + * (WP_Theme_JSON_Resolver::get_theme_data()), so a copy only stops following + * the parent's updates; both functions.php files load (wp-settings.php), so a + * copied one redeclares the parent's functions. A child theme holds the files + * it changes (https://developer.wordpress.org/themes/advanced-topics/child-themes/). + * + * A screenshot is the child's own: WP_Theme::get_screenshot() reads only the + * child's folder. + * + * Each file's version is both files' modified time and size and whether their + * content is the same, compared again only when a time or size moves. + */ +import { readdir, readFile, stat } from 'fs/promises'; +import path from 'path'; +import { readThemeHeader } from 'cli/ai/themes'; +import type { SiteChangeSource, Versions } from 'cli/ai/site-changes'; + +const SCREENSHOT = /^screenshot\.(png|gif|jpe?g|webp|avif)$/; +const SAME = ' same'; + +const stampOf = async ( file: string ) => { + const { mtimeMs, size } = await stat( file ); + return { stamp: `${ mtimeMs }:${ size }`, size }; +}; + +async function readCopies( + themesDir: string, + child: string, + parent: string, + last: Versions +): Promise< Versions > { + const versions: Versions = {}; + const childDir = path.join( themesDir, child ); + for ( const entry of await readdir( childDir, { recursive: true, withFileTypes: true } ) ) { + const relative = path + .relative( childDir, path.join( entry.parentPath, entry.name ) ) + .split( path.sep ) + .join( '/' ); + if ( ! entry.isFile() || SCREENSHOT.test( relative ) ) { + continue; + } + const childFile = path.join( childDir, relative ); + const parentFile = path.join( themesDir, parent, relative ); + const [ own, inherited ] = await Promise.all( [ + stampOf( childFile ), + stampOf( parentFile ).catch( () => undefined ), + ] ); + if ( ! inherited ) { + continue; + } + const key = `${ child }/${ relative }`; + const stamps = `${ own.stamp }|${ inherited.stamp }`; + const same = last[ key ]?.startsWith( `${ stamps } ` ) + ? last[ key ].endsWith( SAME ) + : own.size === inherited.size && + ( await readFile( childFile ) ).equals( await readFile( parentFile ) ); + versions[ key ] = `${ stamps }${ same ? SAME : ' own' }`; + } + return versions; +} + +const copiesOf = ( versions: Versions, child: string ) => + Object.entries( versions ) + .filter( ( [ key, version ] ) => key.startsWith( `${ child }/` ) && version.endsWith( SAME ) ) + .map( ( [ key ] ) => key.slice( child.length + 1 ) ) + .sort(); + +function problem( child: string, parent: string, files: string[] ): string { + const named = files.length > 5 ? `${ files.slice( 0, 5 ).join( ', ' ) }, …` : files.join( ', ' ); + return `The child theme ${ child } holds files of its parent ${ parent } unchanged: ${ named }. WordPress reads a file the child lacks from the parent and merges the child's theme.json over the parent's, so a copy only stops following ${ parent }'s updates, and a copied functions.php loads beside the parent's. Delete the copies, so ${ child } holds only the files it changes (https://developer.wordpress.org/themes/advanced-topics/child-themes/).`; +} + +export const childThemeCopies: SiteChangeSource = { + name: 'child-themes', + async read( sitePath, last = {} ) { + const themesDir = path.join( sitePath, 'wp-content', 'themes' ); + const themes = ( await readdir( themesDir, { withFileTypes: true } ).catch( () => [] ) ) + .filter( ( entry ) => entry.isDirectory() ) + .map( ( entry ) => entry.name ); + const versions: Versions = {}; + for ( const child of themes ) { + const parent = await readThemeHeader( sitePath, child, 'Template' ).catch( () => '' ); + if ( parent && parent !== child ) { + versions[ child ] = parent; + Object.assign( versions, await readCopies( themesDir, child, parent, last ) ); + } + } + return versions; + }, + async check( _site, before, after ) { + const reports = []; + const children = new Set( + [ ...Object.keys( before ), ...Object.keys( after ) ].filter( + ( key ) => ! key.includes( '/' ) + ) + ); + for ( const child of children ) { + const was = copiesOf( before, child ); + const now = copiesOf( after, child ); + if ( was.join( '\n' ) !== now.join( '\n' ) ) { + reports.push( { + key: `child theme ${ child }`, + problem: now.length ? problem( child, after[ child ], now ) : undefined, + } ); + } + } + return { reports }; + }, +}; diff --git a/apps/cli/ai/site-changes.ts b/apps/cli/ai/site-changes.ts index d71f0ebfa1..e4942eaf85 100644 --- a/apps/cli/ai/site-changes.ts +++ b/apps/cli/ai/site-changes.ts @@ -7,6 +7,7 @@ * changed is reported on that call's result. Each source reads one kind of * thing WordPress reads, as versions a check compares. */ +import { childThemeCopies } from 'cli/ai/child-theme-copies'; import { postContentChanges } from 'cli/ai/post-content-check'; import { updatableThemeChanges } from 'cli/ai/theme-file-changes'; import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; @@ -37,6 +38,7 @@ export interface SiteChangeSource { export const SITE_CHANGE_SOURCES: SiteChangeSource[] = [ postContentChanges, updatableThemeChanges, + childThemeCopies, ]; async function localSites(): Promise< SiteData[] > { diff --git a/apps/cli/ai/tests/child-theme-copies.test.ts b/apps/cli/ai/tests/child-theme-copies.test.ts new file mode 100644 index 0000000000..2f82d7d3e7 --- /dev/null +++ b/apps/cli/ai/tests/child-theme-copies.test.ts @@ -0,0 +1,109 @@ +import { mkdirSync, rmSync, writeFileSync } from 'fs'; +import path from 'path'; +import { afterEach, beforeEach, expect, it } from 'vitest'; +import { childThemeCopies } from 'cli/ai/child-theme-copies'; +import { OpenProblems } from 'cli/ai/open-problems'; +import { SiteChanges } from 'cli/ai/site-changes'; +import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; +import type { SiteData } from 'cli/lib/cli-config/core'; + +let fixture: ReturnType< typeof createSite >; +let site: SiteData; + +// Writes a theme file as any tool or script would. +const write = ( file: string, content: string ) => { + const target = path.join( fixture.root, 'wp-content/themes', file ); + mkdirSync( path.dirname( target ), { recursive: true } ); + writeFileSync( target, content ); +}; +const remove = ( file: string ) => rmSync( path.join( fixture.root, 'wp-content/themes', file ) ); + +const HEADER = ''; +const THEME_JSON = '{"version":3,"settings":{"color":{"palette":[]}}}'; + +beforeEach( () => { + fixture = createSite(); + write( 'bakery-parent/parts/header.html', HEADER ); + write( 'bakery-parent/theme.json', THEME_JSON ); + write( 'bakery-parent/screenshot.png', 'png' ); + write( 'kid/style.css', '/*\nTheme Name: Kid\nTemplate: bakery-parent\n*/' ); + site = { id: '1', name: 'Bakery', path: fixture.root } as SiteData; +} ); + +afterEach( () => rmSync( fixture.root, { recursive: true, force: true } ) ); + +const problemsAfter = async ( check: SiteChanges, change: () => void ) => { + await check.snapshot(); + change(); + return ( await check.settle() ).problems; +}; + +it.each( [ + { name: 'nothing', change: () => {}, copies: undefined }, + { + name: "a copy of the parent's files", + change: () => { + write( 'kid/parts/header.html', HEADER ); + write( 'kid/theme.json', THEME_JSON ); + }, + copies: 'parts/header.html, theme.json', + }, + { + name: "a changed copy of the parent's file", + change: () => write( 'kid/theme.json', '{"version":3,"settings":{}}' ), + copies: undefined, + }, + { + name: 'a file the parent lacks', + change: () => write( 'kid/templates/home.html', HEADER ), + copies: undefined, + }, + { + name: "the parent's screenshot, which WordPress reads only from the child", + change: () => write( 'kid/screenshot.png', 'png' ), + copies: undefined, + }, + { + name: 'a theme that names itself as its parent, which WordPress refuses', + change: () => write( 'kid/style.css', '/*\nTheme Name: Kid\nTemplate: kid\n*/' ), + copies: undefined, + }, + { + name: 'the same file in a theme that is no child', + change: () => write( 'twentytwentyfive/parts/header.html', HEADER ), + copies: undefined, + }, +] )( 'reports $name', async ( { change, copies } ) => { + const check = new SiteChanges( new Map(), undefined, async () => [ site ], [ childThemeCopies ] ); + const problems = await problemsAfter( check, change ); + expect( problems ).toEqual( + copies + ? [ + expect.stringContaining( + `The child theme kid holds files of its parent bakery-parent unchanged: ${ copies }.` + ), + ] + : [] + ); +} ); + +it( 'reports a copy once, and keeps it open until it is changed or deleted', async () => { + const open = new OpenProblems( undefined, [] ); + const check = new SiteChanges( new Map(), open, async () => [ site ], [ childThemeCopies ] ); + const isOpen = () => Object.keys( open.toJSON().open ).length > 0; + + // A changed file stays the child's own while neither file changes. + write( 'kid/parts/header.html', '' ); + expect( await problemsAfter( check, () => write( 'kid/theme.json', THEME_JSON ) ) ).toHaveLength( + 1 + ); + expect( await problemsAfter( check, () => write( 'kid/functions.php', ' write( 'bakery-parent/theme.json', '{"version":3}' ) ); + expect( isOpen() ).toBe( false ); + await problemsAfter( check, () => write( 'bakery-parent/theme.json', THEME_JSON ) ); + expect( isOpen() ).toBe( true ); + await problemsAfter( check, () => remove( 'kid/theme.json' ) ); + expect( isOpen() ).toBe( false ); +} ); diff --git a/apps/cli/ai/themes.ts b/apps/cli/ai/themes.ts index adf0850146..2e3337d317 100644 --- a/apps/cli/ai/themes.ts +++ b/apps/cli/ai/themes.ts @@ -20,10 +20,19 @@ function isUpdated( site: WordPressSite, theme: string ): boolean { const UPDATE_CHECK_URL = 'https://api.wordpress.org/themes/update-check/1.1/'; const asked = new Map< string, Promise< boolean > >(); -/** The Version a theme's style.css header names. */ -export async function readThemeVersion( root: string, theme: string ): Promise< string > { +/** A header of a theme's style.css, read as get_file_data() reads it. */ +export async function readThemeHeader( + root: string, + theme: string, + header: string +): Promise< string > { const css = await readFile( path.join( root, 'wp-content/themes', theme, 'style.css' ), 'utf8' ); - return css.match( /^[ \t/*#@]*Version:(.*)$/im )?.[ 1 ].trim() ?? ''; + return css.match( new RegExp( `^[ \\t/*#@]*${ header }:(.*)$`, 'im' ) )?.[ 1 ].trim() ?? ''; +} + +/** The Version a theme's style.css header names. */ +export function readThemeVersion( root: string, theme: string ): Promise< string > { + return readThemeHeader( root, theme, 'Version' ); } // One answer per theme release; an unanswered check is asked again. From 6f479fea82d90bf8b771c7cc61fd04593fe7edac Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:37:21 -0400 Subject: [PATCH 068/199] Use the merged SiteChanges and OpenProblems in the child theme copies test --- apps/cli/ai/tests/child-theme-copies.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/cli/ai/tests/child-theme-copies.test.ts b/apps/cli/ai/tests/child-theme-copies.test.ts index 2f82d7d3e7..7e4615a298 100644 --- a/apps/cli/ai/tests/child-theme-copies.test.ts +++ b/apps/cli/ai/tests/child-theme-copies.test.ts @@ -2,9 +2,9 @@ import { mkdirSync, rmSync, writeFileSync } from 'fs'; import path from 'path'; import { afterEach, beforeEach, expect, it } from 'vitest'; import { childThemeCopies } from 'cli/ai/child-theme-copies'; -import { OpenProblems } from 'cli/ai/open-problems'; import { SiteChanges } from 'cli/ai/site-changes'; import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; +import type { OpenProblems } from 'cli/ai/open-problems'; import type { SiteData } from 'cli/lib/cli-config/core'; let fixture: ReturnType< typeof createSite >; @@ -74,7 +74,7 @@ it.each( [ copies: undefined, }, ] )( 'reports $name', async ( { change, copies } ) => { - const check = new SiteChanges( new Map(), undefined, async () => [ site ], [ childThemeCopies ] ); + const check = new SiteChanges( undefined, async () => [ site ], [ childThemeCopies ] ); const problems = await problemsAfter( check, change ); expect( problems ).toEqual( copies @@ -88,9 +88,13 @@ it.each( [ } ); it( 'reports a copy once, and keeps it open until it is changed or deleted', async () => { - const open = new OpenProblems( undefined, [] ); - const check = new SiteChanges( new Map(), open, async () => [ site ], [ childThemeCopies ] ); - const isOpen = () => Object.keys( open.toJSON().open ).length > 0; + // The problem of each key, as the turn's open problems record it. + const recorded = new Map< string, string | undefined >(); + const open = { + record: ( _site: string, key: string, problem?: string ) => recorded.set( key, problem ), + } as OpenProblems; + const check = new SiteChanges( open, async () => [ site ], [ childThemeCopies ] ); + const isOpen = () => [ ...recorded.values() ].some( Boolean ); // A changed file stays the child's own while neither file changes. write( 'kid/parts/header.html', '' ); From 867eb202e21748b55b6a066ef7032d8001ed5ff4 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:37:42 -0400 Subject: [PATCH 069/199] Type the recorded open problems in the child theme copies test --- apps/cli/ai/tests/child-theme-copies.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/cli/ai/tests/child-theme-copies.test.ts b/apps/cli/ai/tests/child-theme-copies.test.ts index 7e4615a298..a29af58d7e 100644 --- a/apps/cli/ai/tests/child-theme-copies.test.ts +++ b/apps/cli/ai/tests/child-theme-copies.test.ts @@ -91,8 +91,10 @@ it( 'reports a copy once, and keeps it open until it is changed or deleted', asy // The problem of each key, as the turn's open problems record it. const recorded = new Map< string, string | undefined >(); const open = { - record: ( _site: string, key: string, problem?: string ) => recorded.set( key, problem ), - } as OpenProblems; + record: ( _site: string, key: string, problem?: string ) => { + recorded.set( key, problem ); + }, + } as Pick< OpenProblems, 'record' > as OpenProblems; const check = new SiteChanges( open, async () => [ site ], [ childThemeCopies ] ); const isOpen = () => [ ...recorded.values() ].some( Boolean ); From ab670356855c512453cacf1ef459546ea76a7941 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 11:53:47 -0400 Subject: [PATCH 070/199] Note the files any call wrote, and on MCP what changed between calls The theme and tmp files a call wrote are a site change source, so the one snapshot that finds changed post content and theme files also finds them, by a file tool, a shell command or a script, and each gets the write notes. This replaces the second snapshot taken around Bash calls. An MCP client changes sites between calls with its own file tools and shell. Each call now first reports what changed since the last one, where it took a fresh snapshot that absorbed those changes. With the hooks on the clients' file tools gone, this is how those writes are checked. --- apps/cli/ai/runtimes/mcp/served-tool.ts | 27 +++++++++-- .../ai/runtimes/mcp/tests/served-tool.test.ts | 39 ++++++++++++++++ apps/cli/ai/runtimes/pi/index.ts | 34 +++++--------- apps/cli/ai/site-changes.ts | 23 ++++++++-- apps/cli/ai/site-file-writes.ts | 46 +++++++++++++++++++ apps/cli/ai/site-notes.ts | 44 +----------------- apps/cli/ai/tests/pi-runtime.test.ts | 23 +++++++--- apps/cli/ai/tests/post-content-check.test.ts | 2 +- apps/cli/ai/tests/site-file-writes.test.ts | 34 ++++++++++++++ apps/cli/ai/tests/site-notes.test.ts | 30 ------------ 10 files changed, 191 insertions(+), 111 deletions(-) create mode 100644 apps/cli/ai/site-file-writes.ts create mode 100644 apps/cli/ai/tests/site-file-writes.test.ts delete mode 100644 apps/cli/ai/tests/site-notes.test.ts diff --git a/apps/cli/ai/runtimes/mcp/served-tool.ts b/apps/cli/ai/runtimes/mcp/served-tool.ts index 2445fb6c33..19ca5d80bc 100644 --- a/apps/cli/ai/runtimes/mcp/served-tool.ts +++ b/apps/cli/ai/runtimes/mcp/served-tool.ts @@ -1,10 +1,9 @@ +import { NO_CHANGES, type SiteChanges, type SettledChanges } from 'cli/ai/site-changes'; import { studioMessage, validationLines } from 'cli/ai/studio-message'; import { siteOfCall } from 'cli/ai/tools/utils'; -import type { SiteChanges } from 'cli/ai/site-changes'; import type { SiteNotes } from 'cli/ai/site-notes'; import type { AnyStudioAgentTool } from 'cli/ai/tools/define-tool'; -// The site an MCP call works on is the one it names. // Runs a served tool for an MCP call. MCP wants `{content, isError}`; pi-native // handlers throw — translate. export async function runServedTool( @@ -13,11 +12,25 @@ export async function runServedTool( siteNotes: SiteNotes, siteChanges?: SiteChanges ) { - await siteChanges?.snapshot().catch( () => undefined ); + // The client changes sites between calls too, with its own file tools and + // shell, so what changed since the last call is reported with this one. + const since = await settled( siteChanges ); // A call that fails can still have changed content. const changedContent = async () => { - const report = await siteChanges?.settle().catch( () => undefined ); - return report ? validationLines( report ) : []; + const changed = await settled( siteChanges ); + return [ + ...validationLines( { + summaries: [ ...since.summaries, ...changed.summaries ], + problems: [ ...since.problems, ...changed.problems ], + } ), + ...( + await Promise.all( + [ ...new Set( [ ...since.written, ...changed.written ] ) ].map( ( file ) => + siteNotes.afterWrite( file ) + ) + ) + ).flat(), + ]; }; try { const result = await tool.rawHandler( args as never ); @@ -51,3 +64,7 @@ export async function runServedTool( }; } } + +async function settled( siteChanges?: SiteChanges ): Promise< SettledChanges > { + return ( await siteChanges?.settle().catch( () => undefined ) ) ?? NO_CHANGES; +} diff --git a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts index eaac03b0bb..69fa07052f 100644 --- a/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts +++ b/apps/cli/ai/runtimes/mcp/tests/served-tool.test.ts @@ -109,3 +109,42 @@ it( 'reports the block validation of post content the call changed, on any site' /Command exited with code 1\\n\\nBlock validation: Bakery page 9 \\"\\": core\/html policy failed/ ); } ); + +it( 'reports on the next call what the client changed between calls, by its own tools', async () => { + const fixture = createSite(); + fixture.save( 9, 'page', 'home', '\n

Hi

\n' ); + const root = mkdtempSync( path.join( tmpdir(), 'served-tool-' ) ); + const site = { id: '1', name: 'Bakery', path: fixture.root, port: 8881, phpVersion: '8.3' }; + writeFileSync( path.join( root, 'cli.json' ), JSON.stringify( { version: 1, sites: [ site ] } ) ); + vi.stubEnv( 'E2E', '1' ); + vi.stubEnv( 'E2E_CLI_CONFIG_PATH', root ); + const tracked: string[] = []; + const siteNotes = createSiteNotes( { + gate: { track: ( file: string ) => tracked.push( file ) } as never, + } ); + const siteChanges = new SiteChanges(); + const list = defineTool( + 'site_list', + 'Lists sites.', + {}, + async () => ( { content: [ { type: 'text', text: 'Bakery' } ] } ), + { annotations: READ_ONLY } + ); + const call = async () => + ( await runServedTool( list, {}, siteNotes, siteChanges ) ).content.map( ( part ) => + part.type === 'text' ? part.text : '' + ); + + expect( await call() ).toEqual( [ 'Bakery' ] ); + // As the client's own Edit and shell would, outside Studio's tools. + fixture.edit( 9, '\n

Hi

\n' ); + const template = path.join( fixture.root, 'wp-content/themes/bakery/templates/index.html' ); + mkdirSync( path.dirname( template ), { recursive: true } ); + writeFileSync( template, '

Hi

' ); + + expect( ( await call() )[ 1 ] ).toMatch( + /^\nBlock validation: Bakery page 9 "": core\/html policy failed\n/ + ); + expect( tracked ).toEqual( [ template ] ); + expect( await call() ).toEqual( [ 'Bakery' ] ); +} ); diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 1fe876c510..c0cb3833c4 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -46,9 +46,9 @@ import { withDesignSystemPreview } from 'cli/ai/chat-artifacts'; import { isImageGenerationAvailable } from 'cli/ai/image-generation'; import { emitEvent } from 'cli/ai/json-events'; import { describeOpenProblems, getOpenProblems } from 'cli/ai/open-problems'; -import { SiteChanges } from 'cli/ai/site-changes'; +import { NO_CHANGES, SiteChanges } from 'cli/ai/site-changes'; import { readActiveSiteFacts } from 'cli/ai/site-facts'; -import { createSiteNotes, writtenFiles, type SiteNotes } from 'cli/ai/site-notes'; +import { createSiteNotes, type SiteNotes } from 'cli/ai/site-notes'; import { studioMessage, validationLines } from 'cli/ai/studio-message'; import { buildSystemPrompt } from 'cli/ai/system-prompt'; import { resolveStudioToolDefinitions } from 'cli/ai/tools'; @@ -757,9 +757,6 @@ function toToolDefinition( } crossSite?.allow(); const writtenFile = getWrittenFile( tool.name, params, STUDIO_SITES_ROOT ); - // A shell command can write the request's site files too. - const shellWrites = - tool.name === 'Bash' ? await writtenFiles( sitePin.localSitePaths() ) : undefined; const fileWriteRefusal = writtenFile && ( await siteNotes.beforeWrite( writtenFile ) ); if ( fileWriteRefusal ) { throw new Error( fileWriteRefusal ); @@ -778,19 +775,15 @@ function toToolDefinition( } // A call that fails can still have changed content, as a shell // command does that exits non-zero after saving a page. - const changedContent = async () => - checksChanges - ? validationLines( - await siteChanges.settle().catch( () => ( { summaries: [], problems: [] } ) ) - ) - : []; + const settled = async () => + checksChanges ? await siteChanges.settle().catch( () => NO_CHANGES ) : NO_CHANGES; // The validation report was taken from the gate, so it rides on a failure too. const result = await tool .execute( toolCallId, params, signal, onUpdate ) .catch( async ( error: unknown ) => { const report = studioMessage( 'notes', [ ...validationReport, - ...( await changedContent() ), + ...validationLines( await settled() ), ] ); if ( ! report ) { throw error; @@ -807,22 +800,19 @@ function toToolDefinition( pendingWork.add( details.pending ); delete details.pending; } - const changed = await changedContent(); + const changed = await settled(); + // The files the call wrote, by its file tool or otherwise, as a shell command does. + const written = new Set( [ ...( writtenFile ? [ writtenFile ] : [] ), ...changed.written ] ); const resultSite = ( await siteOfCall( tool, params ) )?.path ?? localSite; const resultText = result.content .map( ( part ) => ( part.type === 'text' ? part.text : '' ) ) .join( '\n' ); const reports = studioMessage( 'notes', [ ...validationReport, - ...changed, - ...( writtenFile ? await siteNotes.afterWrite( writtenFile ) : [] ), - ...( shellWrites - ? ( - await Promise.all( - ( await shellWrites() ).map( ( file ) => siteNotes.afterWrite( file ) ) - ) - ).flat() - : [] ), + ...validationLines( changed ), + ...( + await Promise.all( [ ...written ].map( ( file ) => siteNotes.afterWrite( file ) ) ) + ).flat(), ...( resultSite ? await siteNotes.afterResult( resultSite, resultText ) : [] ), pendingWork.takeReports(), ] ); diff --git a/apps/cli/ai/site-changes.ts b/apps/cli/ai/site-changes.ts index cc5fd1d2ea..7ae1db88dd 100644 --- a/apps/cli/ai/site-changes.ts +++ b/apps/cli/ai/site-changes.ts @@ -9,6 +9,7 @@ */ import { childThemeCopies } from 'cli/ai/child-theme-copies'; import { postContentChanges } from 'cli/ai/post-content-check'; +import { siteFileWrites } from 'cli/ai/site-file-writes'; import { updatableThemeChanges } from 'cli/ai/theme-file-changes'; import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; import type { OpenProblems } from 'cli/ai/open-problems'; @@ -16,13 +17,26 @@ import type { OpenProblems } from 'cli/ai/open-problems'; /** Each thing's version on a site, by key. */ export type Versions = Record< string, string >; -/** What a change means: a line for the result, and the problem it leaves, if any. */ +/** + * What a change means: a line for the result, the problem it leaves, if any, + * and the file it wrote, for the write notes. + */ export interface ChangeReport { key: string; summary?: string; problem?: string; + written?: string; } +/** What the calls since the last check changed. */ +export interface SettledChanges { + summaries: string[]; + problems: string[]; + written: string[]; +} + +export const NO_CHANGES: SettledChanges = { summaries: [], problems: [], written: [] }; + export interface SiteChangeSource { name: string; /** The versions on a site now; the last ones read let a source reuse work. */ @@ -39,6 +53,7 @@ export const SITE_CHANGE_SOURCES: SiteChangeSource[] = [ postContentChanges, updatableThemeChanges, childThemeCopies, + siteFileWrites, ]; async function localSites(): Promise< SiteData[] > { @@ -65,9 +80,10 @@ export class SiteChanges { } /** Checks what changed since the snapshot, and snapshots it. */ - async settle(): Promise< { summaries: string[]; problems: string[] } > { + async settle(): Promise< SettledChanges > { const summaries: string[] = []; const problems: string[] = []; + const written: string[] = []; for ( const site of await this.sites() ) { for ( const source of this.sources ) { const key = `${ source.name } ${ site.path }`; @@ -82,9 +98,10 @@ export class SiteChanges { this.open?.record( site.path, `${ site.path }#${ report.key }`, report.problem ); summaries.push( ...( report.summary ? [ report.summary ] : [] ) ); problems.push( ...( report.problem ? [ report.problem ] : [] ) ); + written.push( ...( report.written ? [ report.written ] : [] ) ); } } } - return { summaries, problems }; + return { summaries, problems, written }; } } diff --git a/apps/cli/ai/site-file-writes.ts b/apps/cli/ai/site-file-writes.ts new file mode 100644 index 0000000000..d0be6f5abb --- /dev/null +++ b/apps/cli/ai/site-file-writes.ts @@ -0,0 +1,46 @@ +/** + * The theme and scratch files of a site that a call wrote, by any tool, shell + * command or script, so each gets the write notes a file tool's write gets + * (site-notes.ts): fonts self-hosted, block markup validated, theme.json and + * theme CSS checked. Each file's version is its modified time and size. + */ +import { readdir, stat } from 'fs/promises'; +import path from 'path'; +import type { SiteChangeSource, Versions } from 'cli/ai/site-changes'; + +// The folders of a site whose files the write notes are about. +const NOTED_FOLDERS = [ path.join( 'wp-content', 'themes' ), 'tmp' ]; +const SKIPPED_FOLDERS = new Set( [ 'node_modules', '.git' ] ); + +export const siteFileWrites: SiteChangeSource = { + name: 'files', + async read( sitePath ) { + const versions: Versions = {}; + for ( const folder of NOTED_FOLDERS.map( ( noted ) => path.join( sitePath, noted ) ) ) { + const entries = await readdir( folder, { recursive: true, withFileTypes: true } ).catch( + () => [] + ); + for ( const entry of entries ) { + const file = path.join( entry.parentPath, entry.name ); + if ( + entry.isFile() && + ! path + .relative( folder, file ) + .split( path.sep ) + .some( ( part ) => SKIPPED_FOLDERS.has( part ) ) + ) { + const { mtimeMs, size } = await stat( file ).catch( () => ( { mtimeMs: 0, size: -1 } ) ); + versions[ file ] = `${ mtimeMs }:${ size }`; + } + } + } + return versions; + }, + async check( _site, before, after ) { + return { + reports: Object.entries( after ) + .filter( ( [ file, version ] ) => before[ file ] !== version ) + .map( ( [ file ] ) => ( { key: `file ${ file }`, written: file } ) ), + }; + }, +}; diff --git a/apps/cli/ai/site-notes.ts b/apps/cli/ai/site-notes.ts index 9751c127e1..01a35260a3 100644 --- a/apps/cli/ai/site-notes.ts +++ b/apps/cli/ai/site-notes.ts @@ -6,7 +6,7 @@ * * - `beforeWrite`: a file tool is about to write a site's file. A note here * refuses the write, and says where the change belongs. - * - `write`: a file tool, or a shell command (writtenFiles), wrote a site's + * - `write`: a file tool, or any call (site-file-writes.ts), wrote a site's * file. Sources may act on the file (self-host its fonts, give its theme a * thumbnail) and say what they did, or say what WordPress will render * differently from it. @@ -16,7 +16,6 @@ * fires its trigger: Studio Code fires all three; `studio mcp`, which Claude * Code and Codex use, fires `result`. */ -import { readdir, stat } from 'fs/promises'; import path from 'path'; import { createBlockSettingsNotes } from 'cli/ai/block-settings'; import { readSiteFacts } from 'cli/ai/site-facts'; @@ -152,44 +151,3 @@ export function createSiteNotes( options: SiteNotesOptions = {} ): SiteNotes { }, }; } - -// The folders of a site whose files the write sources have notes on. -const NOTED_FOLDERS = [ path.join( 'wp-content', 'themes' ), 'tmp' ]; -const SKIPPED_FOLDERS = new Set( [ 'node_modules', '.git' ] ); - -async function stamps( sitePaths: string[] ): Promise< Map< string, string > > { - const found = new Map< string, string >(); - for ( const folder of sitePaths.flatMap( ( site ) => - NOTED_FOLDERS.map( ( noted ) => path.join( site, noted ) ) - ) ) { - const entries = await readdir( folder, { recursive: true, withFileTypes: true } ).catch( - () => [] - ); - for ( const entry of entries ) { - const file = path.join( entry.parentPath, entry.name ); - if ( - entry.isFile() && - ! path - .relative( folder, file ) - .split( path.sep ) - .some( ( part ) => SKIPPED_FOLDERS.has( part ) ) - ) { - const { mtimeMs, size } = await stat( file ).catch( () => ( { mtimeMs: 0, size: -1 } ) ); - found.set( file, `${ mtimeMs }:${ size }` ); - } - } - } - return found; -} - -/** - * Stamps the noted files of these sites, and returns what lists the ones - * written since: how a shell command's writes reach the `write` notes. - */ -export async function writtenFiles( sitePaths: string[] ): Promise< () => Promise< string[] > > { - const before = await stamps( sitePaths ); - return async () => - [ ...( await stamps( sitePaths ) ) ] - .filter( ( [ file, stamp ] ) => before.get( file ) !== stamp ) - .map( ( [ file ] ) => file ); -} diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index 4d2dce3d21..40aa089102 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -18,7 +18,7 @@ const mocks = vi.hoisted( () => ( { configRoot: '/tmp/studio-ai-pi-runtime-config', // What ran, in order: tool executions and the post content check. steps: [] as string[], - postChanges: { summaries: [] as string[], problems: [] as string[] }, + postChanges: { summaries: [] as string[], problems: [] as string[], written: [] as string[] }, failNext: false, // What each end of a run finds still open on the sites. openProblems: [] as string[][], @@ -84,12 +84,10 @@ vi.mock( 'cli/ai/site-notes', () => ( { afterWrite: async ( file: string ) => [ `wrote ${ file }` ], afterResult: async ( site: string ) => [ `result on ${ site }` ], } ), - // A shell command writes one theme file on each site it is given. - writtenFiles: async ( sites: string[] ) => async () => - sites.map( ( site ) => `${ site }/wp-content/themes/t/style.css` ), } ) ); vi.mock( 'cli/ai/site-changes', () => ( { + NO_CHANGES: { summaries: [], problems: [], written: [] }, SiteChanges: class { async snapshot() { mocks.steps.push( 'snapshot' ); @@ -563,7 +561,13 @@ describe( 'pi runtime', () => { ).content.map( ( part ) => part.text ) ).toEqual( [ '', `\nwrote ${ file }\nresult on /sites/b\n` ] ); - // A shell command's writes to the request's site get the same notes. + // A file any call wrote gets the same notes, once though a file tool wrote it too. + mocks.postChanges.written = [ file, '/sites/a/wp-content/themes/t/style.css' ]; + expect( await text( { path: 'a/style.css', content: '' } ) ).toEqual( [ + '', + `\nwrote ${ file }\nwrote /sites/a/wp-content/themes/t/style.css\nresult on /sites/a\n`, + ] ); + mocks.postChanges.written = [ '/sites/a/wp-content/themes/t/style.css' ]; await run( local ); expect( ( await getCreatedTool( 'Bash' ).execute( 'b', { command: 'true' } ) ).content.map( @@ -573,13 +577,18 @@ describe( 'pi runtime', () => { '', '\nwrote /sites/a/wp-content/themes/t/style.css\nresult on /sites/a\n', ] ); + mocks.postChanges.written = []; } ); it( 'reports the post content a call changed, from a snapshot taken before it', async () => { mocks.createdSessions.length = 0; await runRuntime( { prompt: 'hello', env: WPCOM_ENV, session: newSession() } ); mocks.steps.length = 0; - mocks.postChanges = { summaries: [ 'page 9 changed' ], problems: [ 'page 9 problem' ] }; + mocks.postChanges = { + summaries: [ 'page 9 changed' ], + problems: [ 'page 9 problem' ], + written: [], + }; const result = await getCreatedTool( 'Bash' ).execute( 'b', { command: 'true' } ); expect( mocks.steps ).toEqual( [ 'snapshot', 'Bash', 'settle' ] ); expect( result.content.map( ( part ) => part.text ) ).toEqual( [ @@ -595,7 +604,7 @@ describe( 'pi runtime', () => { mocks.steps.length = 0; await getCreatedTool( 'Read' ).execute( 'r', { path: 'a/style.css' } ); expect( mocks.steps ).toEqual( [ 'Read' ] ); - mocks.postChanges = { summaries: [], problems: [] }; + mocks.postChanges = { summaries: [], problems: [], written: [] }; } ); it( 'rejects oversized direct Write, Edit, and Bash payloads', async () => { diff --git a/apps/cli/ai/tests/post-content-check.test.ts b/apps/cli/ai/tests/post-content-check.test.ts index 5aa8913792..a9c076d888 100644 --- a/apps/cli/ai/tests/post-content-check.test.ts +++ b/apps/cli/ai/tests/post-content-check.test.ts @@ -74,7 +74,7 @@ describe( 'post content changes', () => { it( 'reports nothing for a site it has not seen before', async () => { const check = checkPosts(); fixture.edit( 9, HTML_ONLY ); - expect( await check.settle() ).toEqual( { summaries: [], problems: [] } ); + expect( await check.settle() ).toEqual( { summaries: [], problems: [], written: [] } ); } ); it( 'asks for editable blocks in place of core/html', async () => { diff --git a/apps/cli/ai/tests/site-file-writes.test.ts b/apps/cli/ai/tests/site-file-writes.test.ts new file mode 100644 index 0000000000..3cf5c1fbee --- /dev/null +++ b/apps/cli/ai/tests/site-file-writes.test.ts @@ -0,0 +1,34 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, expect, it } from 'vitest'; +import { siteFileWrites } from 'cli/ai/site-file-writes'; +import type { SiteData } from 'cli/lib/cli-config/core'; + +let site: string; + +afterEach( () => rmSync( site, { recursive: true, force: true } ) ); + +function write( relativePath: string, content = '' ) { + const file = path.join( site, relativePath ); + mkdirSync( path.dirname( file ), { recursive: true } ); + writeFileSync( file, content ); + return file; +} + +it( 'reports the theme and tmp files written since, and none of the rest', async () => { + site = mkdtempSync( path.join( os.tmpdir(), 'studio-written-' ) ); + const style = write( 'wp-content/themes/t/style.css', 'a' ); + write( 'wp-content/themes/t/theme.json', '{}' ); + const before = await siteFileWrites.read( site ); + write( 'wp-content/themes/t/style.css', 'ab' ); + const page = write( 'tmp/page-home.html' ); + write( 'wp-content/plugins/p/p.php' ); + write( 'wp-content/themes/t/node_modules/x/index.js' ); + const { reports } = await siteFileWrites.check( + {} as SiteData, + before, + await siteFileWrites.read( site ) + ); + expect( reports.map( ( report ) => report.written ).sort() ).toEqual( [ style, page ].sort() ); +} ); diff --git a/apps/cli/ai/tests/site-notes.test.ts b/apps/cli/ai/tests/site-notes.test.ts deleted file mode 100644 index bbdc9e971f..0000000000 --- a/apps/cli/ai/tests/site-notes.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; -import os from 'os'; -import path from 'path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { writtenFiles } from 'cli/ai/site-notes'; - -let site: string; - -afterEach( () => rmSync( site, { recursive: true, force: true } ) ); - -function write( relativePath: string, content = '' ) { - const file = path.join( site, relativePath ); - mkdirSync( path.dirname( file ), { recursive: true } ); - writeFileSync( file, content ); - return file; -} - -describe( 'writtenFiles', () => { - it( 'lists the theme and tmp files written since, and none of the rest', async () => { - site = mkdtempSync( path.join( os.tmpdir(), 'studio-written-' ) ); - const style = write( 'wp-content/themes/t/style.css', 'a' ); - write( 'wp-content/themes/t/theme.json', '{}' ); - const written = await writtenFiles( [ site ] ); - write( 'wp-content/themes/t/style.css', 'ab' ); - const page = write( 'tmp/page-home.html' ); - write( 'wp-content/plugins/p/p.php' ); - write( 'wp-content/themes/t/node_modules/x/index.js' ); - expect( ( await written() ).sort() ).toEqual( [ style, page ].sort() ); - } ); -} ); From aa71f601f70fb3e29291c80e94e88587e62e3b0e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 13:08:52 -0400 Subject: [PATCH 071/199] Validate a page draft as block markup wherever it is written Block markup was a theme template, part or pattern, or an .html file in the site's tmp/. A draft written elsewhere, as one in wp-content/uploads, was validated only once saved as a page. Any file that opens with a block's comment delimiter (get_comment_delimited_block_content()) is now block markup; code that only mentions a delimiter does not open with one. The files a call wrote are found across the site, outside WordPress core, its database, plugin code and project dependencies, where the theme and tmp folders were listed. --- apps/cli/ai/block-validation-gate.ts | 27 ++++++-- apps/cli/ai/site-file-guards.ts | 14 +++-- apps/cli/ai/site-file-writes.ts | 61 +++++++++++-------- .../ai/tests/block-validation-gate.test.ts | 16 +++-- apps/cli/ai/tests/site-file-guards.test.ts | 28 ++++++++- apps/cli/ai/tests/site-file-writes.test.ts | 12 +++- 6 files changed, 113 insertions(+), 45 deletions(-) diff --git a/apps/cli/ai/block-validation-gate.ts b/apps/cli/ai/block-validation-gate.ts index d75e10ca4d..e47d137542 100644 --- a/apps/cli/ai/block-validation-gate.ts +++ b/apps/cli/ai/block-validation-gate.ts @@ -2,8 +2,8 @@ * Block validation as a gate the harness runs, not a tool the model must * remember to call. * - * Writing block markup (a theme template or part, or a page scratch file in the - * site's tmp/) queues that file. Validation starts in the background at once, + * Writing block markup (a theme template or part, or any file that opens with a + * block, as a page draft does wherever it is written) queues that file. Validation starts in the background at once, * and what puts content on the site or renders it (a shell command, which * runs WP-CLI, or take_screenshot) waits for it first. A fix the block editor * can make losslessly is applied to the file silently, once validation has @@ -12,6 +12,7 @@ * has to change itself. */ import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; import { readFile, writeFile } from 'fs/promises'; import path from 'path'; import { validateHtmlBlockPolicy } from 'cli/ai/block-content-policy'; @@ -23,11 +24,27 @@ const SHARED_THEME_MARKUP = /[\\/]wp-content[\\/]themes[\\/][^\\/]+[\\/](parts[\\/][^\\/]+|templates[\\/](?!front-page|home)[^\\/]+)\.html$/; const THEME_MARKUP = /[\\/]wp-content[\\/]themes[\\/][^\\/]+[\\/](templates|parts)[\\/][^\\/]+\.html$/; -const SCRATCH_MARKUP = /[\\/]tmp[\\/][^\\/]+\.html$/; -/** Whether a file holds block markup the gate validates. */ +/** + * Whether content is block markup: it opens with a block's comment delimiter, + * as WordPress serializes a block (get_comment_delimited_block_content()). + * A draft is one wherever it is written; code that only mentions a delimiter + * does not open with one. + */ +export function opensWithBlock( content: string ): boolean { + return content.trimStart().startsWith( '' ); + expect( isBlockMarkupFile( draft ) ).toBe( true ); + // Code that mentions a delimiter, as a block's built script does. + const script = write( 'build/index.js', 'save: () => ""' ); + expect( isBlockMarkupFile( script ) ).toBe( false ); + expect( isBlockMarkupFile( write( 'tmp/page-home.html', '

Hi

' ) ) ).toBe( false ); + expect( isBlockMarkupFile( '/s/missing.html' ) ).toBe( false ); } ); } ); @@ -165,7 +169,7 @@ describe( 'the gate', () => { } ); it( 'reports each validated file once, and revalidates a file that changed', async () => { - const file = write( 'tmp/home.html', 'v1' ); + const file = write( 'wp-content/themes/t/templates/home.html', 'v1' ); const validateFile = vi.fn( async ( filePath: string ) => ok( `validated ${ readFileSync( filePath, 'utf8' ) }`, readFileSync( filePath, 'utf8' ) ) ); @@ -177,7 +181,7 @@ describe( 'the gate', () => { } ); it( 'saves a lossless fix once settled, never over a write made while it validated', async () => { - const file = write( 'tmp/home.html', 'v1' ); + const file = write( 'wp-content/themes/t/templates/home.html', 'v1' ); let finish: () => void = () => {}; const validateFile = vi.fn( async ( filePath: string ) => { const content = readFileSync( filePath, 'utf8' ); diff --git a/apps/cli/ai/tests/site-file-guards.test.ts b/apps/cli/ai/tests/site-file-guards.test.ts index 680753766f..b116d114e2 100644 --- a/apps/cli/ai/tests/site-file-guards.test.ts +++ b/apps/cli/ai/tests/site-file-guards.test.ts @@ -3,7 +3,12 @@ import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { inPageAnchors } from 'cli/ai/block-validation-gate'; -import { clearThemePatternCache, lintThemeCss, resolveSiteFile } from 'cli/ai/site-file-guards'; +import { + blockMarkupNotes, + clearThemePatternCache, + lintThemeCss, + resolveSiteFile, +} from 'cli/ai/site-file-guards'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; vi.mock( 'cli/ai/tools/utils', () => ( { @@ -104,3 +109,24 @@ describe( 'clearThemePatternCache', () => { ] ); } ); } ); + +describe( 'block markup notes', () => { + const trapped = + '
'; + + // A draft is block markup wherever it is written; a file that does not open with a block is not. + it.each( [ + [ 'wp-content/uploads/home.tmp.html', trapped, 1 ], + [ 'tmp/page-home.html', trapped, 1 ], + [ 'notes.md', `Draft:\n${ trapped }`, 0 ], + ] )( '%s gets %s note(s)', ( relativePath, content, count ) => { + const root = mkdtempSync( path.join( os.tmpdir(), 'studio-markup-' ) ); + mkdirSync( path.join( root, 'wp-includes' ) ); + mkdirSync( path.join( root, 'wp-content' ) ); + mkdirSync( path.dirname( path.join( root, relativePath ) ), { recursive: true } ); + writeFileSync( path.join( root, relativePath ), content ); + const file = resolveSiteFile( path.join( root, relativePath ) )!; + expect( blockMarkupNotes( file ) ).toHaveLength( count ); + rmSync( root, { recursive: true, force: true } ); + } ); +} ); diff --git a/apps/cli/ai/tests/site-file-writes.test.ts b/apps/cli/ai/tests/site-file-writes.test.ts index 3cf5c1fbee..38d976e57b 100644 --- a/apps/cli/ai/tests/site-file-writes.test.ts +++ b/apps/cli/ai/tests/site-file-writes.test.ts @@ -16,19 +16,27 @@ function write( relativePath: string, content = '' ) { return file; } -it( 'reports the theme and tmp files written since, and none of the rest', async () => { +it( 'reports the files written since, outside core, the database, plugin code and dependencies', async () => { site = mkdtempSync( path.join( os.tmpdir(), 'studio-written-' ) ); const style = write( 'wp-content/themes/t/style.css', 'a' ); write( 'wp-content/themes/t/theme.json', '{}' ); const before = await siteFileWrites.read( site ); write( 'wp-content/themes/t/style.css', 'ab' ); const page = write( 'tmp/page-home.html' ); + const draft = write( 'wp-content/uploads/home.tmp.html' ); + const notes = write( 'notes.md' ); write( 'wp-content/plugins/p/p.php' ); + write( 'wp-content/mu-plugins/m.php' ); + write( 'wp-includes/version.php' ); + write( 'wp-content/database/.ht.sqlite' ); write( 'wp-content/themes/t/node_modules/x/index.js' ); + write( 'wp-content/themes/t/.git/HEAD' ); const { reports } = await siteFileWrites.check( {} as SiteData, before, await siteFileWrites.read( site ) ); - expect( reports.map( ( report ) => report.written ).sort() ).toEqual( [ style, page ].sort() ); + expect( reports.map( ( report ) => report.written ).sort() ).toEqual( + [ style, page, draft, notes ].sort() + ); } ); From 4edf976fda26104ab10d1ca16ebc9a7357ad1576 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 14:22:17 -0400 Subject: [PATCH 072/199] Check at the end of a turn that header links reach home page sections from every page A bare #features in a template part names an element of whichever page it is on. The note about it was advice the agent could pass over; WordPress now renders the home page and its 404 page, and the links the 404 page has to ids only the home page renders are an open problem, written /#features to work. A Back to top link, whose id renders on every page, stays as written. --- apps/cli/ai/block-validation-gate.ts | 26 ------ apps/cli/ai/home-section-links.ts | 52 +++++++++++ apps/cli/ai/open-problems.ts | 5 +- .../ai/tests/block-validation-gate.test.ts | 15 ---- apps/cli/ai/tests/home-section-links.test.ts | 86 +++++++++++++++++++ apps/cli/ai/tests/site-file-guards.test.ts | 11 --- 6 files changed, 142 insertions(+), 53 deletions(-) create mode 100644 apps/cli/ai/home-section-links.ts create mode 100644 apps/cli/ai/tests/home-section-links.test.ts diff --git a/apps/cli/ai/block-validation-gate.ts b/apps/cli/ai/block-validation-gate.ts index e47d137542..bbc028561a 100644 --- a/apps/cli/ai/block-validation-gate.ts +++ b/apps/cli/ai/block-validation-gate.ts @@ -20,8 +20,6 @@ import { validateBlocks, type ValidationReportBase } from 'cli/ai/block-validato import { getOpenProblems, type OpenProblems } from 'cli/ai/open-problems'; import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config/sites'; -const SHARED_THEME_MARKUP = - /[\\/]wp-content[\\/]themes[\\/][^\\/]+[\\/](parts[\\/][^\\/]+|templates[\\/](?!front-page|home)[^\\/]+)\.html$/; const THEME_MARKUP = /[\\/]wp-content[\\/]themes[\\/][^\\/]+[\\/](templates|parts)[\\/][^\\/]+\.html$/; @@ -47,21 +45,6 @@ export function isBlockMarkupFile( filePath: string ): boolean { } } -/** - * The bare `#anchor` links in block markup. In a template part or shared - * template, which renders on every URL that uses it, such a link points at the - * page it is on: right for a "Back to top" link, wrong for a home page section. - */ -export function inPageAnchors( content: string ): string[] { - return [ - ...new Set( - [ ...content.matchAll( /(?:href="|"url"\s*:\s*")(#[A-Za-z][^"]*)"/g ) ].map( - ( match ) => match[ 1 ] - ) - ), - ]; -} - function hash( content: string ): string { return createHash( 'sha1' ).update( content ).digest( 'hex' ); } @@ -148,15 +131,6 @@ export async function validateBlockMarkupFile( const content = await readFile( filePath, 'utf8' ); const notes: string[] = []; - const anchors = SHARED_THEME_MARKUP.test( filePath ) ? inPageAnchors( content ) : []; - if ( anchors.length > 0 ) { - notes.push( - `${ anchors.join( - ', ' - ) } link(s) to the page they render on, and this file renders on every page that uses it; a link to a home page section is written /#anchor` - ); - } - const result = await validateBlockMarkup( content, label, siteUrl, notes ); if ( result.fixedContent === undefined ) { return { contentHash: hash( content ), summary: result.summary, problem: result.problem }; diff --git a/apps/cli/ai/home-section-links.ts b/apps/cli/ai/home-section-links.ts new file mode 100644 index 0000000000..df9f6fced9 --- /dev/null +++ b/apps/cli/ai/home-section-links.ts @@ -0,0 +1,52 @@ +/** + * Links to a home page section that only work on the home page. A header or + * footer template part renders on every page, so a bare `#features` in it + * names an element of whichever page it is on. WordPress renders the site to + * show which: the links a page other than the home page has, naming an + * element that page lacks and the home page has, go nowhere there. A link to + * an element every page renders, as a "Back to top" link is, works anywhere. + */ +import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config/sites'; + +// A post ID no site has, so WordPress renders its 404 template, which shows +// the header and footer as every page does (WP_Query sets is_404). +const NOT_FOUND = '?p=2147483647'; + +async function render( url: string ): Promise< string | undefined > { + const response = await fetch( url, { signal: AbortSignal.timeout( 10_000 ) } ).catch( + () => undefined + ); + return response?.text().catch( () => undefined ); +} + +// The ids a page renders, which a fragment names (a block's anchor is its id). +function targets( html: string ): Set< string > { + return new Set( [ ...html.matchAll( /\sid="([^"]+)"/g ) ].map( ( match ) => match[ 1 ] ) ); +} + +/** The problem with a site's home section links, while its server renders it. */ +export async function homeSectionLinks( sitePath: string ): Promise< string | undefined > { + const site = await getSiteByFolder( sitePath ).catch( () => undefined ); + if ( ! site ) { + return undefined; + } + const url = getSiteUrl( site ).replace( /\/$/, '' ); + const [ home, other ] = await Promise.all( [ + render( `${ url }/` ), + render( `${ url }/${ NOT_FOUND }` ), + ] ); + if ( ! home || ! other ) { + return undefined; + } + const onHome = targets( home ); + const onOther = targets( other ); + const homeOnly = [ + ...new Set( [ ...other.matchAll( /href="#([^"]+)"/g ) ].map( ( match ) => match[ 1 ] ) ), + ].filter( ( id ) => onHome.has( id ) && ! onOther.has( id ) ); + if ( ! homeOnly.length ) { + return undefined; + } + const links = homeOnly.map( ( id ) => `#${ id }` ).join( ', ' ); + const fixed = homeOnly.map( ( id ) => `/#${ id }` ).join( ', ' ); + return `On every page of ${ url } but the home page, the links ${ links } go nowhere: they render on every page, as a header or footer template part does, and only the home page renders the sections they name. Write them ${ fixed }, so they reach the home page's sections from every page.`; +} diff --git a/apps/cli/ai/open-problems.ts b/apps/cli/ai/open-problems.ts index ff1df0ebc7..2e4d494bcf 100644 --- a/apps/cli/ai/open-problems.ts +++ b/apps/cli/ai/open-problems.ts @@ -2,7 +2,8 @@ * What is still wrong on the sites an agent changed, checked when it ends its * turn: the block validation problems its calls were told about and has not * fixed since, and site state that only shows once every call is done, such as - * a front page whose content WordPress never renders. + * a front page whose content WordPress never renders, or header links to home + * page sections that only work on the home page. * * Each problem is raised once, so the agent decides what to do with it: fix * it, or tell the user why it stays. @@ -14,6 +15,7 @@ import { frontPageTemplateProblem, rendersPageContent, } from 'cli/ai/front-page-template'; +import { homeSectionLinks } from 'cli/ai/home-section-links'; import { readSavedCopies } from 'cli/ai/saved-copies'; import { readWordPressSite } from 'cli/ai/site-database'; import { studioMessage } from 'cli/ai/studio-message'; @@ -32,6 +34,7 @@ export class OpenProblems { constructor( private readonly siteChecks: Array< ( sitePath: string ) => Promise< string | undefined > > = [ unrenderedFrontPage, + homeSectionLinks, ] ) {} diff --git a/apps/cli/ai/tests/block-validation-gate.test.ts b/apps/cli/ai/tests/block-validation-gate.test.ts index f0aed61486..c53dddd4c9 100644 --- a/apps/cli/ai/tests/block-validation-gate.test.ts +++ b/apps/cli/ai/tests/block-validation-gate.test.ts @@ -145,21 +145,6 @@ describe( 'validating a file', () => { expect( result.problem ).toContain( 'core/html' ); expect( validateBlocks ).not.toHaveBeenCalled(); } ); - - it( 'notes in-page links in template parts, and leaves them as written', async () => { - const markup = - 'TopAbout'; - const file = write( 'wp-content/themes/t/parts/footer.html', markup ); - vi.mocked( validateBlocks ).mockResolvedValue( { - totalBlocks: 1, - validBlocks: 1, - invalidBlocks: 0, - results: [], - } ); - const result = await validateBlockMarkupFile( file, 'http://localhost:8881' ); - expect( result.summary ).toContain( '#menu, #top link(s) to the page they render on' ); - expect( readFileSync( file, 'utf8' ) ).toBe( markup ); - } ); } ); describe( 'the gate', () => { diff --git a/apps/cli/ai/tests/home-section-links.test.ts b/apps/cli/ai/tests/home-section-links.test.ts new file mode 100644 index 0000000000..987d63cbb0 --- /dev/null +++ b/apps/cli/ai/tests/home-section-links.test.ts @@ -0,0 +1,86 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { homeSectionLinks } from 'cli/ai/home-section-links'; + +vi.mock( 'cli/lib/cli-config/sites', () => ( { + getSiteByFolder: async ( folder: string ) => { + if ( folder !== '/sites/bakery' ) { + throw new Error( 'not a Studio site' ); + } + return { port: 8881 }; + }, + getSiteUrl: ( site: { port: number } ) => `http://localhost:${ site.port }`, +} ) ); + +afterEach( () => vi.unstubAllGlobals() ); + +const header = + '
FeaturesContactTopAbout
'; +const sections = '
'; + +// The pages WordPress renders, by URL, as the site's server would. +function serve( pages: Record< string, string | undefined > ) { + vi.stubGlobal( + 'fetch', + vi.fn( async ( url: string ) => { + if ( ! ( url in pages ) ) { + throw new Error( `unexpected ${ url }` ); + } + const html = pages[ url ]; + if ( html === undefined ) { + throw new TypeError( 'fetch failed' ); + } + return new Response( html ); + } ) + ); +} + +const HOME = 'http://localhost:8881/'; +const OTHER = 'http://localhost:8881/?p=2147483647'; + +it.each( [ + { + name: 'header links to sections only the home page renders', + home: header + sections, + other: header, + links: '#features, #contact', + }, + { + name: 'links written /#anchor', + home: + header.replaceAll( 'href="#f', 'href="/#f' ).replaceAll( 'href="#c', 'href="/#c' ) + sections, + other: header.replaceAll( 'href="#f', 'href="/#f' ).replaceAll( 'href="#c', 'href="/#c' ), + links: undefined, + }, + { + name: 'a link every page can follow, as the sections render on every page', + home: header + sections, + other: header + sections, + links: undefined, + }, + { + name: 'a link to a section no page renders, which is no home section', + home: header, + other: header, + links: undefined, + }, + { + name: 'a site whose server is not running', + home: undefined, + other: undefined, + links: undefined, + }, +] )( '$name', async ( { home, other, links } ) => { + serve( { [ HOME ]: home, [ OTHER ]: other } ); + const problem = await homeSectionLinks( '/sites/bakery' ); + if ( links ) { + expect( problem ).toContain( `the links ${ links } go nowhere` ); + expect( problem ).toContain( 'Write them /#features, /#contact' ); + } else { + expect( problem ).toBeUndefined(); + } +} ); + +it( 'checks only Studio sites', async () => { + serve( {} ); + expect( await homeSectionLinks( '/elsewhere' ) ).toBeUndefined(); +} ); diff --git a/apps/cli/ai/tests/site-file-guards.test.ts b/apps/cli/ai/tests/site-file-guards.test.ts index b116d114e2..f3c1bc6f02 100644 --- a/apps/cli/ai/tests/site-file-guards.test.ts +++ b/apps/cli/ai/tests/site-file-guards.test.ts @@ -2,7 +2,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { inPageAnchors } from 'cli/ai/block-validation-gate'; import { blockMarkupNotes, clearThemePatternCache, @@ -81,16 +80,6 @@ describe( 'theme CSS lint', () => { } ); } ); -describe( 'in-page anchors', () => { - it( 'lists the bare in-page links in block markup', () => { - expect( - inPageAnchors( - 'VisitAgainAbout' - ) - ).toEqual( [ '#menu', '#visit' ] ); - } ); -} ); - describe( 'clearThemePatternCache', () => { it( 'clears the cache of the theme whose pattern file changed, and only then', async () => { vi.mocked( runWpCliCommand ).mockClear(); From 03bf6c0451827eef78b299080a78196cc091c80f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 14:22:39 -0400 Subject: [PATCH 073/199] Write the home section links test without replaceAll, for the CLI's TypeScript target --- apps/cli/ai/tests/home-section-links.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli/ai/tests/home-section-links.test.ts b/apps/cli/ai/tests/home-section-links.test.ts index 987d63cbb0..2597ffa64d 100644 --- a/apps/cli/ai/tests/home-section-links.test.ts +++ b/apps/cli/ai/tests/home-section-links.test.ts @@ -15,6 +15,7 @@ afterEach( () => vi.unstubAllGlobals() ); const header = '
FeaturesContactTopAbout
'; +const rooted = header.replace( /href="#(features|contact)"/g, 'href="/#$1"' ); const sections = '
'; // The pages WordPress renders, by URL, as the site's server would. @@ -46,9 +47,8 @@ it.each( [ }, { name: 'links written /#anchor', - home: - header.replaceAll( 'href="#f', 'href="/#f' ).replaceAll( 'href="#c', 'href="/#c' ) + sections, - other: header.replaceAll( 'href="#f', 'href="/#f' ).replaceAll( 'href="#c', 'href="/#c' ), + home: rooted + sections, + other: rooted, links: undefined, }, { From e6b805b10dccab422a2a1a487ac7b9e6470d689e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 14:29:54 -0400 Subject: [PATCH 074/199] Load the active theme's style.css where WordPress does not, as Twenty Twenty-Five does WordPress never loads a theme's style.css by itself. When the active theme's style.css holds rules, WordPress runs the theme's hooks to say whether the front end queues it and the editor has it; Studio adds what is missing to the theme's functions.php in Twenty Twenty-Five's form, wp_enqueue_style() on wp_enqueue_scripts and add_editor_style(), and says so. A theme WordPress updates is left as it is. --- apps/cli/ai/site-changes.ts | 3 + apps/cli/ai/tests/theme-stylesheet.test.ts | 173 ++++++++++++++++++ apps/cli/ai/theme-stylesheet.ts | 202 +++++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 apps/cli/ai/tests/theme-stylesheet.test.ts create mode 100644 apps/cli/ai/theme-stylesheet.ts diff --git a/apps/cli/ai/site-changes.ts b/apps/cli/ai/site-changes.ts index 7ae1db88dd..00e534e281 100644 --- a/apps/cli/ai/site-changes.ts +++ b/apps/cli/ai/site-changes.ts @@ -11,6 +11,7 @@ import { childThemeCopies } from 'cli/ai/child-theme-copies'; import { postContentChanges } from 'cli/ai/post-content-check'; import { siteFileWrites } from 'cli/ai/site-file-writes'; import { updatableThemeChanges } from 'cli/ai/theme-file-changes'; +import { themeStylesheet } from 'cli/ai/theme-stylesheet'; import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; import type { OpenProblems } from 'cli/ai/open-problems'; @@ -53,6 +54,8 @@ export const SITE_CHANGE_SOURCES: SiteChangeSource[] = [ postContentChanges, updatableThemeChanges, childThemeCopies, + // Before siteFileWrites, which reports the functions.php it may add to. + themeStylesheet, siteFileWrites, ]; diff --git a/apps/cli/ai/tests/theme-stylesheet.test.ts b/apps/cli/ai/tests/theme-stylesheet.test.ts new file mode 100644 index 0000000000..a512f4ea90 --- /dev/null +++ b/apps/cli/ai/tests/theme-stylesheet.test.ts @@ -0,0 +1,173 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { holdsRules, themeStylesheet, withCode } from 'cli/ai/theme-stylesheet'; +import type { SiteData } from 'cli/lib/cli-config/core'; + +vi.mock( 'cli/lib/daemon-client', () => ( { + connectToDaemon: vi.fn(), + disconnectFromDaemon: vi.fn(), +} ) ); + +it.each( [ + { name: 'a header only', css: '/*\nTheme Name: Bakery\n*/\n', rules: false }, + { + name: 'rules past the header', + css: '/*\nTheme Name: Bakery\n*/\nbody { margin: 0 }', + rules: true, + }, +] )( 'a stylesheet with $name', ( { css, rules } ) => { + expect( holdsRules( css ) ).toBe( rules ); +} ); + +it.each( [ + { name: 'no functions.php', functions: undefined, result: '\n', + result: '\n

Hi

\n', + result: '\n

Hi

\n\n { + expect( withCode( functions, '\nCODE' ) ).toBe( result ); +} ); + +// A real WordPress install to load stylesheets with, when one is given. +const WORDPRESS = process.env.STUDIO_TEST_WORDPRESS; + +describe.runIf( WORDPRESS )( 'the theme stylesheet, loaded by WordPress', () => { + let root: string; + const themes = () => path.join( root, 'wp-content', 'themes' ); + const site = () => ( { id: 'site', name: 'Site', path: root } ) as SiteData; + + beforeEach( () => { + root = mkdtempSync( path.join( os.tmpdir(), 'theme-stylesheet-' ) ); + cpSync( WORDPRESS!, root, { recursive: true } ); + } ); + afterEach( () => rmSync( root, { recursive: true, force: true } ) ); + + const theme = ( slug: string, files: Record< string, string | undefined > ) => { + const dir = path.join( themes(), slug ); + mkdirSync( path.join( dir, 'templates' ), { recursive: true } ); + writeFileSync( path.join( dir, 'templates/index.html' ), '' ); + for ( const [ file, content ] of Object.entries( files ) ) { + writeFileSync( path.join( dir, file ), content ?? '' ); + } + }; + const activate = async ( stylesheet: string, template = stylesheet ) => { + const { DatabaseSync } = await import( 'node:sqlite' ); + const db = new DatabaseSync( path.join( root, 'wp-content/database/.ht.sqlite' ) ); + const set = db.prepare( 'UPDATE wp_options SET option_value = ? WHERE option_name = ?' ); + set.run( stylesheet, 'stylesheet' ); + set.run( template, 'template' ); + db.close(); + }; + // What the source reports when `change` makes a theme active. + const settle = async ( change: () => Promise< void > ) => { + const before = await themeStylesheet.read( root ); + await change(); + return themeStylesheet.check( site(), before, await themeStylesheet.read( root ) ); + }; + const RULES = '/*\nTheme Name: Probe\n*/\nbody { margin: 0 }\n'; + + it( 'adds the loading of a standalone theme’s style.css, as Twenty Twenty-Five does', async () => { + theme( 'probe', { 'style.css': RULES } ); + const { reports, next } = await settle( () => activate( 'probe' ) ); + expect( reports.map( ( report ) => report.summary ) ).toEqual( [ + expect.stringContaining( 'did not reach the front end and the block editor' ), + ] ); + const functions = readFileSync( path.join( themes(), 'probe/functions.php' ), 'utf8' ); + expect( functions ).toContain( "get_parent_theme_file_uri( 'style.css' )" ); + expect( functions ).toContain( "add_editor_style( 'style.css' )" ); + // Studio's own change is the version compared next. + expect( next ).toEqual( await themeStylesheet.read( root ) ); + } ); + + it( 'adds only what a child theme lacks, with get_stylesheet_uri()', async () => { + theme( 'probe-child', { + 'style.css': RULES.replace( '*/', 'Template: twentytwentyfive\n*/' ), + 'functions.php': + " add_editor_style( 'style.css' ) );\n", + } ); + const { reports } = await settle( () => activate( 'probe-child', 'twentytwentyfive' ) ); + expect( reports.map( ( report ) => report.summary ) ).toEqual( [ + expect.stringContaining( 'did not reach the front end.' ), + ] ); + const functions = readFileSync( path.join( themes(), 'probe-child/functions.php' ), 'utf8' ); + expect( functions ).toContain( 'get_stylesheet_uri()' ); + expect( functions.match( /add_editor_style/g ) ).toHaveLength( 1 ); + } ); + + it( 'adds only the editor stylesheet to a theme that enqueues its style.css', async () => { + theme( 'probe', { + 'style.css': RULES, + 'functions.php': + " wp_enqueue_style( 'p', get_stylesheet_uri() ) );\n", + } ); + const { reports } = await settle( () => activate( 'probe' ) ); + expect( reports.map( ( report ) => report.summary ) ).toEqual( [ + expect.stringContaining( 'did not reach the block editor.' ), + ] ); + const functions = readFileSync( path.join( themes(), 'probe/functions.php' ), 'utf8' ); + expect( functions.match( /wp_enqueue_style/g ) ).toHaveLength( 1 ); + } ); + + it( 'says what still does not load when the theme already declares the functions', async () => { + theme( 'probe', { + 'style.css': RULES, + 'functions.php': + ' activate( 'probe' ) ); + expect( reports.map( ( report ) => report.problem ) ).toEqual( [ + expect.stringContaining( 'still do not reach the front end or the block editor' ), + ] ); + } ); + + it.each( [ + { + name: 'a theme that loads its style.css', + slug: 'probe', + files: { + 'style.css': RULES, + 'functions.php': + " wp_enqueue_style( 'p', get_stylesheet_uri() ) );\nadd_action( 'after_setup_theme', fn() => add_editor_style( 'style.css' ) );\n", + }, + }, + { + name: 'a style.css with only its header', + slug: 'probe', + files: { 'style.css': '/*\nTheme Name: Probe\n*/' }, + }, + { name: 'a theme WordPress updates', slug: 'twentytwentythree', files: { 'style.css': RULES } }, + ] )( 'leaves $name as it is', async ( { slug, files } ) => { + theme( slug, files ); + const { reports } = await settle( () => activate( slug ) ); + expect( reports ).toEqual( [] ); + expect( existsSync( path.join( themes(), slug, 'functions.php' ) ) ).toBe( + 'functions.php' in files + ); + } ); + + it( 'checks nothing while the active theme and its files stay as they are', async () => { + theme( 'probe', { 'style.css': RULES } ); + await activate( 'probe' ); + const { reports } = await settle( async () => {} ); + expect( reports ).toEqual( [] ); + expect( existsSync( path.join( themes(), 'probe/functions.php' ) ) ).toBe( false ); + } ); +} ); diff --git a/apps/cli/ai/theme-stylesheet.ts b/apps/cli/ai/theme-stylesheet.ts new file mode 100644 index 0000000000..8f94d434e7 --- /dev/null +++ b/apps/cli/ai/theme-stylesheet.ts @@ -0,0 +1,202 @@ +/** + * The active theme's own style.css, loaded where its rules apply. WordPress + * never loads a theme's style.css by itself: a theme enqueues it on the front + * end, as Twenty Twenty-Five's functions.php does with wp_enqueue_style() on + * wp_enqueue_scripts, and adds it to the block editor with add_editor_style(). + * When the active theme's style.css holds rules WordPress does not load, Studio + * adds what is missing to the theme's functions.php in that same form, and + * says so. + * + * WordPress itself says what loads: the check runs the theme's hooks and reads + * the styles queued (wp_styles()) and the editor stylesheets + * (get_editor_stylesheets()). + */ +import { readFile, stat, writeFile } from 'fs/promises'; +import path from 'path'; +import { readWordPressSite } from 'cli/ai/site-database'; +import { isUpdatableTheme } from 'cli/ai/themes'; +import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; +import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; +import type { SiteChangeSource } from 'cli/ai/site-changes'; +import type { SiteData } from 'cli/lib/cli-config/core'; + +const LOADED = ` +do_action( 'wp_enqueue_scripts' ); +$styles = wp_styles(); +$styles->all_deps( $styles->queue ); +$uri = get_stylesheet_uri(); +$front = false; +foreach ( $styles->to_do as $handle ) { + $src = $styles->registered[ $handle ]->src ?? ''; + $front = $front || ( is_string( $src ) && strtok( $src, '?' ) === $uri ); +} +echo wp_json_encode( array( 'front' => $front, 'editor' => in_array( $uri, get_editor_stylesheets(), true ) ) ); +`; + +interface Loaded { + front: boolean; + editor: boolean; +} + +async function loaded( site: SiteData ): Promise< Loaded > { + try { + await connectToDaemon(); + await using command = await runWpCliCommand( site, [ 'eval', LOADED ] ); + const [ output, exitCode ] = await Promise.all( [ + command.response.stdoutText, + command.response.exitCode, + ] ); + if ( exitCode !== 0 ) { + throw new Error( `wp eval exited ${ exitCode }` ); + } + return JSON.parse( output.slice( output.indexOf( '{' ) ) ) as Loaded; + } finally { + await disconnectFromDaemon(); + } +} + +/** Whether a stylesheet holds rules, past its header comment. */ +export function holdsRules( css: string ): boolean { + return css.replace( /\/\*[\s\S]*?\*\//g, '' ).trim() !== ''; +} + +/** + * The functions.php code that loads a theme's style.css where `missing` says + * it is not, in Twenty Twenty-Five's form. A standalone theme loads its own + * file (get_parent_theme_file_uri(), so a child of it still loads it); a child + * theme loads its style.css with get_stylesheet_uri(), as the Child Themes + * handbook does. + */ +export function loadingCode( slug: string, child: boolean, missing: Loaded ): string { + const prefix = slug.replace( /[^A-Za-z0-9]+/g, '_' ).toLowerCase(); + const src = child ? 'get_stylesheet_uri()' : "get_parent_theme_file_uri( 'style.css' )"; + const front = ` +if ( ! function_exists( '${ prefix }_enqueue_styles' ) ) : + /** + * Enqueues the theme stylesheet on the front. + * + * @return void + */ + function ${ prefix }_enqueue_styles() { + wp_enqueue_style( + '${ slug }-style', + ${ src }, + array(), + wp_get_theme()->get( 'Version' ) + ); + } +endif; +add_action( 'wp_enqueue_scripts', '${ prefix }_enqueue_styles' ); +`; + const editor = ` +if ( ! function_exists( '${ prefix }_editor_style' ) ) : + /** + * Enqueues the theme stylesheet in the editors. + * + * @return void + */ + function ${ prefix }_editor_style() { + add_editor_style( 'style.css' ); + } +endif; +add_action( 'after_setup_theme', '${ prefix }_editor_style' ); +`; + return ( missing.front ? '' : front ) + ( missing.editor ? '' : editor ); +} + +/** functions.php with `code` added at its end, in PHP. */ +export function withCode( functions: string | undefined, code: string ): string { + if ( functions === undefined ) { + return `' ) ) { + return `${ ends.slice( 0, -2 ).trimEnd() }\n${ code }`; + } + // A file that ends outside PHP takes the code in a PHP block of its own. + const inPhp = functions.lastIndexOf( ' functions.lastIndexOf( '?>' ); + return inPhp ? `${ ends }\n${ code }` : `${ functions }\n + stat( file ).then( + ( { mtimeMs, size } ) => `${ mtimeMs }:${ size }`, + () => '-' + ); + +const themesOf = ( sitePath: string ) => + readWordPressSite( sitePath, ( site ) => ( { + stylesheet: String( site.option( 'stylesheet' ) ?? '' ), + template: String( site.option( 'template' ) ?? '' ), + } ) ); + +export const themeStylesheet: SiteChangeSource = { + name: 'theme-stylesheet', + async read( sitePath ) { + const themes = await themesOf( sitePath ); + if ( ! themes?.stylesheet ) { + return {}; + } + const dir = ( theme: string ) => path.join( sitePath, 'wp-content', 'themes', theme ); + const stamps = await Promise.all( [ + stampOf( path.join( dir( themes.stylesheet ), 'style.css' ) ), + stampOf( path.join( dir( themes.stylesheet ), 'functions.php' ) ), + stampOf( path.join( dir( themes.template ), 'functions.php' ) ), + ] ); + return { [ themes.stylesheet ]: `${ themes.template }|${ stamps.join( '|' ) }` }; + }, + async check( site, before, after ) { + const [ theme ] = Object.keys( after ); + if ( ! theme || before[ theme ] === after[ theme ] ) { + return { reports: [] }; + } + const dir = path.join( site.path, 'wp-content', 'themes', theme ); + const css = await readFile( path.join( dir, 'style.css' ), 'utf8' ).catch( () => '' ); + if ( ! holdsRules( css ) || ( await isUpdatableTheme( site.path, theme ) ) ) { + return { reports: [] }; + } + const was = await loaded( site ); + if ( was.front && was.editor ) { + return { reports: [] }; + } + const child = after[ theme ].split( '|' )[ 0 ] !== theme; + const file = path.join( dir, 'functions.php' ); + const functions = await readFile( file, 'utf8' ).catch( () => undefined ); + await writeFile( file, withCode( functions, loadingCode( theme, child, was ) ) ); + const now = await loaded( site ); + const where = [ ! was.front && 'the front end', ! was.editor && 'the block editor' ] + .filter( Boolean ) + .join( ' and ' ); + const key = `stylesheet of ${ theme }`; + const relative = `wp-content/themes/${ theme }/functions.php`; + const next = await themeStylesheet.read( site.path ); + if ( now.front && now.editor ) { + return { + reports: [ + { + key, + summary: `WordPress loads no theme's style.css by itself, so ${ theme }'s rules did not reach ${ where }. Studio added to ${ relative } what Twenty Twenty-Five's functions.php does: wp_enqueue_style() on wp_enqueue_scripts for the front end, add_editor_style() for the editor.`, + }, + ], + next, + }; + } + return { + reports: [ + { + key, + problem: `WordPress loads no theme's style.css by itself, and ${ theme }'s rules still do not reach ${ [ + ! now.front && 'the front end', + ! now.editor && 'the block editor', + ] + .filter( Boolean ) + .join( + ' or ' + ) } after Studio added wp_enqueue_style() and add_editor_style() to ${ relative }, which may already declare the functions it added. Enqueue style.css there, as Twenty Twenty-Five's functions.php does.`, + }, + ], + next, + }; + }, +}; From 048a5e61b9673951b810a615419e2de51b205b99 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 14:32:21 -0400 Subject: [PATCH 075/199] Give shell commands a default timeout of two minutes Pi's bash tool runs a command for as long as it takes unless the call sets a timeout, so a search of the whole disk held a turn for seven minutes. Studio's Bash tool now times a command out after 120 seconds, the default Claude Code's Bash tool has, and its timeout parameter says so; a call that needs longer sets its own. --- apps/cli/ai/runtimes/pi/bash-timeout.ts | 36 +++++++++++++++++++ apps/cli/ai/runtimes/pi/index.ts | 5 ++- .../ai/runtimes/pi/tests/bash-timeout.test.ts | 36 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 apps/cli/ai/runtimes/pi/bash-timeout.ts create mode 100644 apps/cli/ai/runtimes/pi/tests/bash-timeout.test.ts diff --git a/apps/cli/ai/runtimes/pi/bash-timeout.ts b/apps/cli/ai/runtimes/pi/bash-timeout.ts new file mode 100644 index 0000000000..d2c48e9740 --- /dev/null +++ b/apps/cli/ai/runtimes/pi/bash-timeout.ts @@ -0,0 +1,36 @@ +/** + * A default timeout for shell commands. Pi's bash tool runs a command for as + * long as it takes unless the call sets a timeout, so a command that never + * ends, such as a search of the whole disk, holds the turn. Studio gives each + * command the default Claude Code's Bash tool has, two minutes; a call that + * needs longer sets its own. + */ +import { Type, type TSchema } from 'typebox'; +import type { AgentTool } from '@earendil-works/pi-agent-core'; + +export const DEFAULT_TIMEOUT_SECONDS = 120; + +export function withDefaultTimeout< S extends TSchema >( + tool: AgentTool< S >, + seconds = DEFAULT_TIMEOUT_SECONDS +): AgentTool< S > { + const schema = tool.parameters as S & { properties: Record< string, TSchema > }; + // A spread keeps TypeBox's symbol keys, which structuredClone drops. + const parameters = { + ...schema, + properties: { + ...schema.properties, + timeout: Type.Optional( + Type.Number( { + description: `Timeout in seconds (default ${ seconds }); a command that needs longer, such as a large install, sets its own`, + } ) + ), + }, + }; + return { + ...tool, + parameters, + execute: ( id, args, ...rest ) => + tool.execute( id, { ...args, timeout: args.timeout ?? seconds }, ...rest ), + }; +} diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index c0cb3833c4..1fc02dc00f 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -58,6 +58,7 @@ import { createSkillTool } from 'cli/ai/tools/skill'; import { siteOfCall } from 'cli/ai/tools/utils'; import { readCliConfig } from 'cli/lib/cli-config/core'; import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths'; +import { withDefaultTimeout } from './bash-timeout'; import { createConfirmer, describeDestructiveCall, type Confirmer } from './confirmation'; import { withLeakedToolCallMarkupRemoved } from './leaked-tool-call-markup'; import { createMcpTools, isUserMcpTool, readMcpServers } from './mcp-tools'; @@ -867,7 +868,9 @@ function buildAgentTools( designSystemPreview( renameTool( createWriteTool( STUDIO_SITES_ROOT ), 'Write' ) ), designSystemPreview( renameTool( createEditTool( STUDIO_SITES_ROOT ), 'Edit' ) ), renameTool( - createBashTool( STUDIO_SITES_ROOT, { spawnHook: getStudioWpSpawnHook() } ), + withDefaultTimeout( + createBashTool( STUDIO_SITES_ROOT, { spawnHook: getStudioWpSpawnHook() } ) + ), 'Bash' ), renameTool( createGrepTool( STUDIO_SITES_ROOT ), 'Grep' ), diff --git a/apps/cli/ai/runtimes/pi/tests/bash-timeout.test.ts b/apps/cli/ai/runtimes/pi/tests/bash-timeout.test.ts new file mode 100644 index 0000000000..13fcb028ce --- /dev/null +++ b/apps/cli/ai/runtimes/pi/tests/bash-timeout.test.ts @@ -0,0 +1,36 @@ +import os from 'os'; +import { createBashTool } from '@earendil-works/pi-coding-agent'; +import { expect, it } from 'vitest'; +import { withDefaultTimeout } from '../bash-timeout'; + +const bash = withDefaultTimeout( createBashTool( os.tmpdir() ), 1 ); +const run = ( args: { command: string; timeout?: number } ) => + bash.execute( 'call', args, new AbortController().signal ).then( + ( result ) => ( result.content[ 0 ] as { text: string } ).text, + ( error: Error ) => error.message + ); + +it.each( [ + { name: 'a command that ends in time', command: 'echo done', timeout: undefined, output: 'done' }, + { + name: 'a command past the default', + command: 'echo started; sleep 5', + timeout: undefined, + output: 'Command timed out after 1 seconds', + }, + { + name: 'a call that sets a longer timeout', + command: 'sleep 1.5; echo done', + timeout: 5, + output: 'done', + }, +] )( 'runs $name', async ( { command, timeout, output } ) => { + expect( await run( { command, timeout } ) ).toContain( output ); +} ); + +it( 'states the default in the timeout parameter', () => { + expect( + ( bash.parameters.properties.timeout as { description?: string } ).description + ).toContain( 'default 1' ); + expect( bash.parameters.required ).toEqual( [ 'command' ] ); +} ); From ffb8c7ff7abbb323103a6338cafdf5937ac2a610 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 14:59:45 -0400 Subject: [PATCH 076/199] Give models that cannot view a screenshot the page's rendered layout as text --- apps/cli/ai/tests/tools.test.ts | 13 +++- apps/cli/ai/tools/page-layout.ts | 67 +++++++++++++++++++++ apps/cli/ai/tools/screenshot-helpers.ts | 6 ++ apps/cli/ai/tools/take-screenshot.ts | 11 +++- apps/cli/ai/tools/tests/page-layout.test.ts | 60 ++++++++++++++++++ 5 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 apps/cli/ai/tools/page-layout.ts create mode 100644 apps/cli/ai/tools/tests/page-layout.test.ts diff --git a/apps/cli/ai/tests/tools.test.ts b/apps/cli/ai/tests/tools.test.ts index f5cf1c7e51..8dd3d53521 100644 --- a/apps/cli/ai/tests/tools.test.ts +++ b/apps/cli/ai/tests/tools.test.ts @@ -186,9 +186,13 @@ describe( 'Studio AI MCP tools', () => { emulateMedia: vi.fn(), goto: vi.fn(), waitForLoadState: vi.fn().mockResolvedValue( undefined ), - evaluate: vi.fn( async ( _script: unknown, args?: { source?: string } ) => - args?.source ? scaledBuffer.toString( 'base64' ) : documentHeight - ), + // The page layout's script takes its region limit and returns lines. + evaluate: vi.fn( async ( _script: unknown, args?: number | { source?: string } ) => { + if ( typeof args === 'number' ) { + return [ 'main: y 0–900 (900px tall), x 0–1040' ]; + } + return args?.source ? scaledBuffer.toString( 'base64' ) : documentHeight; + } ), addStyleTag: vi.fn(), screenshot: vi.fn().mockResolvedValue( buffer ), close: vi.fn(), @@ -374,6 +378,9 @@ describe( 'Studio AI MCP tools', () => { } as never ); expect( result.content.map( ( block ) => block.type ) ).toEqual( [ 'text' ] ); + expect( result.content[ 0 ] ).toMatchObject( { + text: expect.stringContaining( '\n main: y 0–900 (900px tall), x 0–1040\n' ), + } ); expect( result.studioArtifacts ).toBeUndefined(); } ); diff --git a/apps/cli/ai/tools/page-layout.ts b/apps/cli/ai/tools/page-layout.ts new file mode 100644 index 0000000000..04b8334c99 --- /dev/null +++ b/apps/cli/ai/tools/page-layout.ts @@ -0,0 +1,67 @@ +/** + * A rendered page's layout as text, for a model that cannot view a + * screenshot: where the browser put each region of the page (the block + * theme's header, main and footer, and the blocks main holds) and whether the + * page is wider than its viewport, measured after the page settles. + */ +import type { getSharedBrowser } from 'cli/ai/browser-utils'; + +type Browser = Awaited< ReturnType< typeof getSharedBrowser > >; +type Page = Awaited< ReturnType< Browser[ 'newPage' ] > >; + +// Enough lines for a long page's sections without flooding the result. +const MAX_REGIONS = 40; + +export function pageLayout( page: Page ): Promise< string[] > { + return page.evaluate( ( maxRegions ) => { + // Classes that name a block or the author's own, not the layout WordPress generates. + const generated = /^(is-|has-|wp-container-|wp-elements-|wp-block-[a-z-]+-is-layout-)/; + const label = ( element: Element ) => + element.tagName.toLowerCase() + + ( element.id ? `#${ element.id }` : '' ) + + [ ...element.classList ] + .filter( ( name ) => ! generated.test( name ) ) + .slice( 0, 3 ) + .map( ( name ) => `.${ name }` ) + .join( '' ); + const lines: string[] = []; + let more = 0; + const visit = ( parent: Element, depth: number ) => { + for ( const element of parent.children ) { + const rect = element.getBoundingClientRect(); + if ( rect.width === 0 || rect.height === 0 ) { + continue; + } + if ( lines.length >= maxRegions ) { + more++; + continue; + } + const top = Math.round( rect.top + window.scrollY ); + lines.push( + `${ ' '.repeat( depth ) }${ label( element ) }: y ${ top }–${ + top + Math.round( rect.height ) + } (${ Math.round( rect.height ) }px tall), x ${ Math.round( rect.left ) }–${ Math.round( + rect.right + ) }` + ); + // A region's blocks are a level down, past wrappers that hold one block. + const wraps = ( box: Element ): Element => + box.children.length === 1 ? wraps( box.children[ 0 ] ) : box; + if ( depth === 0 ) { + visit( wraps( element ), 1 ); + } + } + }; + visit( document.querySelector( '.wp-site-blocks' ) ?? document.body, 0 ); + if ( more ) { + lines.push( `…and ${ more } more` ); + } + const width = document.documentElement.scrollWidth; + if ( width > window.innerWidth ) { + lines.push( + `The page is ${ width }px wide in a ${ window.innerWidth }px viewport, so it scrolls sideways.` + ); + } + return lines; + }, MAX_REGIONS ); +} diff --git a/apps/cli/ai/tools/screenshot-helpers.ts b/apps/cli/ai/tools/screenshot-helpers.ts index 167fe3f2ba..956a481d89 100644 --- a/apps/cli/ai/tools/screenshot-helpers.ts +++ b/apps/cli/ai/tools/screenshot-helpers.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { getSharedBrowser } from 'cli/ai/browser-utils'; import { resolveScreenshotDirectory } from 'cli/ai/screenshot-storage'; +import { pageLayout } from 'cli/ai/tools/page-layout'; type Browser = Awaited< ReturnType< typeof getSharedBrowser > >; type Page = Awaited< ReturnType< Browser[ 'newPage' ] > >; @@ -76,6 +77,8 @@ export interface ScreenshotCapture { capturedHeight: number; offset: number; clipped: boolean; + /** The page's layout as text, for a model that cannot view the capture. */ + layout?: string[]; } /** A JPEG copy that fits {@link MODEL_IMAGE_MAX_EDGE_PX}, or undefined when the capture already does. */ @@ -211,6 +214,7 @@ export async function captureScreenshotBuffer( `, } ); + const layout = options.forModel === false ? await pageLayout( page ) : undefined; const dpr = options.deviceScaleFactor ?? 1; const maxCssHeight = Math.floor( MAX_IMAGE_DIMENSION_PX / dpr ); const formatOptions = @@ -240,6 +244,7 @@ export async function captureScreenshotBuffer( capturedHeight: viewport.height, offset: 0, clipped: false, + layout, }; } @@ -274,6 +279,7 @@ export async function captureScreenshotBuffer( capturedHeight, offset, clipped: offset + capturedHeight < documentHeight, + layout, }; } finally { await page.close(); diff --git a/apps/cli/ai/tools/take-screenshot.ts b/apps/cli/ai/tools/take-screenshot.ts index e29a88bd02..32320a1703 100644 --- a/apps/cli/ai/tools/take-screenshot.ts +++ b/apps/cli/ai/tools/take-screenshot.ts @@ -53,7 +53,8 @@ function getCaptureListLabel( return targets.map( getCaptureLabel ).join( ', ' ); } -const TEXT_ONLY_NOTE = 'This model cannot view images, so the capture is not shown to you.'; +const TEXT_ONLY_NOTE = + 'This model cannot view images, so the capture is not shown to you; its layout, as the browser rendered it, is.'; // Text-only models still get the tool, to show the user a page, but no image // block, which they would otherwise describe without seeing. @@ -138,6 +139,7 @@ export function createTakeScreenshotTool( { capturedHeight: capture.capturedHeight, offset: capture.offset, clipped: capture.clipped, + layout: capture.layout, mimeType: screenshotFile.mimeType, mediaWidgetPayload: { type: 'media', @@ -174,8 +176,11 @@ export function createTakeScreenshotTool( { } return `${ label }: captured full page (${ capture.documentHeight }px tall${ shown }).`; }; - const captureLines = captures.map( - ( capture ) => `${ describeCapture( capture ) } Saved to ${ capture.path }` + const captureLines = captures.map( ( capture ) => + [ + `${ describeCapture( capture ) } Saved to ${ capture.path }`, + ...( capture.layout ?? [] ).map( ( line ) => ` ${ line }` ), + ].join( '\n' ) ); const textLines = captures.length === 1 diff --git a/apps/cli/ai/tools/tests/page-layout.test.ts b/apps/cli/ai/tools/tests/page-layout.test.ts new file mode 100644 index 0000000000..8b68ac36b2 --- /dev/null +++ b/apps/cli/ai/tools/tests/page-layout.test.ts @@ -0,0 +1,60 @@ +import { existsSync } from 'fs'; +import { chromium } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { pageLayout } from 'cli/ai/tools/page-layout'; + +type Browser = Awaited< ReturnType< typeof chromium.launch > >; + +// A real browser lays the page out, wherever Playwright's Chromium is installed. +describe.runIf( existsSync( chromium.executablePath() ) )( 'the page layout', () => { + let browser: Browser; + beforeAll( async () => { + browser = await chromium.launch(); + } ); + afterAll( () => browser.close() ); + + const layoutOf = async ( body: string ) => { + const page = await browser.newPage( { viewport: { width: 1000, height: 800 } } ); + await page.setContent( `${ body }` ); + try { + return await pageLayout( page ); + } finally { + await page.close(); + } + }; + + it( "states where each region and main's blocks render, past a wrapper", async () => { + expect( + await layoutOf( `
+

+
+

+
+
+
+
+
` ) + ).toEqual( [ + 'header.wp-block-template-part: y 0–80 (80px tall), x 0–1000', + ' p.wp-block-site-title: y 0–80 (80px tall), x 0–200', + ' nav.wp-block-navigation: y 0–40 (40px tall), x 700–1000', + 'main.wp-block-group: y 80–980 (900px tall), x 0–1000', + ' section#hero.wp-block-cover.poster: y 80–680 (600px tall), x 0–1000', + ' section.wp-block-group: y 680–980 (300px tall), x 250–750', + 'footer: y 980–1080 (100px tall), x 0–1000', + ] ); + } ); + + it( 'says when the page is wider than its viewport', async () => { + expect( await layoutOf( '
' ) ).toEqual( [ + 'div: y 0–10 (10px tall), x 0–1400', + 'The page is 1400px wide in a 1000px viewport, so it scrolls sideways.', + ] ); + } ); + + it( 'counts the regions past its limit', async () => { + const layout = await layoutOf( '

'.repeat( 45 ) ); + expect( layout ).toHaveLength( 41 ); + expect( layout.at( -1 ) ).toBe( '…and 5 more' ); + } ); +} ); From 3cce60ff1f95243ebffed0cb78b88d88d792df5a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 15:57:34 -0400 Subject: [PATCH 077/199] Eval: a multi-page site, a block plugin and a blog, judged on what the site serves --- apps/cli/ai/eval-outcome.ts | 23 +++++++- scripts/eval/outcome.mjs | 74 ++++++++++++++++++++++++++ scripts/eval/outcome.test.mjs | 84 ++++++++++++++++++++++++++++++ scripts/eval/promptfoo.config.yaml | 66 +++++++++++++++++++++++ 4 files changed, 245 insertions(+), 2 deletions(-) diff --git a/apps/cli/ai/eval-outcome.ts b/apps/cli/ai/eval-outcome.ts index 3cd41a1492..733721a677 100644 --- a/apps/cli/ai/eval-outcome.ts +++ b/apps/cli/ai/eval-outcome.ts @@ -86,7 +86,14 @@ $content = array(); foreach ( get_posts( array( 'post_type' => array( 'page', 'post', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_block' ), 'post_status' => 'publish', 'numberposts' => -1 ) ) as $post ) { $blocks = array(); $count( parse_blocks( $post->post_content ), $blocks ); - $content[] = array( 'id' => $post->ID, 'type' => $post->post_type, 'slug' => $post->post_name, 'title' => $post->post_title, 'isFront' => $post->ID === $front, 'blocks' => $blocks, 'content' => $post->post_content ); + $content[] = array( 'id' => $post->ID, 'type' => $post->post_type, 'slug' => $post->post_name, 'title' => $post->post_title, 'renderedTitle' => get_the_title( $post ), 'link' => get_permalink( $post ), 'categories' => 'post' === $post->post_type ? wp_get_post_categories( $post->ID, array( 'fields' => 'names' ) ) : array(), 'isFront' => $post->ID === $front, 'blocks' => $blocks, 'content' => $post->post_content ); +} +$block_types = array(); +foreach ( WP_Block_Type_Registry::get_instance()->get_all_registered() as $name => $type ) { + if ( str_starts_with( $name, 'core/' ) ) { continue; } + $sources = array(); + foreach ( (array) $type->editor_script_handles as $handle ) { $sources[] = wp_scripts()->registered[ $handle ]->src ?? $handle; } + $block_types[] = array( 'name' => $name, 'editorScripts' => $sources ); } $styles = array(); foreach ( WP_Block_Styles_Registry::get_instance()->get_all_registered() as $block => $variations ) { @@ -106,6 +113,7 @@ echo 'OUTCOME_JSON=' . wp_json_encode( array( 'content' => $content, 'products' => post_type_exists( 'product' ) ? (int) wp_count_posts( 'product' )->publish : 0, 'blockStyles' => $styles, + 'blockTypes' => $block_types, 'palette' => $palette, 'mergedStyles' => $merged['styles'] ?? array(), 'userGlobalStyles' => WP_Theme_JSON_Resolver::get_user_data()->get_raw_data(), @@ -200,13 +208,24 @@ async function renderHome( url: string ) { const nav = [ ...html.matchAll( //g ) ] .map( ( m ) => m[ 0 ] ) .join( '\n' ); + const navHrefs = [ ...nav.matchAll( /]*\bhref="([^"]*)"/g ) ].map( ( m ) => m[ 1 ] ); + // Where each of the site's own navigation links leads. + const navTargets: Record< string, { status: number; url: string } > = {}; + for ( const href of new Set( navHrefs ) ) { + const target = new URL( href.replace( /�?38;|&/g, '&' ), url ); + target.hash = ''; + if ( target.origin !== new URL( url ).origin ) continue; + const page = await fetch( target, { redirect: 'follow' } ).catch( () => undefined ); + navTargets[ href ] = { status: page?.status ?? 0, url: page?.url ?? target.href }; + } return { status: response.status, html, phpErrors: [ ...html.matchAll( /(Fatal error|Parse error|Warning|Notice|Deprecated)<\/b>:[^<]*/g ), ].map( ( m ) => m[ 0 ].replace( /<\/?b>/g, '' ) ), - navHrefs: [ ...nav.matchAll( /]*\bhref="([^"]*)"/g ) ].map( ( m ) => m[ 1 ] ), + navHrefs, + navTargets, ids: [ ...html.matchAll( /\bid="([^"]+)"/g ) ].map( ( m ) => m[ 1 ] ), }; } catch ( error ) { diff --git a/scripts/eval/outcome.mjs b/scripts/eval/outcome.mjs index 388e6c244b..ec025c2ce8 100644 --- a/scripts/eval/outcome.mjs +++ b/scripts/eval/outcome.mjs @@ -519,6 +519,80 @@ export const designTokenReachedTheme = check( ( d, vars ) => { : fail( `DESIGN.md ${ vars.token } is ${ value }, but the theme palette does not have it` ); } ); +// The rendered home page, as the capture saved it. +const homeHtml = ( s ) => ( s.home?.htmlFile ? readFileSync( s.home.htmlFile, 'utf8' ) : '' ); +const pathOf = ( url ) => new URL( url ).pathname.replace( /\/?$/, '/' ); + +// Every published page besides the home page is in the rendered navigation, +// and its link opens that page. +export const menuReachesPages = check( ( d, vars ) => { + const s = site( d, vars ); + const pages = ( s.content || [] ).filter( ( c ) => c.type === 'page' && ! c.isFront ); + if ( pages.length < vars.minPages ) + return fail( + `${ pages.length } published page(s) besides the home page: ${ JSON.stringify( + pages.map( ( p ) => p.title ) + ) }` + ); + const reached = Object.values( s.home?.navTargets || {} ) + .filter( ( t ) => t.status === 200 ) + .map( ( t ) => pathOf( t.url ) ); + const missing = pages.filter( ( p ) => ! reached.includes( pathOf( p.link ) ) ); + if ( missing.length ) + return fail( + `the navigation does not open ${ missing + .map( ( p ) => `"${ p.title }"` ) + .join( ', ' ) }; it leads to ${ JSON.stringify( s.home?.navTargets ) }` + ); + return pass( `the navigation opens ${ pages.map( ( p ) => `"${ p.title }"` ).join( ', ' ) }` ); +} ); + +// The home page shows a block that a plugin registers with WordPress, with an +// editor script so editors can insert it, and it renders `vars.text`. +export const pluginBlockRendered = check( ( d, vars ) => { + const s = site( d, vars ); + const used = Object.keys( frontPage( s )?.blocks || {} ); + const fromPlugin = ( s.blockTypes || [] ).filter( + ( t ) => + used.includes( t.name ) && + t.editorScripts.some( ( src ) => String( src ).includes( '/wp-content/plugins/' ) ) + ); + if ( ! fromPlugin.length ) + return fail( + `the home page uses no block a plugin registers with an editor script; it uses ${ JSON.stringify( + used + ) }, and plugins register ${ JSON.stringify( s.blockTypes ) }` + ); + if ( ! homeHtml( s ).includes( vars.text ) ) + return fail( `the rendered home page does not show "${ vars.text }"` ); + return pass( `the home page shows ${ fromPlugin.map( ( t ) => t.name ).join( ', ' ) }` ); +} ); + +// At least `vars.minPosts` new posts, each category in `vars.categories` used, +// and the rendered home page lists every new post. +export const blogPostsOnHome = check( ( d, vars ) => { + const s = site( d, vars ); + const posts = ( s.content || [] ).filter( + ( c ) => c.type === 'post' && c.slug !== 'hello-world' + ); + if ( posts.length < vars.minPosts ) return fail( `${ posts.length } new published post(s)` ); + const empty = vars.categories + .map( ( c ) => c.name ) + .filter( ( name ) => ! posts.some( ( p ) => p.categories.includes( name ) ) ); + if ( empty.length ) return fail( `no new post is filed under ${ empty.join( ', ' ) }` ); + const html = homeHtml( s ); + const unlisted = posts.filter( ( p ) => ! html.includes( p.renderedTitle ) ); + if ( unlisted.length ) + return fail( + `the home page does not list ${ unlisted.map( ( p ) => `"${ p.title }"` ).join( ', ' ) }` + ); + return pass( + `${ posts.length } posts in ${ vars.categories + .map( ( c ) => c.name ) + .join( ', ' ) }, all on the home page` + ); +} ); + // Routing: every skill in `vars.skills` was loaded before the run's stop call // (`vars.stopAt`), that is, before the agent started the work. A run that never // reached a stop call is judged on all of its calls. diff --git a/scripts/eval/outcome.test.mjs b/scripts/eval/outcome.test.mjs index 8e532bac0b..4e6cc3ef54 100644 --- a/scripts/eval/outcome.test.mjs +++ b/scripts/eval/outcome.test.mjs @@ -492,6 +492,90 @@ describe( 'site checks', () => { } ); } ); +describe( 'new-case checks', () => { + const homeFile = ( html ) => { + const file = path.join( dir, `home-${ count++ }.html` ); + writeFileSync( file, html ); + return file; + }; + const page = ( title, link, extra = {} ) => ( { + type: 'page', + title, + link, + blocks: {}, + ...extra, + } ); + + test( 'menuReachesPages', () => { + const site = ( navTargets, pages = [ 'about', 'classes' ] ) => ( { + ...workingSite, + content: [ + page( 'Home', 'http://localhost:8881/', { isFront: true } ), + ...pages.map( ( slug ) => page( slug, `http://localhost:8881/${ slug }/` ) ), + ], + home: { ...workingSite.home, navTargets }, + } ); + const opens = ( slug, status = 200 ) => ( { + status, + url: `http://localhost:8881/${ slug }`, + } ); + const vars = { minPages: 2 }; + const judge = ( s ) => run( checks.menuReachesPages, outcome( s ), vars ).pass; + expect( + judge( site( { '/about': opens( 'about' ), '?page_id=7': opens( 'classes' ) } ) ) + ).toBe( true ); + expect( judge( site( { '/about': opens( 'about' ) } ) ) ).toBe( false ); + expect( + judge( site( { '/about': opens( 'about' ), '/classes': opens( 'classes', 404 ) } ) ) + ).toBe( false ); + expect( judge( site( { '/about': opens( 'about' ) }, [ 'about' ] ) ) ).toBe( false ); + } ); + + test( 'pluginBlockRendered', () => { + const site = ( editorScripts, html = '

Monday 9am

', blocks = { 'acme/hours': 1 } ) => ( { + ...workingSite, + content: [ page( 'Home', '/', { isFront: true, blocks } ) ], + blockTypes: [ { name: 'acme/hours', editorScripts } ], + home: { ...workingSite.home, htmlFile: homeFile( html ) }, + } ); + const plugin = [ 'http://localhost:8881/wp-content/plugins/acme/build/index.js' ]; + const vars = { text: 'Monday' }; + const judge = ( s ) => run( checks.pluginBlockRendered, outcome( s ), vars ).pass; + expect( judge( site( plugin ) ) ).toBe( true ); + expect( judge( site( [] ) ) ).toBe( false ); + expect( judge( site( [ 'http://localhost:8881/wp-content/themes/acme/index.js' ] ) ) ).toBe( + false + ); + expect( judge( site( plugin, '

Tuesday

' ) ) ).toBe( false ); + expect( judge( site( plugin, undefined, { 'core/paragraph': 1 } ) ) ).toBe( false ); + } ); + + test( 'blogPostsOnHome', () => { + const post = ( title, categories ) => ( { + type: 'post', + slug: title.toLowerCase(), + title, + renderedTitle: `${ title } ’s`, + categories, + } ); + const site = ( posts, html ) => ( { + ...workingSite, + content: [ post( 'Hello-world', [ 'Uncategorized' ] ), ...posts ].map( ( p, i ) => + i === 0 ? { ...p, slug: 'hello-world' } : p + ), + home: { ...workingSite.home, htmlFile: homeFile( html ) }, + } ); + const posts = [ post( 'Rome', [ 'Europe' ] ), post( 'Kyoto', [ 'Asia' ] ) ]; + const listed = '

Rome ’s

Kyoto ’s

'; + const vars = { minPosts: 2, categories: [ { name: 'Europe' }, { name: 'Asia' } ] }; + const judge = ( s ) => run( checks.blogPostsOnHome, outcome( s ), vars ).pass; + expect( judge( site( posts, listed ) ) ).toBe( true ); + expect( judge( site( posts.slice( 0, 1 ), listed ) ) ).toBe( false ); + expect( judge( site( [ posts[ 0 ], post( 'Kyoto', [ 'Europe' ] ) ], listed ) ) ).toBe( false ); + expect( judge( site( posts, '

Rome ’s

' ) ) ).toBe( false ); + } ); +} ); + describe( 'routing checks', () => { const skill = ( name ) => ( { name: 'Skill', input: { name } } ); const vars = { skills: [ { name: 'block-content' }, { name: 'imagery' } ] }; diff --git a/scripts/eval/promptfoo.config.yaml b/scripts/eval/promptfoo.config.yaml index 63bf541cc6..15d7809400 100644 --- a/scripts/eval/promptfoo.config.yaml +++ b/scripts/eval/promptfoo.config.yaml @@ -651,6 +651,72 @@ tests: # costs only the turns up to that point, and `skillsLoadedFirst` checks the # skills named in `skills` were loaded before it. The new-site cases also stop # at the site's creation. The outcome cases above judge the work itself. + # Cases written before any fix for them, to see whether the agent holds up + # on work the other cases do not cover: a multi-page site, a plugin, a blog. + - description: a multi-page site's menu opens every page + vars: + caseId: multi-page-site-menu + siteName: 'Eval Yoga Studio' + minPages: 3 + timeoutMs: 600000 + prompt: | + Build a site for a small yoga studio named "Eval Yoga Studio" with + four pages — Home, Classes, About, and Contact — and a header menu + that links to each page. Make every choice yourself: nobody can + answer questions during this run. + assert: + - type: javascript + value: file://outcome.mjs:siteWorks + - type: javascript + value: file://outcome.mjs:builtFrontPage + - type: javascript + value: file://outcome.mjs:menuReachesPages + - type: javascript + value: file://outcome.mjs:otherSitesUntouched + + - description: a custom block from a new plugin is on the home page + vars: + caseId: custom-block-plugin + siteName: 'Eval Opening Hours' + text: Monday + timeoutMs: 600000 + prompt: | + Create a new site named "Eval Opening Hours". Write a small plugin + for it that adds an "Opening hours" block, which editors can insert + from the block inserter and edit the hours in. Put the block on the + home page with the hours Monday to Friday, 9am to 5pm. Make every + choice yourself: nobody can answer questions during this run. + assert: + - type: javascript + value: file://outcome.mjs:siteWorks + - type: javascript + value: file://outcome.mjs:pluginBlockRendered + - type: javascript + value: file://outcome.mjs:otherSitesUntouched + + - description: a blog's new posts are filed in their categories and listed on the home page + vars: + caseId: travel-blog-posts + minPosts: 3 + categories: + - name: Europe + - name: Asia + timeoutMs: 600000 + seed: + activeSite: + name: Eval Travel Blog + prompt: | + Turn this site into my travel blog: write three short posts about + trips, filed under the categories Europe and Asia (at least one in + each), and make the home page show the latest posts. + assert: + - type: javascript + value: file://outcome.mjs:siteWorks + - type: javascript + value: file://outcome.mjs:blogPostsOnHome + - type: javascript + value: file://outcome.mjs:otherSitesUntouched + - description: 'routing: a request to make a site loads visual-design before creating it' vars: caseId: routing-new-site-make From 89403051ae158d7d5bd7e5b60cd2afb5ba11b099 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 16:00:06 -0400 Subject: [PATCH 078/199] Check at the end of a turn that the front page is not also the posts page --- apps/cli/ai/open-problems.ts | 26 +++++++++++++++++++++ apps/cli/ai/tests/open-problems.test.ts | 30 ++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/cli/ai/open-problems.ts b/apps/cli/ai/open-problems.ts index 2e4d494bcf..3edfe18cb0 100644 --- a/apps/cli/ai/open-problems.ts +++ b/apps/cli/ai/open-problems.ts @@ -34,6 +34,7 @@ export class OpenProblems { constructor( private readonly siteChecks: Array< ( sitePath: string ) => Promise< string | undefined > > = [ unrenderedFrontPage, + frontPageIsPostsPage, homeSectionLinks, ] ) {} @@ -91,6 +92,31 @@ export function getOpenProblems( session: object ): OpenProblems { return open; } +/** + * Settings → Reading naming one page as both the front page and the posts + * page, which its screen warns against (wp-admin/options-reading.php). + */ +export async function frontPageIsPostsPage( sitePath: string ): Promise< string | undefined > { + const page = await readWordPressSite( sitePath, ( site ) => { + const front = site.option( 'page_on_front' ); + const same = + site.option( 'show_on_front' ) === 'page' && + Number( front ) > 0 && + site.option( 'page_for_posts' ) === front; + if ( ! same ) { + return undefined; + } + const [ post ] = site.query< { title: string } >( + `SELECT post_title AS title FROM ${ site.prefix }posts WHERE ID = ?`, + Number( front ) + ); + return { id: front, title: post?.title ?? '' }; + } ); + return page + ? `Settings → Reading names page ${ page.id } "${ page.title }" as both the front page (page_on_front) and the posts page (page_for_posts), which its screen warns against ("these pages should not be the same!", wp-admin/options-reading.php): WordPress then queries the posts index at the front page instead of that page (WP_Query::parse_query in wp-includes/class-wp-query.php). Set page_for_posts to another page, or to 0 for none.` + : undefined; +} + /** * A front-page template of the active theme that renders no page's content, * which WordPress shows at the front page whatever Settings → Reading picks diff --git a/apps/cli/ai/tests/open-problems.test.ts b/apps/cli/ai/tests/open-problems.test.ts index 1bfdc19c0a..8275addd85 100644 --- a/apps/cli/ai/tests/open-problems.test.ts +++ b/apps/cli/ai/tests/open-problems.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { describeOpenProblems, OpenProblems } from 'cli/ai/open-problems'; +import { describeOpenProblems, frontPageIsPostsPage, OpenProblems } from 'cli/ai/open-problems'; +import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; describe( 'OpenProblems', () => { it( 'raises each problem still open once, and the problems of the sites changed', async () => { @@ -28,3 +29,30 @@ describe( 'OpenProblems', () => { ); } ); } ); + +describe( 'frontPageIsPostsPage', () => { + it.each( [ + [ 'the same page', { show_on_front: 'page', page_on_front: '7', page_for_posts: '7' }, true ], + [ + 'another posts page', + { show_on_front: 'page', page_on_front: '7', page_for_posts: '8' }, + false, + ], + [ + 'latest posts at the front', + { show_on_front: 'posts', page_on_front: '7', page_for_posts: '7' }, + false, + ], + [ 'no front page', { show_on_front: 'page', page_on_front: '0', page_for_posts: '0' }, false ], + ] )( '%s', async ( _name, options, raised ) => { + const site = createSite(); + site.save( 7, 'page', 'home', '', 1, 'publish', 'Home' ); + site.setOptions( options ); + const problem = await frontPageIsPostsPage( site.root ); + expect( problem ).toBe( + raised + ? 'Settings → Reading names page 7 "Home" as both the front page (page_on_front) and the posts page (page_for_posts), which its screen warns against ("these pages should not be the same!", wp-admin/options-reading.php): WordPress then queries the posts index at the front page instead of that page (WP_Query::parse_query in wp-includes/class-wp-query.php). Set page_for_posts to another page, or to 0 for none.' + : undefined + ); + } ); +} ); From 260a54a2c9c4db3730f8276dd01e14e411fc3107 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 16:03:23 -0400 Subject: [PATCH 079/199] Self-host the font a theme.json family asks for first, not its label, and say when Google Fonts does not serve it --- apps/cli/ai/site-file-guards.ts | 4 +- apps/cli/ai/tests/site-file-guards.test.ts | 72 +++++++++++++++++++++- packages/common/lib/theme-fonts.ts | 21 ++++--- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/apps/cli/ai/site-file-guards.ts b/apps/cli/ai/site-file-guards.ts index 1d47a9caab..a933997e59 100644 --- a/apps/cli/ai/site-file-guards.ts +++ b/apps/cli/ai/site-file-guards.ts @@ -268,8 +268,8 @@ export async function selfHostFonts( siteFile: SiteFile ): Promise< string[] > { ] : [] ), ...failed.map( - ( { family, error } ) => - `${ family } has no font files in ${ themePath }/theme.json, and Google Fonts did not provide them (${ error }). Put its files in ${ themePath }/assets/fonts and declare them as its fontFace, or choose a Google Fonts family.` + ( { family, font, error } ) => + `${ family } in ${ themePath }/theme.json asks first for ${ font }, which Google Fonts does not serve (${ error }), so the font each visitor sees is whichever their device has. Twenty Twenty-Five ships each family's files in the theme and declares them as its fontFace: put ${ font }'s files in ${ themePath }/assets/fonts and declare them, or choose a Google Fonts family, which Studio downloads.` ), ]; } diff --git a/apps/cli/ai/tests/site-file-guards.test.ts b/apps/cli/ai/tests/site-file-guards.test.ts index f3c1bc6f02..1cdaa62928 100644 --- a/apps/cli/ai/tests/site-file-guards.test.ts +++ b/apps/cli/ai/tests/site-file-guards.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -7,6 +7,7 @@ import { clearThemePatternCache, lintThemeCss, resolveSiteFile, + selfHostFonts, } from 'cli/ai/site-file-guards'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; @@ -119,3 +120,72 @@ describe( 'block markup notes', () => { rmSync( root, { recursive: true, force: true } ); } ); } ); + +describe( 'self-hosted theme fonts', () => { + afterEach( () => vi.unstubAllGlobals() ); + + it( "downloads each family's first font from Google Fonts, and names the fonts it does not serve", async () => { + const requested: string[] = []; + vi.stubGlobal( 'fetch', async ( url: string ) => { + requested.push( url ); + if ( url.includes( 'family=Manrope:' ) ) { + return new Response( + [ 400, 700 ] + .map( + ( weight ) => + `/* latin */ @font-face { font-family: 'Manrope'; font-style: normal; font-weight: ${ weight }; src: url(https://fonts.gstatic.com/manrope.woff2) format('woff2'); }` + ) + .join( '\n' ) + ); + } + if ( url === 'https://fonts.gstatic.com/manrope.woff2' ) { + return new Response( 'woff2' ); + } + return new Response( '', { status: 400 } ); + } ); + const themeJson = write( + 'wp-content/themes/mine/theme.json', + JSON.stringify( { + settings: { + typography: { + fontFamilies: [ + { name: 'Body', slug: 'body', fontFamily: 'Manrope, sans-serif' }, + { name: 'System Sans', slug: 'system', fontFamily: '-apple-system, sans-serif' }, + { name: 'Held', slug: 'held', fontFamily: 'Held', fontFace: [ {} ] }, + ], + }, + }, + styles: { elements: { h1: { typography: { fontWeight: '700' } } } }, + } ) + ); + + expect( await selfHostFonts( resolveSiteFile( themeJson )! ) ).toEqual( [ + 'Studio downloaded the font files of Body from Google Fonts into wp-content/themes/mine/assets/fonts and declared them as their fontFace in theme.json.', + "System Sans in wp-content/themes/mine/theme.json asks first for -apple-system, which Google Fonts does not serve (https://fonts.googleapis.com/css2?family=-apple-system:wght@400;700&display=swap responded with HTTP 400), so the font each visitor sees is whichever their device has. Twenty Twenty-Five ships each family's files in the theme and declares them as its fontFace: put -apple-system's files in wp-content/themes/mine/assets/fonts and declare them, or choose a Google Fonts family, which Studio downloads.", + ] ); + expect( requested ).toEqual( [ + 'https://fonts.googleapis.com/css2?family=Manrope:wght@400;700&display=swap', + 'https://fonts.gstatic.com/manrope.woff2', + 'https://fonts.googleapis.com/css2?family=-apple-system:wght@400;700&display=swap', + ] ); + const families = JSON.parse( readFileSync( themeJson, 'utf8' ) ).settings.typography + .fontFamilies; + expect( families[ 0 ].fontFace ).toEqual( [ + { + fontFamily: 'Manrope', + fontStyle: 'normal', + fontWeight: '400 700', + src: [ 'file:./assets/fonts/manrope/manrope-400-700-latin.woff2' ], + }, + ] ); + expect( + readFileSync( + path.join( + site, + 'wp-content/themes/mine/assets/fonts/manrope/manrope-400-700-latin.woff2' + ), + 'utf8' + ) + ).toBe( 'woff2' ); + } ); +} ); diff --git a/packages/common/lib/theme-fonts.ts b/packages/common/lib/theme-fonts.ts index 2793d9b11f..a9fa5018c0 100644 --- a/packages/common/lib/theme-fonts.ts +++ b/packages/common/lib/theme-fonts.ts @@ -138,8 +138,8 @@ interface FontFamilyPreset { export interface SelfHostedFonts { /** Families whose font files were downloaded and declared. */ hosted: string[]; - /** Families Google Fonts had no files for, with why. */ - failed: { family: string; error: string }[]; + /** Families whose first font Google Fonts had no files for, with why. */ + failed: { family: string; font: string; error: string }[]; } // Every weight a theme.json's styles set, and 400. Styles inherit their font @@ -171,16 +171,16 @@ export async function selfHostThemeJsonFonts( themeDir: string ): Promise< SelfH const families: FontFamilyPreset[] = themeJson?.settings?.typography?.fontFamilies ?? []; const result: SelfHostedFonts = { hosted: [], failed: [] }; for ( const family of families ) { - const name = - family.name ?? - family.fontFamily - ?.split( ',' )[ 0 ] - .trim() - .replace( /^['"]|['"]$/g, '' ); - if ( family.fontFace?.length || ! name ) { + // The font the browser asks for first; the preset's name is only its label. + const font = family.fontFamily + ?.split( ',' )[ 0 ] + .trim() + .replace( /^['"]|['"]$/g, '' ); + const name = family.name ?? font; + if ( family.fontFace?.length || ! font || ! name ) { continue; } - const url = `https://fonts.googleapis.com/css2?family=${ name.replace( + const url = `https://fonts.googleapis.com/css2?family=${ font.replace( / /g, '+' ) }:wght@${ weightsOf( themeJson.styles ).join( ';' ) }&display=swap`; @@ -190,6 +190,7 @@ export async function selfHostThemeJsonFonts( themeDir: string ): Promise< SelfH } catch ( error ) { result.failed.push( { family: name, + font, error: error instanceof Error ? error.message : String( error ), } ); } From b7b5745dd54f4ae475c5626e5027c2c53a5b68c5 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 16:21:15 -0400 Subject: [PATCH 080/199] Store colors written as palette values as the palette's presets, as the block editor does --- apps/cli/ai/block-validation-gate.ts | 67 ++++++++--- apps/cli/ai/block-validator.ts | 105 ++++++++++++++++++ apps/cli/ai/post-content-check.ts | 29 ++++- .../ai/tests/block-validation-gate.test.ts | 34 +++++- apps/cli/ai/tests/block-validator.test.ts | 56 +++++++++- apps/cli/ai/tests/post-content-check.test.ts | 32 +++++- apps/cli/ai/theme-presets.ts | 30 +++-- 7 files changed, 316 insertions(+), 37 deletions(-) diff --git a/apps/cli/ai/block-validation-gate.ts b/apps/cli/ai/block-validation-gate.ts index bbc028561a..3153b2cd24 100644 --- a/apps/cli/ai/block-validation-gate.ts +++ b/apps/cli/ai/block-validation-gate.ts @@ -16,8 +16,15 @@ import { readFileSync } from 'fs'; import { readFile, writeFile } from 'fs/promises'; import path from 'path'; import { validateHtmlBlockPolicy } from 'cli/ai/block-content-policy'; -import { validateBlocks, type ValidationReportBase } from 'cli/ai/block-validator'; +import { + storePaletteColors, + validateBlocks, + type PaletteColorsResult, + type ValidationReportBase, +} from 'cli/ai/block-validator'; import { getOpenProblems, type OpenProblems } from 'cli/ai/open-problems'; +import { readWordPressSite } from 'cli/ai/site-database'; +import { themePresets } from 'cli/ai/theme-presets'; import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config/sites'; const THEME_MARKUP = @@ -120,25 +127,49 @@ export interface MarkupValidation { /** What the block editor would save instead, when that loses nothing. */ fixedContent?: string; invalidBlocks?: number; + /** The palette colors the fix stores as presets. */ + paletteColors?: PaletteColorsResult[ 'colors' ]; +} + +/** How the editor stores colors written as palette values, for a note. */ +export function describePaletteColors( colors: PaletteColorsResult[ 'colors' ] ): string { + const each = [ + ...new Set( colors.map( ( { color, slug } ) => `${ color } as the palette's ${ slug }` ) ), + ]; + return `stores ${ each.join( + ', ' + ) }, as the editor stores a palette color (encodeColorValueWithPalette in @wordpress/block-editor), so it follows the palette`; +} + +/** The colors of a site's palette, as WordPress resolves them. */ +export async function sitePalette( siteRoot: string ): Promise< Array< [ string, string ] > > { + return ( + ( await readWordPressSite( siteRoot, themePresets ).catch( () => undefined ) )?.colors ?? [] + ); } /** Validates one block markup file, with the lossless fix for the caller to save. */ export async function validateBlockMarkupFile( filePath: string, - siteUrl: string + siteUrl: string, + palette: Array< [ string, string ] > = [] ): Promise< FileValidation > { const label = filePath.split( path.sep ).slice( -2 ).join( '/' ); const content = await readFile( filePath, 'utf8' ); - const notes: string[] = []; - const result = await validateBlockMarkup( content, label, siteUrl, notes ); + const result = await validateBlockMarkup( content, label, siteUrl, palette ); if ( result.fixedContent === undefined ) { return { contentHash: hash( content ), summary: result.summary, problem: result.problem }; } - notes.push( `saved through the block editor, fixing ${ result.invalidBlocks } block(s)` ); + const notes = [ + result.invalidBlocks ? `fixes ${ result.invalidBlocks } block(s)` : undefined, + result.paletteColors?.length ? describePaletteColors( result.paletteColors ) : undefined, + ].filter( Boolean ); return { contentHash: hash( content ), - summary: `${ result.summary }; ${ notes.join( '; ' ) }`, + summary: `${ result.summary }; saved through the block editor, which ${ notes.join( + ' and ' + ) }`, fixedContent: result.fixedContent, }; } @@ -152,7 +183,7 @@ export async function validateBlockMarkup( content: string, label: string, siteUrl: string, - notes: string[] = [] + palette: Array< [ string, string ] > = [] ): Promise< MarkupValidation > { const policy = validateHtmlBlockPolicy( content ); if ( policy.invalidHtmlBlocks.length > 0 ) { @@ -173,12 +204,12 @@ export async function validateBlockMarkup( summary: `${ label }: not validated (${ report.error })`, }; } + const valid = `${ label }: ${ report.totalBlocks }/${ report.totalBlocks } blocks valid`; if ( report.invalidBlocks === 0 ) { - return { - summary: `${ label }: ${ report.totalBlocks }/${ report.totalBlocks } blocks valid${ - notes.length ? `; ${ notes.join( '; ' ) }` : '' - }`, - }; + const presets = await storePaletteColors( content, siteUrl, palette ); + return presets.colors.length + ? { summary: valid, fixedContent: presets.content, paletteColors: presets.colors } + : { summary: valid }; } const fix = report.proposedFix; @@ -202,10 +233,12 @@ export async function validateBlockMarkup( ( attribute ) => ! fixedAttributes.has( attribute ) ); if ( ! lostText && droppedClasses.length === 0 && droppedAttributes.length === 0 ) { + const presets = await storePaletteColors( fix.fixedContent, siteUrl, palette ); return { - summary: `${ label }: ${ report.totalBlocks }/${ report.totalBlocks } blocks valid`, - fixedContent: fix.fixedContent, + summary: valid, + fixedContent: presets.content, invalidBlocks: report.invalidBlocks, + ...( presets.colors.length && { paletteColors: presets.colors } ), }; } return { @@ -333,7 +366,11 @@ export class BlockValidationGate { } async function validateWithSite( filePath: string, siteRoot: string ): Promise< FileValidation > { - return validateBlockMarkupFile( filePath, getSiteUrl( await getSiteByFolder( siteRoot ) ) ); + return validateBlockMarkupFile( + filePath, + getSiteUrl( await getSiteByFolder( siteRoot ) ), + await sitePalette( siteRoot ) + ); } const gates = new WeakMap< object, BlockValidationGate >(); diff --git a/apps/cli/ai/block-validator.ts b/apps/cli/ai/block-validator.ts index 838d7c9357..c7149aefec 100644 --- a/apps/cli/ai/block-validator.ts +++ b/apps/cli/ai/block-validator.ts @@ -467,6 +467,111 @@ export async function validateBlocks( } } +export interface PaletteColorsResult { + content: string; + /** Each color written as a value that the palette has, with its preset. */ + colors: Array< { color: string; slug: string } >; +} + +/** + * Stores each color a block's style gives as a palette color's value as that + * palette color, as the editor does when one is picked + * (encodeColorValueWithPalette in @wordpress/block-editor): the block's color + * attribute where its color or border support has one, `var:preset|color|` + * elsewhere in its style. The colors stay the same, and follow the palette + * when it changes. Content the editor would save otherwise is returned as is. + */ +export async function storePaletteColors( + content: string, + siteUrl: string, + palette: Array< [ string, string ] > +): Promise< PaletteColorsResult > { + if ( ! palette.length ) { + return { content, colors: [] }; + } + const page = await getEditorPage( siteUrl ).getPage(); + return page.evaluate( + ( { html, presets } ) => { + /* eslint-disable @typescript-eslint/no-explicit-any */ + const wpBlocks = ( window as any ).wp.blocks; + const colors: Array< { color: string; slug: string } > = []; + const slugOf = ( value: unknown ) => + typeof value === 'string' + ? presets.find( + ( [ , color ] ) => color.toLowerCase() === value.trim().toLowerCase() + )?.[ 0 ] + : undefined; + // The style values the color and border supports read a color from. + const presetValues = ( value: any ): any => { + if ( ! value || typeof value !== 'object' || Array.isArray( value ) ) { + return value; + } + return Object.fromEntries( + Object.entries( value ).map( ( [ key, child ] ) => { + const slug = [ 'text', 'background', 'color' ].includes( key ) + ? slugOf( child ) + : undefined; + if ( ! slug ) { + return [ key, presetValues( child ) ]; + } + colors.push( { color: String( child ), slug } ); + return [ key, `var:preset|color|${ slug }` ]; + } ) + ); + }; + const NAMED: Array< [ string, string, string ] > = [ + [ 'color', 'background', 'backgroundColor' ], + [ 'color', 'text', 'textColor' ], + [ 'border', 'color', 'borderColor' ], + ]; + const visit = ( block: any ): any => { + const innerBlocks = ( block.innerBlocks ?? [] ).map( visit ); + const before = colors.length; + const style = presetValues( block.attributes?.style ); + if ( colors.length === before ) { + return { ...block, innerBlocks }; + } + const attributes = { ...block.attributes, style }; + const types = wpBlocks.getBlockType( block.name )?.attributes ?? {}; + for ( const [ group, key, name ] of NAMED ) { + const value = attributes.style?.[ group ]?.[ key ]; + if ( + types[ name ] && + typeof value === 'string' && + value.startsWith( 'var:preset|color|' ) + ) { + attributes[ name ] = value.slice( 'var:preset|color|'.length ); + const { [ key ]: _named, ...rest } = attributes.style[ group ]; + attributes.style = { ...attributes.style, [ group ]: rest }; + } + } + for ( const group of [ 'color', 'border' ] ) { + if ( attributes.style?.[ group ] && ! Object.keys( attributes.style[ group ] ).length ) { + const { [ group ]: _empty, ...rest } = attributes.style; + attributes.style = rest; + } + } + if ( attributes.style && ! Object.keys( attributes.style ).length ) { + delete attributes.style; + } + return { ...block, attributes, innerBlocks }; + }; + const blocks = wpBlocks.parse( html ).map( visit ); + if ( ! colors.length ) { + return { content: html, colors }; + } + const fixed = wpBlocks.serialize( blocks ); + const valid = ( list: any[] ): boolean => + list.every( ( block ) => block.isValid !== false && valid( block.innerBlocks ?? [] ) ); + return valid( wpBlocks.parse( fixed ) ) + ? { content: fixed, colors } + : { content: html, colors: [] }; + /* eslint-enable @typescript-eslint/no-explicit-any */ + }, + { html: content, presets: palette } + ); +} + /** Clean up all cached editor pages. */ export async function cleanupValidatorPages(): Promise< void > { for ( const ep of editorPages.values() ) { diff --git a/apps/cli/ai/post-content-check.ts b/apps/cli/ai/post-content-check.ts index 0f6a757ed7..776fc0e92d 100644 --- a/apps/cli/ai/post-content-check.ts +++ b/apps/cli/ai/post-content-check.ts @@ -6,7 +6,11 @@ */ import { mkdir, writeFile } from 'fs/promises'; import path from 'path'; -import { validateBlockMarkup } from 'cli/ai/block-validation-gate'; +import { + describePaletteColors, + sitePalette, + validateBlockMarkup, +} from 'cli/ai/block-validation-gate'; import { readWordPressSite } from 'cli/ai/site-database'; import { getSiteUrl } from 'cli/lib/cli-config/sites'; import type { ChangeReport, SiteChangeSource, Versions } from 'cli/ai/site-changes'; @@ -61,7 +65,12 @@ async function readPosts( sitePath: string, ids: string[] ): Promise< Row[] > { // shell keeps where it reads a Windows backslash as an escape. async function validatePost( site: SiteData, post: Row ) { const label = `${ site.name } ${ post.type } ${ post.id } "${ post.title }"`; - const result = await validateBlockMarkup( post.content, label, getSiteUrl( site ) ); + const result = await validateBlockMarkup( + post.content, + label, + getSiteUrl( site ), + await sitePalette( site.path ) + ); if ( result.fixedContent === undefined ) { return result; } @@ -69,9 +78,21 @@ async function validatePost( site: SiteData, post: Row ) { await mkdir( path.dirname( fixFile ), { recursive: true } ); await writeFile( fixFile, result.fixedContent, 'utf8' ); const shellPath = fixFile.split( path.sep ).join( '/' ); + const saves = [ + result.invalidBlocks + ? `fixes ${ result.invalidBlocks } block(s) that do not match what the block saves` + : undefined, + result.paletteColors?.length ? describePaletteColors( result.paletteColors ) : undefined, + ].filter( Boolean ); return { - summary: `${ label }: ${ result.invalidBlocks } invalid block(s)`, - problem: `${ label }: ${ result.invalidBlocks } block(s) do not match what the block saves; the block editor's fix loses nothing and is saved in ${ shellPath }. Apply it in the site's folder with \`wp post update ${ post.id } ${ shellPath }\`.`, + summary: result.invalidBlocks + ? `${ label }: ${ result.invalidBlocks } invalid block(s)` + : result.summary, + problem: `${ label }: the block editor's save ${ saves.join( + ' and ' + ) }, losing nothing; it is saved in ${ shellPath }. Apply it in the site's folder with \`wp post update ${ + post.id + } ${ shellPath }\`.`, }; } diff --git a/apps/cli/ai/tests/block-validation-gate.test.ts b/apps/cli/ai/tests/block-validation-gate.test.ts index c53dddd4c9..8541cdf27d 100644 --- a/apps/cli/ai/tests/block-validation-gate.test.ts +++ b/apps/cli/ai/tests/block-validation-gate.test.ts @@ -8,10 +8,13 @@ import { isBlockMarkupFile, validateBlockMarkupFile, } from 'cli/ai/block-validation-gate'; -import { validateBlocks, type ValidationReport } from 'cli/ai/block-validator'; +import { storePaletteColors, validateBlocks, type ValidationReport } from 'cli/ai/block-validator'; import { OpenProblems } from 'cli/ai/open-problems'; -vi.mock( 'cli/ai/block-validator', () => ( { validateBlocks: vi.fn() } ) ); +vi.mock( 'cli/ai/block-validator', () => ( { + validateBlocks: vi.fn(), + storePaletteColors: vi.fn( async ( content: string ) => ( { content, colors: [] } ) ), +} ) ); let site: string; @@ -79,12 +82,37 @@ describe( 'validating a file', () => { const result = await validateBlockMarkupFile( file, 'http://localhost:8881' ); expect( result.problem ).toBeUndefined(); expect( result.summary ).toContain( - '1/1 blocks valid; saved through the block editor, fixing 1 block(s)' + '1/1 blocks valid; saved through the block editor, which fixes 1 block(s)' ); expect( result.fixedContent ).toBe( fixed ); expect( readFileSync( file, 'utf8' ) ).toBe( original ); } ); + it( "returns the content with the palette's colors stored as presets", async () => { + const file = write( 'parts/hero.html', original ); + vi.mocked( validateBlocks ).mockResolvedValue( { + totalBlocks: 1, + validBlocks: 1, + invalidBlocks: 0, + results: [], + } ); + vi.mocked( storePaletteColors ).mockResolvedValueOnce( { + content: fixed, + colors: [ { color: '#503AA8', slug: 'accent-3' } ], + } ); + const palette: Array< [ string, string ] > = [ [ 'accent-3', '#503AA8' ] ]; + const result = await validateBlockMarkupFile( file, 'http://localhost:8881', palette ); + expect( storePaletteColors ).toHaveBeenLastCalledWith( + original, + 'http://localhost:8881', + palette + ); + expect( result.summary ).toBe( + "parts/hero.html: 1/1 blocks valid; saved through the block editor, which stores #503AA8 as the palette's accent-3, as the editor stores a palette color (encodeColorValueWithPalette in @wordpress/block-editor), so it follows the palette" + ); + expect( result.fixedContent ).toBe( fixed ); + } ); + it( 'refuses a fix that would drop classes, and says what to change', async () => { const file = write( 'tmp/home.html', diff --git a/apps/cli/ai/tests/block-validator.test.ts b/apps/cli/ai/tests/block-validator.test.ts index c0eca9d9a4..0468f36252 100644 --- a/apps/cli/ai/tests/block-validator.test.ts +++ b/apps/cli/ai/tests/block-validator.test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, it } from 'vitest'; -import { cleanupValidatorPages, validateBlocks } from 'cli/ai/block-validator'; +import { cleanupValidatorPages, storePaletteColors, validateBlocks } from 'cli/ai/block-validator'; // Runs the site's own block editor, so it needs a running WordPress site: // STUDIO_BLOCK_EDITOR_URL=http://localhost:8881 npx vitest run block-validator @@ -98,3 +98,57 @@ describe.runIf( siteUrl )( 'validating in the block editor (live)', () => { expect( report.proposedFix?.sameText ).toBe( true ); }, 60_000 ); } ); + +describe.runIf( siteUrl )( 'storing palette colors as presets (live)', () => { + afterAll( cleanupValidatorPages ); + const palette: Array< [ string, string ] > = [ + [ 'base', '#FFFFFF' ], + [ 'accent-3', '#503AA8' ], + ]; + + it( 'stores each color a palette color has as that preset, as the editor does', async () => { + const markup = group( + '{"style":{"color":{"background":"#503aa8","text":"#FFFFFF"},"elements":{"link":{"color":{"text":"#FFFFFF"}}}},"layout":{"type":"constrained"}}', + '' + ); + const result = await storePaletteColors( markup, siteUrl!, palette ); + + expect( result.colors ).toEqual( [ + { color: '#503aa8', slug: 'accent-3' }, + { color: '#FFFFFF', slug: 'base' }, + { color: '#FFFFFF', slug: 'base' }, + ] ); + expect( result.content ).toContain( + '' + ); + expect( result.content ).toContain( 'has-base-color has-accent-3-background-color' ); + expect( result.content ).toContain( 'background-color:#123456' ); + expect( ( await validateBlocks( result.content, siteUrl! ) ).invalidBlocks ).toBe( 0 ); + } ); + + it( 'leaves no empty style behind', async () => { + const markup = paragraph( + '{"style":{"color":{"text":"#503AA8"}}}', + '

Hi

' + ); + expect( ( await storePaletteColors( markup, siteUrl!, palette ) ).content ).toBe( + paragraph( '{"textColor":"accent-3"}', '

Hi

' ) + ); + } ); + + it( 'returns content without palette values as written', async () => { + const markup = paragraph( + '{"textColor":"base"}', + '

Hi

' + ); + expect( await storePaletteColors( markup, siteUrl!, palette ) ).toEqual( { + content: markup, + colors: [], + } ); + } ); +} ); diff --git a/apps/cli/ai/tests/post-content-check.test.ts b/apps/cli/ai/tests/post-content-check.test.ts index a9c076d888..5e6718a57d 100644 --- a/apps/cli/ai/tests/post-content-check.test.ts +++ b/apps/cli/ai/tests/post-content-check.test.ts @@ -1,13 +1,19 @@ import { existsSync, readFileSync, rmSync } from 'fs'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { validateBlocks, type ValidationReport } from 'cli/ai/block-validator'; +import { storePaletteColors, validateBlocks, type ValidationReport } from 'cli/ai/block-validator'; import { postContentChanges } from 'cli/ai/post-content-check'; import { SiteChanges } from 'cli/ai/site-changes'; import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; import type { SiteData } from 'cli/lib/cli-config/core'; -vi.mock( 'cli/ai/block-validator', () => ( { validateBlocks: vi.fn() } ) ); +vi.mock( 'cli/ai/block-validator', () => ( { + validateBlocks: vi.fn(), + storePaletteColors: vi.fn( async ( content: string ) => ( { content, colors: [] } ) ), +} ) ); +vi.mock( 'cli/ai/theme-presets', () => ( { + themePresets: vi.fn( async () => ( { colors: [ [ 'accent-3', '#503AA8' ] ] } ) ), +} ) ); const PARAGRAPH = '\n

Hello

\n'; const UNWRAPPED_GROUP = '\n

Hello

\n'; @@ -103,6 +109,28 @@ describe( 'post content changes', () => { ); } ); + it( "saves the content with the palette's colors stored as presets, with the command that applies it", async () => { + const PRESET = ''; + vi.mocked( storePaletteColors ).mockResolvedValueOnce( { + content: PRESET, + colors: [ { color: '#503AA8', slug: 'accent-3' } ], + } ); + const check = checkPosts(); + await check.snapshot(); + fixture.edit( 9, UNWRAPPED_GROUP ); + const { summaries, problems } = await check.settle(); + expect( storePaletteColors ).toHaveBeenLastCalledWith( + UNWRAPPED_GROUP, + 'http://localhost:8881', + [ [ 'accent-3', '#503AA8' ] ] + ); + expect( summaries ).toEqual( [ 'Bakery page 9 "": 1/1 blocks valid' ] ); + expect( problems[ 0 ] ).toContain( + `Bakery page 9 "": the block editor's save stores #503AA8 as the palette's accent-3, as the editor stores a palette color (encodeColorValueWithPalette in @wordpress/block-editor), so it follows the palette, losing nothing; it is saved in` + ); + expect( readFileSync( path.join( fixture.root, 'tmp/page-9.html' ), 'utf8' ) ).toBe( PRESET ); + } ); + it( 'reports a validation that cannot run', async () => { vi.mocked( validateBlocks ).mockRejectedValue( new Error( 'site is not running' ) ); const check = checkPosts(); diff --git a/apps/cli/ai/theme-presets.ts b/apps/cli/ai/theme-presets.ts index ec2c87216a..6839a7bcec 100644 --- a/apps/cli/ai/theme-presets.ts +++ b/apps/cli/ai/theme-presets.ts @@ -16,7 +16,7 @@ import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; import type { WordPressSite } from 'cli/ai/site-database'; -interface Presets { +export interface Presets { colors: Array< [ string, string ] >; fontFamilies: Array< [ string, string ] >; fontSizes: Array< [ string, string ] >; @@ -54,7 +54,7 @@ echo wp_json_encode( array( ) ); `; -const resolved = new Map< string, { inputs: string; lines: Promise< string[] > } >(); +const resolved = new Map< string, { inputs: string; presets: Promise< Presets > } >(); async function filesVersion( dir: string ): Promise< string > { const entries = await readdir( dir, { recursive: true, withFileTypes: true } ).catch( () => [] ); @@ -130,24 +130,30 @@ function describePresets( presets: Presets ): string[] { ].filter( ( line ): line is string => Boolean( line ) ); } -/** The active theme's presets, as WordPress resolves them, as site facts. */ -export async function themePresetsFact( site: WordPressSite ): Promise< string[] > { +/** + * The site's presets, as WordPress resolves them, or undefined when it has no + * active theme or WordPress could not run, in which case they are resolved + * again next time. + */ +export async function themePresets( site: WordPressSite ): Promise< Presets | undefined > { const themes = [ ...new Set( [ site.option( 'stylesheet' ), site.option( 'template' ) ] ), ].filter( ( theme ): theme is string => typeof theme === 'string' && theme !== '' ); if ( ! themes.length ) { - return []; + return undefined; } const inputs = await inputsOf( site, themes ); let entry = resolved.get( site.root ); if ( entry?.inputs !== inputs ) { - entry = { - inputs, - lines: resolve( site.root ).then( describePresets ), - }; + entry = { inputs, presets: resolve( site.root ) }; resolved.set( site.root, entry ); - // A site WordPress could not run for states no presets, and is asked again next time. - entry.lines.catch( () => resolved.delete( site.root ) ); + entry.presets.catch( () => resolved.delete( site.root ) ); } - return entry.lines.catch( () => [] ); + return entry.presets.catch( () => undefined ); +} + +/** The active theme's presets, as WordPress resolves them, as site facts. */ +export async function themePresetsFact( site: WordPressSite ): Promise< string[] > { + const presets = await themePresets( site ); + return presets ? describePresets( presets ) : []; } From d2707e12a36ce198758c4afb3f07ab20761fe0a9 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 16:51:06 -0400 Subject: [PATCH 081/199] Eval: judge the menu on the pages the prompt asks for, not every published page --- scripts/eval/outcome.mjs | 26 ++++++++++++++++---------- scripts/eval/outcome.test.mjs | 10 ++++++---- scripts/eval/promptfoo.config.yaml | 5 ++++- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/scripts/eval/outcome.mjs b/scripts/eval/outcome.mjs index ec025c2ce8..f97526d406 100644 --- a/scripts/eval/outcome.mjs +++ b/scripts/eval/outcome.mjs @@ -523,28 +523,34 @@ export const designTokenReachedTheme = check( ( d, vars ) => { const homeHtml = ( s ) => ( s.home?.htmlFile ? readFileSync( s.home.htmlFile, 'utf8' ) : '' ); const pathOf = ( url ) => new URL( url ).pathname.replace( /\/?$/, '/' ); -// Every published page besides the home page is in the rendered navigation, -// and its link opens that page. +// Each page the prompt asks for (`vars.pages`, by `title`) is published, is in +// the rendered navigation, and its link opens it. export const menuReachesPages = check( ( d, vars ) => { const s = site( d, vars ); - const pages = ( s.content || [] ).filter( ( c ) => c.type === 'page' && ! c.isFront ); - if ( pages.length < vars.minPages ) + const published = ( s.content || [] ).filter( ( c ) => c.type === 'page' && ! c.isFront ); + const titled = ( title ) => + published.find( ( p ) => p.title.trim().toLowerCase() === title.toLowerCase() ); + const wanted = vars.pages.map( ( p ) => p.title ); + const absent = wanted.filter( ( title ) => ! titled( title ) ); + if ( absent.length ) return fail( - `${ pages.length } published page(s) besides the home page: ${ JSON.stringify( - pages.map( ( p ) => p.title ) - ) }` + `no published page titled ${ absent + .map( ( t ) => `"${ t }"` ) + .join( ', ' ) }: ${ JSON.stringify( published.map( ( p ) => p.title ) ) }` ); const reached = Object.values( s.home?.navTargets || {} ) .filter( ( t ) => t.status === 200 ) .map( ( t ) => pathOf( t.url ) ); - const missing = pages.filter( ( p ) => ! reached.includes( pathOf( p.link ) ) ); + const missing = wanted.filter( + ( title ) => ! reached.includes( pathOf( titled( title ).link ) ) + ); if ( missing.length ) return fail( `the navigation does not open ${ missing - .map( ( p ) => `"${ p.title }"` ) + .map( ( t ) => `"${ t }"` ) .join( ', ' ) }; it leads to ${ JSON.stringify( s.home?.navTargets ) }` ); - return pass( `the navigation opens ${ pages.map( ( p ) => `"${ p.title }"` ).join( ', ' ) }` ); + return pass( `the navigation opens ${ wanted.map( ( t ) => `"${ t }"` ).join( ', ' ) }` ); } ); // The home page shows a block that a plugin registers with WordPress, with an diff --git a/scripts/eval/outcome.test.mjs b/scripts/eval/outcome.test.mjs index 4e6cc3ef54..0bc27089a7 100644 --- a/scripts/eval/outcome.test.mjs +++ b/scripts/eval/outcome.test.mjs @@ -507,11 +507,13 @@ describe( 'new-case checks', () => { } ); test( 'menuReachesPages', () => { - const site = ( navTargets, pages = [ 'about', 'classes' ] ) => ( { + const site = ( navTargets, pages = [ 'About', 'Classes', 'Sample Page' ] ) => ( { ...workingSite, content: [ page( 'Home', 'http://localhost:8881/', { isFront: true } ), - ...pages.map( ( slug ) => page( slug, `http://localhost:8881/${ slug }/` ) ), + ...pages.map( ( title ) => + page( title, `http://localhost:8881/${ title.toLowerCase() }/` ) + ), ], home: { ...workingSite.home, navTargets }, } ); @@ -519,7 +521,7 @@ describe( 'new-case checks', () => { status, url: `http://localhost:8881/${ slug }`, } ); - const vars = { minPages: 2 }; + const vars = { pages: [ { title: 'About' }, { title: 'Classes' } ] }; const judge = ( s ) => run( checks.menuReachesPages, outcome( s ), vars ).pass; expect( judge( site( { '/about': opens( 'about' ), '?page_id=7': opens( 'classes' ) } ) ) @@ -528,7 +530,7 @@ describe( 'new-case checks', () => { expect( judge( site( { '/about': opens( 'about' ), '/classes': opens( 'classes', 404 ) } ) ) ).toBe( false ); - expect( judge( site( { '/about': opens( 'about' ) }, [ 'about' ] ) ) ).toBe( false ); + expect( judge( site( { '/about': opens( 'about' ) }, [ 'About' ] ) ) ).toBe( false ); } ); test( 'pluginBlockRendered', () => { diff --git a/scripts/eval/promptfoo.config.yaml b/scripts/eval/promptfoo.config.yaml index 15d7809400..dff9458324 100644 --- a/scripts/eval/promptfoo.config.yaml +++ b/scripts/eval/promptfoo.config.yaml @@ -657,7 +657,10 @@ tests: vars: caseId: multi-page-site-menu siteName: 'Eval Yoga Studio' - minPages: 3 + pages: + - title: Classes + - title: About + - title: Contact timeoutMs: 600000 prompt: | Build a site for a small yoga studio named "Eval Yoga Studio" with From 927ca8ea6295f186697ed7c758d4889c403643ea Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 16:55:51 -0400 Subject: [PATCH 082/199] Say when a theme made during the session is a classic theme, citing WordPress's default theme --- apps/cli/ai/block-theme.ts | 97 +++++++++++++++++ apps/cli/ai/site-changes.ts | 2 + apps/cli/ai/tests/block-theme.test.ts | 144 ++++++++++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 apps/cli/ai/block-theme.ts create mode 100644 apps/cli/ai/tests/block-theme.test.ts diff --git a/apps/cli/ai/block-theme.ts b/apps/cli/ai/block-theme.ts new file mode 100644 index 0000000000..9145c5bfad --- /dev/null +++ b/apps/cli/ai/block-theme.ts @@ -0,0 +1,97 @@ +/** + * Whether a theme made during the session is a block theme, as WordPress's + * default theme is. WordPress treats a theme as a block theme when it has + * templates/index.html (WP_Theme::is_block_theme() in + * wp-includes/class-wp-theme.php), and only a block theme's templates, parts + * and styles are edited in the Site Editor, around page content that is blocks. + * + * A theme the site already had keeps the kind it is. One made during the + * session that is classic stays an open problem while it is. + */ +import { readFile, readdir, stat } from 'fs/promises'; +import path from 'path'; +import { readThemeHeader, isUpdatableTheme } from 'cli/ai/themes'; +import type { SiteChangeSource, Versions } from 'cli/ai/site-changes'; + +const CREATED = 'created:'; +const exists = ( file: string ) => stat( file ).then( Boolean, () => false ); + +/** + * A standalone theme's kind: block, with a block template index + * (WP_Theme::is_block_theme()); classic, with the index.php a classic theme + * needs instead (WP_Theme::__construct(), "Template is missing"). A child + * theme is its parent's kind, so it is left to its parent. + */ +async function themeKind( root: string, theme: string ): Promise< string > { + const dir = path.join( root, 'wp-content', 'themes', theme ); + if ( await readThemeHeader( root, theme, 'Template' ).catch( () => '' ) ) { + return 'child'; + } + for ( const [ kind, file ] of [ + [ 'block', 'templates/index.html' ], + [ 'classic', 'index.php' ], + ] ) { + if ( await exists( path.join( dir, file ) ) ) { + return kind; + } + } + return 'unfinished'; +} + +/** WP_DEFAULT_THEME, the theme core falls back to (wp-includes/default-constants.php). */ +export async function defaultTheme( root: string ): Promise< string | undefined > { + const php = await readFile( + path.join( root, 'wp-includes/default-constants.php' ), + 'utf8' + ).catch( () => '' ); + return php.match( /define\(\s*'WP_DEFAULT_THEME',\s*'([^']+)'/ )?.[ 1 ]; +} + +async function classicThemeProblem( root: string, theme: string ): Promise< string > { + const fallback = await defaultTheme( root ); + const named = fallback ? `, ${ fallback } (WP_DEFAULT_THEME),` : ''; + return `${ theme } (wp-content/themes/${ theme }), made this session, is a classic theme: WordPress treats a theme as a block theme when it has templates/index.html (WP_Theme::is_block_theme() in wp-includes/class-wp-theme.php), and only then does the Site Editor edit its templates, parts and styles, around page content that is blocks. WordPress's default theme${ named } is a block theme: its templates are block markup in templates/*.html and its parts in parts/*.html. Give ${ theme } its templates and parts that way, with each page's content in the page as blocks.`; +} + +export const blockTheme: SiteChangeSource = { + name: 'block-theme', + async read( sitePath, last ) { + const dir = path.join( sitePath, 'wp-content', 'themes' ); + const themes = await readdir( dir ).catch( () => [] as string[] ); + const versions: Versions = {}; + for ( const theme of themes ) { + if ( ! ( await exists( path.join( dir, theme, 'style.css' ) ) ) ) { + continue; + } + const kind = await themeKind( sitePath, theme ); + versions[ theme ] = ( last?.[ theme ]?.startsWith( CREATED ) ? CREATED : '' ) + kind; + } + return versions; + }, + async check( site, before, after ) { + const next: Versions = {}; + const reports = []; + for ( const [ theme, version ] of Object.entries( after ) ) { + const created = ! ( theme in before ) || version.startsWith( CREATED ); + next[ theme ] = created && ! version.startsWith( CREATED ) ? CREATED + version : version; + if ( ! created || before[ theme ] === next[ theme ] ) { + continue; + } + const classic = next[ theme ] === `${ CREATED }classic`; + reports.push( { + key: `block theme ${ theme }`, + problem: + classic && ! ( await isUpdatableTheme( site.path, theme ) ) + ? await classicThemeProblem( site.path, theme ) + : undefined, + } ); + } + // A theme made this session and deleted since leaves no problem. + for ( const theme of Object.keys( before ) ) { + if ( before[ theme ].startsWith( CREATED ) && ! ( theme in after ) ) { + reports.push( { key: `block theme ${ theme }`, problem: undefined } ); + } + } + return { reports, next }; + }, +}; diff --git a/apps/cli/ai/site-changes.ts b/apps/cli/ai/site-changes.ts index 00e534e281..1fd4f3cb2d 100644 --- a/apps/cli/ai/site-changes.ts +++ b/apps/cli/ai/site-changes.ts @@ -7,6 +7,7 @@ * changed is reported on that call's result. Each source reads one kind of * thing WordPress reads, as versions a check compares. */ +import { blockTheme } from 'cli/ai/block-theme'; import { childThemeCopies } from 'cli/ai/child-theme-copies'; import { postContentChanges } from 'cli/ai/post-content-check'; import { siteFileWrites } from 'cli/ai/site-file-writes'; @@ -54,6 +55,7 @@ export const SITE_CHANGE_SOURCES: SiteChangeSource[] = [ postContentChanges, updatableThemeChanges, childThemeCopies, + blockTheme, // Before siteFileWrites, which reports the functions.php it may add to. themeStylesheet, siteFileWrites, diff --git a/apps/cli/ai/tests/block-theme.test.ts b/apps/cli/ai/tests/block-theme.test.ts new file mode 100644 index 0000000000..82e9cef8bb --- /dev/null +++ b/apps/cli/ai/tests/block-theme.test.ts @@ -0,0 +1,144 @@ +import { mkdirSync, rmSync, writeFileSync } from 'fs'; +import path from 'path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { blockTheme } from 'cli/ai/block-theme'; +import { OpenProblems } from 'cli/ai/open-problems'; +import { SiteChanges } from 'cli/ai/site-changes'; +import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; +import type { SiteData } from 'cli/lib/cli-config/core'; + +let fixture: ReturnType< typeof createSite >; +let site: SiteData; + +// Writes a theme file as any tool or script would. +const write = ( file: string, content = '' ) => { + const target = path.join( fixture.root, 'wp-content/themes', file ); + mkdirSync( path.dirname( target ), { recursive: true } ); + writeFileSync( target, content ); +}; +const header = ( name: string, parent?: string ) => + `/*\nTheme Name: ${ name }\n${ parent ? `Template: ${ parent }\n` : '' }*/`; + +beforeEach( () => { + fixture = createSite(); + writeFileSync( + path.join( fixture.root, 'wp-includes/default-constants.php' ), + " { + const { themes } = JSON.parse( init.body.get( 'themes' ) ?? '{}' ); + return new Response( + JSON.stringify( { themes: {}, no_update: 'installed' in themes ? themes : {} } ) + ); + } ); +} ); + +afterEach( () => { + vi.unstubAllGlobals(); + rmSync( fixture.root, { recursive: true, force: true } ); +} ); + +// `shown` counts the call results that tell of the problem. +it.each( [ + { + name: 'a classic theme made this session', + calls: [ () => write( 'yoga/style.css', header( 'Yoga' ) ), () => write( 'yoga/index.php' ) ], + shown: 1, + classic: 'yoga', + }, + { + name: 'a block theme made this session', + calls: [ + () => write( 'yoga/style.css', header( 'Yoga' ) ), + () => write( 'yoga/templates/index.html', '' ), + ], + shown: 0, + classic: undefined, + }, + { + name: 'a theme still being written, with neither index', + calls: [ + () => write( 'yoga/style.css', header( 'Yoga' ) ), + () => write( 'yoga/theme.json', '{}' ), + ], + shown: 0, + classic: undefined, + }, + { + name: 'a classic theme made this session, then given a block template index', + calls: [ + () => write( 'yoga/style.css', header( 'Yoga' ) ), + () => write( 'yoga/index.php' ), + () => write( 'yoga/templates/index.html', '' ), + ], + shown: 1, + classic: undefined, + }, + { + name: 'a classic theme made this session, then deleted', + calls: [ + () => { + write( 'yoga/style.css', header( 'Yoga' ) ); + write( 'yoga/index.php' ); + }, + () => rmSync( path.join( fixture.root, 'wp-content/themes/yoga' ), { recursive: true } ), + ], + shown: 1, + classic: undefined, + }, + { + name: 'a classic theme the site had, edited', + calls: [ () => write( 'legacy/page.php', ' { + write( 'legacy-child/style.css', header( 'Legacy child', 'legacy' ) ); + write( 'legacy-child/index.php' ); + }, + ], + shown: 0, + classic: undefined, + }, + { + name: 'a classic theme installed from WordPress.org', + calls: [ + () => { + write( 'installed/style.css', `${ header( 'Installed' ) }\nVersion: 1.0` ); + write( 'installed/index.php' ); + }, + ], + shown: 0, + classic: undefined, + }, +] )( 'raises at the end of the turn $name', async ( { calls, shown, classic } ) => { + const open = new OpenProblems( [] ); + const changes = new SiteChanges( open, async () => [ site ], [ blockTheme ] ); + const results: string[] = []; + for ( const call of [ ...calls, () => write( 'legacy/footer.php' ) ] ) { + await changes.snapshot(); + call(); + results.push( ...( await changes.settle() ).problems ); + } + // A problem is told on the result of the call that made it, once. + expect( results ).toHaveLength( shown ); + expect( await open.raise() ).toEqual( + classic + ? [ + expect.stringMatching( + new RegExp( + `^${ classic } \\(wp-content/themes/${ classic }\\), made this session, is a classic theme: .*WP_Theme::is_block_theme\\(\\).*WordPress's default theme, twentytwentyfive \\(WP_DEFAULT_THEME\\), is a block theme: its templates are block markup in templates/\\*\\.html` + ) + ), + ] + : [] + ); +} ); From 41fc92e68e79e857d97236c544372246a92c03d8 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 16:57:57 -0400 Subject: [PATCH 083/199] Read the active theme and its parent in one place --- apps/cli/ai/open-problems.ts | 5 ++--- apps/cli/ai/saved-copies.ts | 11 +++++------ apps/cli/ai/theme-presets.ts | 5 ++--- apps/cli/ai/theme-stylesheet.ts | 20 ++++++++------------ apps/cli/ai/themes.ts | 19 ++++++++++++++----- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/apps/cli/ai/open-problems.ts b/apps/cli/ai/open-problems.ts index 3edfe18cb0..300c0aa0a8 100644 --- a/apps/cli/ai/open-problems.ts +++ b/apps/cli/ai/open-problems.ts @@ -19,6 +19,7 @@ import { homeSectionLinks } from 'cli/ai/home-section-links'; import { readSavedCopies } from 'cli/ai/saved-copies'; import { readWordPressSite } from 'cli/ai/site-database'; import { studioMessage } from 'cli/ai/studio-message'; +import { activeThemes } from 'cli/ai/themes'; interface OpenProblemsState { // The sites changed, whose state is checked at the end of the turn. @@ -137,9 +138,7 @@ export async function unrenderedFrontPage( sitePath: string ): Promise< string | return { frontPage: pageId ? `page ${ pageId } "${ post( pageId )?.title ?? '' }"` : undefined, saved: saved && { id: saved.id, content: post( saved.id )?.content ?? '' }, - themes: [ ...new Set( [ site.option( 'stylesheet' ), site.option( 'template' ) ] ) ].filter( - ( theme ): theme is string => typeof theme === 'string' && theme !== '' - ), + themes: activeThemes( site ), }; } ); if ( ! state ) { diff --git a/apps/cli/ai/saved-copies.ts b/apps/cli/ai/saved-copies.ts index 9ade5eeb6a..2651715216 100644 --- a/apps/cli/ai/saved-copies.ts +++ b/apps/cli/ai/saved-copies.ts @@ -1,4 +1,5 @@ import path from 'path'; +import { activeThemes } from 'cli/ai/themes'; import type { WordPressSite } from 'cli/ai/site-database'; // A block theme's templates, template parts, and theme.json are only what @@ -52,8 +53,8 @@ function styleAreas( content: string ): string[] { */ export function readSavedCopies( site: WordPressSite ): SavedCopy[] { const { prefix } = site; - const stylesheet = site.option( 'stylesheet' ); - if ( typeof stylesheet !== 'string' ) return []; + const [ stylesheet ] = activeThemes( site ); + if ( ! stylesheet ) return []; // The latest published post per type and slug, for the theme its wp_theme // term names. const rows = site.query< Omit< SavedCopy, 'file' > & { content: string } >( @@ -116,11 +117,9 @@ function describeCopy( copy: SavedCopy ): string { */ export function savedCopyWriteNote( site: WordPressSite, file: string ): string | undefined { const relative = path.relative( site.root, file ).split( path.sep ).join( '/' ); - const themes = [ site.option( 'stylesheet' ), site.option( 'template' ) ]; const copy = readSavedCopies( site ).find( ( candidate ) => - themes.some( - ( theme ) => - typeof theme === 'string' && relative === `wp-content/themes/${ theme }/${ candidate.file }` + activeThemes( site ).some( + ( theme ) => relative === `wp-content/themes/${ theme }/${ candidate.file }` ) ); if ( ! copy ) { diff --git a/apps/cli/ai/theme-presets.ts b/apps/cli/ai/theme-presets.ts index 6839a7bcec..5cd268a760 100644 --- a/apps/cli/ai/theme-presets.ts +++ b/apps/cli/ai/theme-presets.ts @@ -11,6 +11,7 @@ */ import { readdir, stat } from 'fs/promises'; import path from 'path'; +import { activeThemes } from 'cli/ai/themes'; import { resolveSite } from 'cli/ai/tools/utils'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; @@ -136,9 +137,7 @@ function describePresets( presets: Presets ): string[] { * again next time. */ export async function themePresets( site: WordPressSite ): Promise< Presets | undefined > { - const themes = [ - ...new Set( [ site.option( 'stylesheet' ), site.option( 'template' ) ] ), - ].filter( ( theme ): theme is string => typeof theme === 'string' && theme !== '' ); + const themes = activeThemes( site ); if ( ! themes.length ) { return undefined; } diff --git a/apps/cli/ai/theme-stylesheet.ts b/apps/cli/ai/theme-stylesheet.ts index 8f94d434e7..10d84857b2 100644 --- a/apps/cli/ai/theme-stylesheet.ts +++ b/apps/cli/ai/theme-stylesheet.ts @@ -14,7 +14,7 @@ import { readFile, stat, writeFile } from 'fs/promises'; import path from 'path'; import { readWordPressSite } from 'cli/ai/site-database'; -import { isUpdatableTheme } from 'cli/ai/themes'; +import { activeThemes, isUpdatableTheme } from 'cli/ai/themes'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; import type { SiteChangeSource } from 'cli/ai/site-changes'; @@ -125,26 +125,22 @@ const stampOf = ( file: string ) => () => '-' ); -const themesOf = ( sitePath: string ) => - readWordPressSite( sitePath, ( site ) => ( { - stylesheet: String( site.option( 'stylesheet' ) ?? '' ), - template: String( site.option( 'template' ) ?? '' ), - } ) ); +const themesOf = ( sitePath: string ) => readWordPressSite( sitePath, activeThemes ); export const themeStylesheet: SiteChangeSource = { name: 'theme-stylesheet', async read( sitePath ) { - const themes = await themesOf( sitePath ); - if ( ! themes?.stylesheet ) { + const [ stylesheet, template = stylesheet ] = ( await themesOf( sitePath ) ) ?? []; + if ( ! stylesheet ) { return {}; } const dir = ( theme: string ) => path.join( sitePath, 'wp-content', 'themes', theme ); const stamps = await Promise.all( [ - stampOf( path.join( dir( themes.stylesheet ), 'style.css' ) ), - stampOf( path.join( dir( themes.stylesheet ), 'functions.php' ) ), - stampOf( path.join( dir( themes.template ), 'functions.php' ) ), + stampOf( path.join( dir( stylesheet ), 'style.css' ) ), + stampOf( path.join( dir( stylesheet ), 'functions.php' ) ), + stampOf( path.join( dir( template ), 'functions.php' ) ), ] ); - return { [ themes.stylesheet ]: `${ themes.template }|${ stamps.join( '|' ) }` }; + return { [ stylesheet ]: `${ template }|${ stamps.join( '|' ) }` }; }, async check( site, before, after ) { const [ theme ] = Object.keys( after ); diff --git a/apps/cli/ai/themes.ts b/apps/cli/ai/themes.ts index 2e3337d317..a80eb8582c 100644 --- a/apps/cli/ai/themes.ts +++ b/apps/cli/ai/themes.ts @@ -68,16 +68,25 @@ export async function isUpdatableTheme( root: string, theme: string ): Promise< ); } +/** + * The active theme, then its parent when it is a child theme: the stylesheet + * and template options WordPress loads the theme by. + */ +export function activeThemes( site: WordPressSite ): string[] { + return [ ...new Set( [ site.option( 'stylesheet' ), site.option( 'template' ) ] ) ].filter( + ( theme ): theme is string => typeof theme === 'string' && theme !== '' + ); +} + /** Site fact: the active theme, its parent, and which of them WordPress updates. */ export function activeThemeFact( site: WordPressSite ): string | undefined { - const stylesheet = site.option( 'stylesheet' ); - const template = site.option( 'template' ); - if ( typeof stylesheet !== 'string' ) { + const [ stylesheet, parent ] = activeThemes( site ); + if ( ! stylesheet ) { return undefined; } const describe = ( theme: string ) => isUpdated( site, theme ) ? `${ theme }, which WordPress updates` : theme; - return typeof template === 'string' && template !== stylesheet - ? `Active theme: ${ describe( stylesheet ) }; a child theme of ${ describe( template ) }.` + return parent + ? `Active theme: ${ describe( stylesheet ) }; a child theme of ${ describe( parent ) }.` : `Active theme: ${ describe( stylesheet ) }.`; } From a70e21501ba0d7eab908ee4201aa51e7f955dd41 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 17:00:08 -0400 Subject: [PATCH 084/199] Keep module-only names unexported --- apps/cli/ai/block-theme.ts | 2 +- apps/cli/ai/block-validation-gate.ts | 2 +- apps/cli/ai/runtimes/pi/bash-timeout.ts | 2 +- apps/cli/ai/theme-presets.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cli/ai/block-theme.ts b/apps/cli/ai/block-theme.ts index 9145c5bfad..c13a330cbe 100644 --- a/apps/cli/ai/block-theme.ts +++ b/apps/cli/ai/block-theme.ts @@ -39,7 +39,7 @@ async function themeKind( root: string, theme: string ): Promise< string > { } /** WP_DEFAULT_THEME, the theme core falls back to (wp-includes/default-constants.php). */ -export async function defaultTheme( root: string ): Promise< string | undefined > { +async function defaultTheme( root: string ): Promise< string | undefined > { const php = await readFile( path.join( root, 'wp-includes/default-constants.php' ), 'utf8' diff --git a/apps/cli/ai/block-validation-gate.ts b/apps/cli/ai/block-validation-gate.ts index 3153b2cd24..393519b2ee 100644 --- a/apps/cli/ai/block-validation-gate.ts +++ b/apps/cli/ai/block-validation-gate.ts @@ -121,7 +121,7 @@ interface FileValidation { fixedContent?: string; } -export interface MarkupValidation { +interface MarkupValidation { summary: string; problem?: string; /** What the block editor would save instead, when that loses nothing. */ diff --git a/apps/cli/ai/runtimes/pi/bash-timeout.ts b/apps/cli/ai/runtimes/pi/bash-timeout.ts index d2c48e9740..dfed8f9974 100644 --- a/apps/cli/ai/runtimes/pi/bash-timeout.ts +++ b/apps/cli/ai/runtimes/pi/bash-timeout.ts @@ -8,7 +8,7 @@ import { Type, type TSchema } from 'typebox'; import type { AgentTool } from '@earendil-works/pi-agent-core'; -export const DEFAULT_TIMEOUT_SECONDS = 120; +const DEFAULT_TIMEOUT_SECONDS = 120; export function withDefaultTimeout< S extends TSchema >( tool: AgentTool< S >, diff --git a/apps/cli/ai/theme-presets.ts b/apps/cli/ai/theme-presets.ts index 5cd268a760..982e87b963 100644 --- a/apps/cli/ai/theme-presets.ts +++ b/apps/cli/ai/theme-presets.ts @@ -17,7 +17,7 @@ import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; import type { WordPressSite } from 'cli/ai/site-database'; -export interface Presets { +interface Presets { colors: Array< [ string, string ] >; fontFamilies: Array< [ string, string ] >; fontSizes: Array< [ string, string ] >; From 4c50a1a5ae0b1849071bbcb2ea3ab023bd7baa9f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 17:44:27 -0400 Subject: [PATCH 085/199] Remove the Write, Edit and Bash payload size limits The 14 KB file and 8 KB command limits came with #3584, when the model output cap was 8,192 tokens and long tool arguments were observed to arrive incomplete. #3609 found the cause: a response that hits the output cap mid tool call. It refuses exactly those calls (stopReason 'length') and raised every provider path to 32,000 output tokens. Since then the size limits only refuse calls that arrived whole, after the model has already generated them, and the refused payload stays in the context every later call re-reads. A 35 KB Write through each WordPress.com tier (fast, balanced, strong) arrived complete: all 520 lines, a normal tool-call stop, 10-15k output tokens; balanced and strong byte-identical, fast with one model typo. All three were refused by the 14 KB limit. In eval run 11 one refused 20 KB style.css cost about 10% of the build. The output-cap check stays and covers truncation at any size. --- apps/cli/ai/runtimes/pi/index.ts | 5 - .../ai/runtimes/pi/tests/tool-safety.test.ts | 47 ---------- apps/cli/ai/runtimes/pi/tool-safety.ts | 93 +------------------ apps/cli/ai/tests/pi-runtime.test.ts | 31 ------- 4 files changed, 2 insertions(+), 174 deletions(-) delete mode 100644 apps/cli/ai/runtimes/pi/tests/tool-safety.test.ts diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 1fc02dc00f..be33a0a348 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -75,7 +75,6 @@ import { import { getIncompleteToolCallReason, getWrittenFile, - getPayloadLimitViolation, type StudioToolPayloadGuardState, updateStudioToolPayloadGuardState, } from './tool-safety'; @@ -742,10 +741,6 @@ function toToolDefinition( if ( incompleteToolCallReason ) { throw new Error( incompleteToolCallReason ); } - const refusal = getPayloadLimitViolation( tool.name, params ); - if ( refusal ) { - throw new Error( refusal ); - } // An irreversible call names its site in its own question, so it // needs no second one about which site it changes. const destructive = describeDestructiveCall( tool.name, params ); diff --git a/apps/cli/ai/runtimes/pi/tests/tool-safety.test.ts b/apps/cli/ai/runtimes/pi/tests/tool-safety.test.ts deleted file mode 100644 index 079c4fc6b6..0000000000 --- a/apps/cli/ai/runtimes/pi/tests/tool-safety.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getPayloadLimitViolation, STUDIO_FILE_TOOL_MAX_BYTES } from '../tool-safety'; - -const big = 'x'.repeat( STUDIO_FILE_TOOL_MAX_BYTES + 1 ); -const half = 'x'.repeat( Math.ceil( STUDIO_FILE_TOOL_MAX_BYTES / 2 ) ); - -describe( 'getPayloadLimitViolation', () => { - it( 'rejects a Write whose content exceeds the limit', () => { - expect( getPayloadLimitViolation( 'Write', { path: 'a.css', content: big } ) ).toContain( - 'Write content' - ); - expect( getPayloadLimitViolation( 'Write', { path: 'a.css', content: half } ) ).toBeUndefined(); - } ); - - it( 'sums every entry of an Edit call', () => { - const oneBig = { path: 'a.css', edits: [ { oldText: '/* a */', newText: big } ] }; - expect( getPayloadLimitViolation( 'Edit', oneBig ) ).toContain( 'Edit edits' ); - - const twoHalves = { - path: 'a.css', - edits: [ - { oldText: '/* a */', newText: half }, - { oldText: '/* b */', newText: half }, - ], - }; - expect( getPayloadLimitViolation( 'Edit', twoHalves ) ).toContain( 'Edit edits' ); - - const small = { - path: 'a.css', - edits: [ { oldText: '/* a */', newText: 'a { color: red; }' } ], - }; - expect( getPayloadLimitViolation( 'Edit', small ) ).toBeUndefined(); - } ); - - it( 'reads an Edit whose edits arrived as a JSON string', () => { - const params = { - path: 'a.css', - edits: JSON.stringify( [ { oldText: '/* a */', newText: big } ] ), - }; - expect( getPayloadLimitViolation( 'Edit', params ) ).toContain( 'Edit edits' ); - } ); - - it( 'ignores Edit calls without a usable edits array', () => { - expect( getPayloadLimitViolation( 'Edit', { path: 'a.css' } ) ).toBeUndefined(); - expect( getPayloadLimitViolation( 'Edit', { path: 'a.css', edits: '{' } ) ).toBeUndefined(); - } ); -} ); diff --git a/apps/cli/ai/runtimes/pi/tool-safety.ts b/apps/cli/ai/runtimes/pi/tool-safety.ts index a5c6a57cff..57f377f550 100644 --- a/apps/cli/ai/runtimes/pi/tool-safety.ts +++ b/apps/cli/ai/runtimes/pi/tool-safety.ts @@ -2,23 +2,10 @@ import os from 'os'; import path from 'path'; import type { AgentSessionEvent } from '@earendil-works/pi-coding-agent'; -// Keep individual model-generated tool payloads below the range where long -// strings have been observed to arrive incomplete. -export const STUDIO_FILE_TOOL_MAX_BYTES = 14 * 1024; -export const STUDIO_BASH_COMMAND_MAX_BYTES = 8 * 1024; - export interface StudioToolPayloadGuardState { incompleteToolCallReasons?: Record< string, string >; } -function getByteLength( value: string ): number { - return Buffer.byteLength( value, 'utf8' ); -} - -function formatBytes( bytes: number ): string { - return `${ bytes } bytes`; -} - function getStringParam( params: unknown, key: string ): string | undefined { if ( ! params || typeof params !== 'object' ) { return undefined; @@ -27,81 +14,6 @@ function getStringParam( params: unknown, key: string ): string | undefined { return typeof value === 'string' ? value : undefined; } -function getEditEntries( params: unknown ): Array< { oldText: string; newText: string } > { - if ( ! params || typeof params !== 'object' ) { - return []; - } - let edits = ( params as Record< string, unknown > ).edits; - // Some models send the array as a JSON string; pi parses it the same way. - if ( typeof edits === 'string' ) { - try { - edits = JSON.parse( edits ); - } catch { - return []; - } - } - if ( ! Array.isArray( edits ) ) { - return []; - } - return edits.map( ( edit ) => ( { - oldText: getStringParam( edit, 'oldText' ) ?? '', - newText: getStringParam( edit, 'newText' ) ?? '', - } ) ); -} - -function getPayloadRecoveryAdvice( toolName: string ): string { - if ( toolName === 'Bash' ) { - return 'Split the work into smaller Write/Edit calls. Do not retry with Bash heredocs or Python scripts; they carry the same large payload risk.'; - } - return 'Write a small skeleton and fill it with Edit calls that stay under the limit, several edits[] entries per call. Do not split the content across multiple files to concatenate later; that hits the same limit on the concatenation step.'; -} - -function createPayloadLimitMessage( - toolName: string, - fieldName: string, - actualBytes: number, - maxBytes: number -): string { - return `${ toolName } ${ fieldName } is ${ formatBytes( - actualBytes - ) }, exceeding Studio's ${ formatBytes( - maxBytes - ) } single-call safety limit. ${ getPayloadRecoveryAdvice( toolName ) }`; -} - -export function getPayloadLimitViolation( toolName: string, params: unknown ): string | undefined { - if ( toolName === 'Write' ) { - const content = getStringParam( params, 'content' ); - const bytes = content ? getByteLength( content ) : 0; - if ( bytes > STUDIO_FILE_TOOL_MAX_BYTES ) { - return createPayloadLimitMessage( toolName, 'content', bytes, STUDIO_FILE_TOOL_MAX_BYTES ); - } - } - - if ( toolName === 'Edit' ) { - // pi's edit tool takes `{ path, edits: [ { oldText, newText } ] }`; the - // limit applies to the whole call, so sum every entry. - const edits = getEditEntries( params ); - const bytes = edits.reduce( - ( total, edit ) => total + getByteLength( edit.oldText ) + getByteLength( edit.newText ), - 0 - ); - if ( bytes > STUDIO_FILE_TOOL_MAX_BYTES ) { - return createPayloadLimitMessage( toolName, 'edits', bytes, STUDIO_FILE_TOOL_MAX_BYTES ); - } - } - - if ( toolName === 'Bash' ) { - const command = getStringParam( params, 'command' ); - const bytes = command ? getByteLength( command ) : 0; - if ( bytes > STUDIO_BASH_COMMAND_MAX_BYTES ) { - return createPayloadLimitMessage( toolName, 'command', bytes, STUDIO_BASH_COMMAND_MAX_BYTES ); - } - } - - return undefined; -} - function resolveToolPath( filePath: string, cwd: string ): string { const withoutAt = filePath.replace( /^@/, '' ); const expanded = @@ -146,9 +58,8 @@ function getInProgressToolCall( content: unknown ): { id: string; name: string } function createIncompleteToolCallMessage( toolName: string ): string { return ( `Refusing to run ${ toolName } because the assistant response hit the model output ` + - `limit while generating tool arguments; the arguments may be incomplete. ${ getPayloadRecoveryAdvice( - toolName - ) }` + 'limit while generating tool arguments; the arguments may be incomplete. Send the ' + + 'content in parts: a first Write with the start of the file, then Edit calls that add the rest.' ); } diff --git a/apps/cli/ai/tests/pi-runtime.test.ts b/apps/cli/ai/tests/pi-runtime.test.ts index 40aa089102..5c9e73410e 100644 --- a/apps/cli/ai/tests/pi-runtime.test.ts +++ b/apps/cli/ai/tests/pi-runtime.test.ts @@ -607,37 +607,6 @@ describe( 'pi runtime', () => { mocks.postChanges = { summaries: [], problems: [], written: [] }; } ); - it( 'rejects oversized direct Write, Edit, and Bash payloads', async () => { - await runRuntime( { - prompt: 'hello', - env: WPCOM_ENV, - model: 'balanced', - session: newSession(), - } ); - - const write = getCreatedTool( 'Write' ); - const edit = getCreatedTool( 'Edit' ); - const bash = getCreatedTool( 'Bash' ); - - await expect( - write.execute( 'write-call', { - path: '/tmp/studio/site/tmp/large.txt', - content: 'x'.repeat( 14 * 1024 + 1 ), - } ) - ).rejects.toThrow( /single-call safety limit/ ); - await expect( - edit.execute( 'edit-call', { - path: '/tmp/studio/site/tmp/large.txt', - edits: [ { oldText: '', newText: 'x'.repeat( 14 * 1024 + 1 ) } ], - } ) - ).rejects.toThrow( /single-call safety limit/ ); - await expect( - bash.execute( 'bash-call', { - command: 'x'.repeat( 8 * 1024 + 1 ), - } ) - ).rejects.toThrow( /single-call safety limit/ ); - } ); - it( 'rejects tool calls from length-truncated assistant messages', async () => { mocks.nextEvents = [ assistantMessage( From ea036e69f506aa76c2b5342f01d2e334c2210d52 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 18:50:47 -0400 Subject: [PATCH 086/199] Studio Code: save the editor's fix block by block, and move textAlign where 7.0 reads it The block validation gate declined a file's whole fix when any block in it would lose something, so one lossy block kept every other block invalid. In run 12's multi-page-site-menu case the agent spent 6.13M of 6.61M tokens after the first invalid-block note, and home.html still failed the judge. Losses are now found block by block in the editor: a block whose fix would drop classes, inline style declarations (read as the browser parses them, so .85rem is 0.85rem) or text stays as written, which the serializer keeps from its source, and the fix is saved for every other block. The note names only the blocks that keep something, with what the fix would lose. The file-wide attribute comparison is gone. The fix is made from each block's own attributes, with the comment attributes the parser drops merged back, so the only attribute differences left were the editor's own migrations (style.typography.fontFamily to fontFamily) and defaults (site-title level 1, navigation hasIcon true), which it flagged as losses. WordPress 7.0 moved textAlign to the typography.textAlign block support (migrate-text-align in @wordpress/block-library). A block with that support that never declared textAlign, as core/paragraph, now gets its comment's textAlign stored there, as the blocks that declared it migrate it. Replaying run 12's eight yoga theme files against the live editor: before, one file saved its fix and six were declined; after, five are fully fixed, home.html saves the fix for all but three blocks (each a placeholder style the agent wrote, "--wp--preset--spacing--30:..."), and header.html names its one group (position:sticky; top:0px, which the editor drops). --- apps/cli/ai/block-validation-gate.ts | 94 +++++------- apps/cli/ai/block-validator.ts | 135 ++++++++++++++++-- apps/cli/ai/post-content-check.ts | 13 +- .../ai/tests/block-validation-gate.test.ts | 72 +++++----- apps/cli/ai/tests/block-validator.test.ts | 68 ++++++++- apps/cli/ai/tests/post-content-check.test.ts | 27 +++- 6 files changed, 287 insertions(+), 122 deletions(-) diff --git a/apps/cli/ai/block-validation-gate.ts b/apps/cli/ai/block-validation-gate.ts index 393519b2ee..7bd69e4fd9 100644 --- a/apps/cli/ai/block-validation-gate.ts +++ b/apps/cli/ai/block-validation-gate.ts @@ -56,36 +56,6 @@ function hash( content: string ): string { return createHash( 'sha1' ).update( content ).digest( 'hex' ); } -function classTokens( html: string ): Set< string > { - const tokens = new Set< string >(); - for ( const match of html.matchAll( /\sclass="([^"]*)"/g ) ) { - match[ 1 ].split( /\s+/ ).forEach( ( token ) => token && tokens.add( token ) ); - } - return tokens; -} - -/** Every block comment attribute path, e.g. `core/group:layout.type`. */ -function attributePaths( html: string ): Set< string > { - const paths = new Set< string >(); - const walk = ( prefix: string, value: unknown ) => { - if ( value && typeof value === 'object' && ! Array.isArray( value ) ) { - for ( const [ key, child ] of Object.entries( value ) ) { - walk( prefix ? `${ prefix }.${ key }` : key, child ); - } - } else if ( ! prefix.endsWith( 'className' ) ) { - paths.add( prefix ); - } - }; - for ( const match of html.matchAll( //g ) ) { - try { - walk( `${ match[ 1 ] }:`, JSON.parse( match[ 2 ] ) ); - } catch { - // Unparseable attributes are the editor's to report. - } - } - return paths; -} - function oneLine( html: string, length: number ): string { const flat = html.replace( /\s+/g, ' ' ).trim(); return flat.length > length ? `${ flat.slice( 0, length ) }…` : flat; @@ -95,14 +65,19 @@ function oneLine( html: string, length: number ): string { * Each invalid block with every mismatch the editor found, and the HTML the * block saves from its comment attributes — the markup that validates. */ -function describeInvalidBlocks( report: ValidationReportBase ): string[] { +function describeInvalidBlocks( + report: ValidationReportBase, + lost = new Map< string, string >() +): string[] { return report.results - .filter( ( result ) => ! result.isValid ) + .filter( ( result ) => ( lost.size ? lost.has( result.place ?? '' ) : ! result.isValid ) ) .slice( 0, 5 ) .flatMap( ( result ) => [ ` - ${ result.blockName }: ${ - result.issues.slice( 0, 3 ).join( '; ' ) || 'does not match what the block saves' - }`, + lost.has( result.place ?? '' ) + ? `the editor's fix would ${ lost.get( result.place ?? '' ) }; ` + : '' + }${ result.issues.slice( 0, 3 ).join( '; ' ) || 'does not match what the block saves' }`, ...( result.originalContent ? [ ` written: ${ oneLine( result.originalContent, 700 ) }` ] : [] ), @@ -170,6 +145,7 @@ export async function validateBlockMarkupFile( summary: `${ result.summary }; saved through the block editor, which ${ notes.join( ' and ' ) }`, + ...( result.problem && { problem: result.problem } ), fixedContent: result.fixedContent, }; } @@ -223,38 +199,32 @@ export async function validateBlockMarkup( }; } - const lostText = ! fix.sameText; - const fixedClasses = classTokens( fix.fixedContent ); - const droppedClasses = [ ...classTokens( content ) ].filter( - ( token ) => ! fixedClasses.has( token ) - ); - const fixedAttributes = attributePaths( fix.fixedContent ); - const droppedAttributes = [ ...attributePaths( content ) ].filter( - ( attribute ) => ! fixedAttributes.has( attribute ) - ); - if ( ! lostText && droppedClasses.length === 0 && droppedAttributes.length === 0 ) { - const presets = await storePaletteColors( fix.fixedContent, siteUrl, palette ); - return { - summary: valid, - fixedContent: presets.content, - invalidBlocks: report.invalidBlocks, - ...( presets.colors.length && { paletteColors: presets.colors } ), - }; + // A block stays as written when the fix loses something from it or from a + // block it is inside; the editor's fix is saved for the rest. + const lost = new Map( fix.losses.map( ( loss ) => [ loss.place, loss.lost ] ) ); + const keptAsWritten = ( place = '' ) => + [ ...lost.keys() ].some( ( at ) => place === at || place.startsWith( `${ at }/` ) ); + const fixedBlocks = report.results.filter( + ( result ) => ! result.isValid && ! keptAsWritten( result.place ) + ).length; + const presets = fixedBlocks + ? await storePaletteColors( fix.fixedContent, siteUrl, palette ) + : undefined; + const saved = presets && { + fixedContent: presets.content, + invalidBlocks: fixedBlocks, + ...( presets.colors.length && { paletteColors: presets.colors } ), + }; + if ( lost.size === 0 ) { + return { summary: valid, ...saved }; } return { - summary: `${ label }: ${ report.invalidBlocks } invalid block(s)`, + summary: `${ label }: ${ report.invalidBlocks - fixedBlocks } invalid block(s)`, problem: [ - `${ label }: ${ - report.invalidBlocks - } block(s) do not match what the block saves, and the editor's fix would ${ - lostText - ? 'change the text' - : droppedClasses.length - ? `drop the classes ${ droppedClasses.join( ' ' ) }` - : `drop the attributes ${ droppedAttributes.join( ', ' ) }` - }. Change the HTML to what the block saves, or change the comment attributes the block saves it from (className and style carry extra classes and styles):`, - ...describeInvalidBlocks( report ), + `${ label }: ${ lost.size } block(s) do not match what the block saves, and the editor's fix would lose something from them. Change them to what the block saves, or change the comment attributes the block saves them from (className and style carry extra classes and styles):`, + ...describeInvalidBlocks( report, lost ), ].join( '\n' ), + ...saved, }; } diff --git a/apps/cli/ai/block-validator.ts b/apps/cli/ai/block-validator.ts index c7149aefec..7da6019ebb 100644 --- a/apps/cli/ai/block-validator.ts +++ b/apps/cli/ai/block-validator.ts @@ -2,6 +2,8 @@ import { EditorPage } from 'cli/ai/browser-utils'; interface BlockValidationResult { blockName: string; + /** The block's place in the content, as `/0/2` for the third block in the first. */ + place?: string; isValid: boolean; issues: string[]; originalContent: string; @@ -17,10 +19,18 @@ export interface ValidationReportBase { } export interface BlockFixProposal { + /** What the editor saves for every block that loses nothing; the rest stay as written. */ fixedContent: string; report: ValidationReportBase; - /** Whether the fix shows the same text, read as the browser reads it. */ - sameText: boolean; + /** The blocks the fix loses something from, and what. */ + losses: BlockLoss[]; +} + +export interface BlockLoss { + place: string; + blockName: string; + /** What the fix loses, as `drop the styles position:sticky`. */ + lost: string; } export interface ValidationReport extends ValidationReportBase { @@ -218,10 +228,12 @@ export async function validateBlocks( function validateContent( blockHtml: string ) { const blocks = wpBlocks.parse( blockHtml ); const results: any[] = []; + // Each block as written, by place, for a block that stays as written. + const written = new Map< string, any >(); // `raw` is the block as written, from the default parser; it is // only checked when it is the same block type. - function validateRecursive( block: any, raw?: any ) { + function validateRecursive( block: any, raw: any, place: string ) { if ( ! block.name ) { return; } @@ -300,8 +312,10 @@ export async function validateBlocks( issues = [ ...issues, ...attributeProblems ]; } + written.set( place, raw ); results.push( { blockName: block.name, + place, isValid, issues, originalContent: block.originalContent || '', @@ -309,7 +323,11 @@ export async function validateBlocks( } ); ( block.innerBlocks ?? [] ).forEach( ( inner: any, i: number ) => - validateRecursive( inner, sameBlock ? raw.innerBlocks?.[ i ] : undefined ) + validateRecursive( + inner, + sameBlock ? raw.innerBlocks?.[ i ] : undefined, + `${ place }/${ i }` + ) ); } @@ -332,13 +350,17 @@ export async function validateBlocks( } ); } } - blocks - .filter( ( block: any ) => block.name !== 'core/freeform' ) - .forEach( ( block: any, i: number ) => validateRecursive( block, rawBlocks[ i ] ) ); + let named = 0; + blocks.forEach( ( block: any, i: number ) => { + if ( block.name !== 'core/freeform' ) { + validateRecursive( block, rawBlocks[ named++ ], `/${ i }` ); + } + } ); const validCount = results.filter( ( r: any ) => r.isValid ).length; return { blocks, + written, report: { totalBlocks: results.length, validBlocks: validCount, @@ -379,6 +401,22 @@ export async function validateBlocks( withComment[ key ] = value; } } + // WordPress 7.0 moved `textAlign` to the typography.textAlign block + // support, and the blocks that declared it migrate it there + // (migrate-text-align in @wordpress/block-library). A block with the + // support that never declared it reads it from there the same way. + const textAlign = sameBlock ? raw.attrs?.textAlign : undefined; + if ( + typeof textAlign === 'string' && + ! blockType.attributes?.textAlign && + blockType.supports?.typography?.textAlign && + withComment.style?.typography?.textAlign === undefined + ) { + withComment.style = { + ...withComment.style, + typography: { ...withComment.style?.typography, textAlign }, + }; + } const inner = fixedInnerBlocks.length > 0 ? fixedInnerBlocks : undefined; // Re-creating a block the parser accepted can only lose content — @@ -422,17 +460,84 @@ export async function validateBlocks( } const fixed = validateContent( fixedContent ); - // Entities and whitespace the serializer rewrites are the same text. - const textOf = ( markup: string ) => - ( new DOMParser().parseFromString( markup, 'text/html' ).body.textContent ?? '' ) - .replace( /\s+/g, ' ' ) - .trim(); + + // A block's own markup, without its inner blocks: its classes, inline + // style declarations and text, as the browser reads them. + function ownMarkup( markup: string ) { + const body = new DOMParser().parseFromString( markup, 'text/html' ).body; + const classes = new Set< string >(); + const styles = new Set< string >(); + body.querySelectorAll< HTMLElement >( '*' ).forEach( ( element ) => { + element.classList.forEach( ( name ) => classes.add( name ) ); + // As the browser reads each declaration, so .85rem is 0.85rem. + for ( const property of Array.from( element.style ) ) { + styles.add( `${ property }:${ element.style.getPropertyValue( property ) }` ); + } + } ); + // Entities and whitespace the serializer rewrites are the same text. + return { classes, styles, text: ( body.textContent ?? '' ).replace( /\s+/g, ' ' ).trim() }; + } + + // What the fix loses from each block as written, block by block: its + // text, classes and styles. Its attributes it keeps, being made from + // them, apart from the editor's own migrations. + function losses( written: any[], fixes: any[], at = '' ): any[] { + return written.flatMap( ( block: any, i: number ) => { + const place = `${ at }/${ i }`; + const fix = fixes[ i ]; + if ( ! block.name || ! fix || fix.name !== block.name ) { + return []; + } + const before = ownMarkup( block.originalContent ?? '' ); + const after = ownMarkup( fix.originalContent ?? '' ); + const missing = ( names: Set< string >, kept: Set< string > ) => + [ ...names ].filter( ( name ) => ! kept.has( name ) ); + const classes = missing( before.classes, after.classes ); + const styles = missing( before.styles, after.styles ); + const lost = [ + before.text !== after.text ? 'change the text' : '', + classes.length ? `drop the classes ${ classes.join( ' ' ) }` : '', + styles.length ? `drop the styles ${ styles.join( '; ' ) }` : '', + ].filter( Boolean ); + return [ + ...( lost.length + ? [ + { + place, + blockName: block.name, + lost: lost.join( ' and ' ), + }, + ] + : [] ), + ...losses( block.innerBlocks ?? [], fix.innerBlocks ?? [], place ), + ]; + } ); + } + + // The fix for every block that loses nothing. A block that would lose + // something stays as written, inner blocks and all: the serializer + // keeps a block that does not validate from its source. + const lossList = losses( initial.blocks, fixed.blocks ); + const lossy = new Set( lossList.map( ( loss: any ) => loss.place ) ); + function lossless( fixes: any[], at = '' ): any[] { + return fixes.map( ( fix: any, i: number ) => { + const place = `${ at }/${ i }`; + const raw = lossy.has( place ) && initial.written.get( place ); + return raw + ? { ...fix, isValid: false, __unstableBlockSource: raw } + : { ...fix, innerBlocks: lossless( fix.innerBlocks ?? [], place ) }; + } ); + } + const losslessContent = lossList.length + ? wpBlocks.serialize( lossless( fixed.blocks ) ) + : fixedContent; + return { report: initial.report, proposedFix: { - fixedContent, + fixedContent: losslessContent, report: fixed.report, - sameText: textOf( fixedContent ) === textOf( html ), + losses: lossList, }, }; /* eslint-enable @typescript-eslint/no-explicit-any */ @@ -444,7 +549,7 @@ export async function validateBlocks( report.proposedFix = { fixedContent: validation.proposedFix.fixedContent, report: validation.proposedFix.report as ValidationReportBase, - sameText: validation.proposedFix.sameText, + losses: validation.proposedFix.losses, }; } diff --git a/apps/cli/ai/post-content-check.ts b/apps/cli/ai/post-content-check.ts index 776fc0e92d..d7c6b74af0 100644 --- a/apps/cli/ai/post-content-check.ts +++ b/apps/cli/ai/post-content-check.ts @@ -88,11 +88,14 @@ async function validatePost( site: SiteData, post: Row ) { summary: result.invalidBlocks ? `${ label }: ${ result.invalidBlocks } invalid block(s)` : result.summary, - problem: `${ label }: the block editor's save ${ saves.join( - ' and ' - ) }, losing nothing; it is saved in ${ shellPath }. Apply it in the site's folder with \`wp post update ${ - post.id - } ${ shellPath }\`.`, + problem: [ + `${ label }: the block editor's save ${ saves.join( + ' and ' + ) }, losing nothing; it is saved in ${ shellPath }. Apply it in the site's folder with \`wp post update ${ + post.id + } ${ shellPath }\`.`, + ...( result.problem ? [ result.problem ] : [] ), + ].join( '\n' ), }; } diff --git a/apps/cli/ai/tests/block-validation-gate.test.ts b/apps/cli/ai/tests/block-validation-gate.test.ts index 8541cdf27d..3fbfd06466 100644 --- a/apps/cli/ai/tests/block-validation-gate.test.ts +++ b/apps/cli/ai/tests/block-validation-gate.test.ts @@ -8,7 +8,12 @@ import { isBlockMarkupFile, validateBlockMarkupFile, } from 'cli/ai/block-validation-gate'; -import { storePaletteColors, validateBlocks, type ValidationReport } from 'cli/ai/block-validator'; +import { + storePaletteColors, + validateBlocks, + type BlockLoss, + type ValidationReport, +} from 'cli/ai/block-validator'; import { OpenProblems } from 'cli/ai/open-problems'; vi.mock( 'cli/ai/block-validator', () => ( { @@ -25,27 +30,24 @@ function write( relativePath: string, content: string ) { return file; } -function invalidWithFix( - fixedContent: string, - fixValid = true, - sameText = true -): ValidationReport { +function invalidWithFix( fixedContent: string, losses: BlockLoss[] = [] ): ValidationReport { return { totalBlocks: 1, validBlocks: 0, invalidBlocks: 1, results: [ - { blockName: 'core/paragraph', isValid: false, issues: [ 'mismatch' ], originalContent: '' }, + { + blockName: 'core/paragraph', + place: '/0', + isValid: false, + issues: [ 'mismatch' ], + originalContent: '', + }, ], proposedFix: { fixedContent, - sameText, - report: { - totalBlocks: 1, - validBlocks: fixValid ? 1 : 0, - invalidBlocks: fixValid ? 0 : 1, - results: [], - }, + losses, + report: { totalBlocks: 1, validBlocks: 1, invalidBlocks: 0, results: [] }, }, }; } @@ -113,18 +115,33 @@ describe( 'validating a file', () => { expect( result.fixedContent ).toBe( fixed ); } ); - it( 'refuses a fix that would drop classes, and says what to change', async () => { - const file = write( - 'tmp/home.html', - '

Hello

' + it( 'saves the fix for the blocks that lose nothing, and names the blocks that would', async () => { + const file = write( 'tmp/home.html', `${ original }\n${ original }` ); + const report = invalidWithFix( `${ fixed }\n${ original }`, [ + { place: '/1', blockName: 'core/paragraph', lost: 'drop the classes eyebrow' }, + ] ); + report.invalidBlocks = 2; + report.results.push( { ...report.results[ 0 ], place: '/1', issues: [ 'second' ] } ); + vi.mocked( validateBlocks ).mockResolvedValue( report ); + const result = await validateBlockMarkupFile( file, 'http://localhost:8881' ); + expect( result.fixedContent ).toBe( `${ fixed }\n${ original }` ); + expect( result.summary ).toBe( + 'tmp/home.html: 1 invalid block(s); saved through the block editor, which fixes 1 block(s)' ); - vi.mocked( validateBlocks ).mockResolvedValue( - invalidWithFix( '\n

Hello

\n' ) + expect( result.problem ).toContain( + "core/paragraph: the editor's fix would drop the classes eyebrow; second" ); + expect( result.problem ).not.toContain( 'mismatch' ); + } ); + + it( 'saves nothing when the fix itself does not validate', async () => { + const file = write( 'tmp/home.html', original ); + const report = invalidWithFix( fixed ); + report.proposedFix!.report.invalidBlocks = 1; + vi.mocked( validateBlocks ).mockResolvedValue( report ); const result = await validateBlockMarkupFile( file, 'http://localhost:8881' ); - expect( result.problem ).toContain( 'drop the classes eyebrow' ); - expect( result.problem ).toContain( 'className' ); - expect( readFileSync( file, 'utf8' ) ).toContain( 'class="eyebrow"' ); + expect( result.fixedContent ).toBeUndefined(); + expect( result.problem ).toContain( 'which the block editor cannot fix' ); } ); it( 'shows each mismatch and the HTML the block saves', async () => { @@ -155,15 +172,6 @@ describe( 'validating a file', () => { ); } ); - it( 'refuses a fix that would change the text', async () => { - const file = write( 'tmp/home.html', original ); - vi.mocked( validateBlocks ).mockResolvedValue( - invalidWithFix( '\n

\n', true, false ) - ); - const result = await validateBlockMarkupFile( file, 'http://localhost:8881' ); - expect( result.problem ).toContain( 'change the text' ); - } ); - it( 'gates core/html policy before the editor runs', async () => { const file = write( 'tmp/home.html', diff --git a/apps/cli/ai/tests/block-validator.test.ts b/apps/cli/ai/tests/block-validator.test.ts index 0468f36252..92b0dd38e6 100644 --- a/apps/cli/ai/tests/block-validator.test.ts +++ b/apps/cli/ai/tests/block-validator.test.ts @@ -40,13 +40,26 @@ describe.runIf( siteUrl )( 'validating in the block editor (live)', () => { '

Eyebrow

', ], }, + { + name: "a paragraph's textAlign moves to where WordPress 7.0 reads it", + markup: paragraph( '{"textAlign":"center"}', '

Centered

' ), + fixed: [ '{"style":{"typography":{"textAlign":"center"}}}', 'has-text-align-center' ], + }, + { + name: "an attribute at the block's default is kept", + markup: group( + '{}', + '\n\n

Hi

\n' + ), + fixed: [ '
' ], + }, ] )( '$name', async ( { markup, fixed } ) => { const report = await validateBlocks( markup, siteUrl! ); expect( report.error ).toBeUndefined(); expect( report.proposedFix?.report.invalidBlocks ).toBe( 0 ); - expect( report.proposedFix?.sameText ).toBe( true ); + expect( report.proposedFix?.losses ).toEqual( [] ); for ( const text of fixed ) { expect( report.proposedFix?.fixedContent ).toContain( text ); } @@ -56,16 +69,20 @@ describe.runIf( siteUrl )( 'validating in the block editor (live)', () => { it.each( [ { - name: 'a block that renders as written', + name: 'leaves it out of the fix', html: '
', + losses: [], }, { - name: 'a block that renders the value too', + name: 'keeps the block as written when it renders the value too', html: '
', + losses: [ + { place: '/0', blockName: 'core/cover', lost: 'drop the styles min-height:100dvh' }, + ], }, ] )( - 'reports an attribute of the wrong type, and leaves it out of the fix: $name', - async ( { html } ) => { + 'reports an attribute of the wrong type, and $name', + async ( { html, losses } ) => { const report = await validateBlocks( `\n${ html }\n`, siteUrl! @@ -73,8 +90,8 @@ describe.runIf( siteUrl )( 'validating in the block editor (live)', () => { expect( report.results[ 0 ].issues ).toContain( 'core/cover\'s "minHeight" is of type number, so WordPress ignores "100dvh".' ); + expect( report.proposedFix?.losses ).toEqual( losses ); expect( report.proposedFix?.report.invalidBlocks ).toBe( 0 ); - expect( report.proposedFix?.fixedContent ).not.toContain( '100dvh' ); }, 60_000 ); @@ -95,8 +112,45 @@ describe.runIf( siteUrl )( 'validating in the block editor (live)', () => { group( '{}', paragraph( '{}', '

A — B C

' ) ), siteUrl! ); - expect( report.proposedFix?.sameText ).toBe( true ); + expect( report.proposedFix?.losses ).toEqual( [] ); }, 60_000 ); + + it.each( [ + { + name: 'its classes', + written: + '\n
\n', + blockName: 'core/image', + lost: 'drop the classes photo', + }, + { + name: 'its styles', + written: paragraph( '{}', '

Eyebrow

' ), + blockName: 'core/paragraph', + lost: 'drop the styles position:sticky; top:0px', + }, + { + name: 'its text', + written: paragraph( '{}', '
Lost words
' ), + blockName: 'core/paragraph', + lost: 'change the text', + }, + ] )( + 'names the block whose fix would lose $name, keeps it as written and saves the fix for the rest', + async ( { written, blockName, lost } ) => { + const markup = group( + '{}', + `${ paragraph( '{"textAlign":"center"}', '

Centered

' ) }\n${ written }` + ); + const report = await validateBlocks( markup, siteUrl! ); + expect( report.proposedFix?.losses ).toEqual( [ { place: '/0/1', blockName, lost } ] ); + expect( report.proposedFix?.fixedContent ).toContain( 'has-text-align-center' ); + expect( report.proposedFix?.fixedContent ).toContain( + `-->\n${ written.split( '\n' )[ 1 ] }\n'; vi.mocked( storePaletteColors ).mockResolvedValueOnce( { From 357e2c564c80d78f4762935fb5a9b70e8e713a9c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 21:27:48 -0400 Subject: [PATCH 087/199] Studio Code: leave comment attributes of the wrong type out of the editor's fix normalizeBlock merges back the comment attributes the parser drops, so a value WordPress ignores for its type came back too: in run 13's yoga case a core/columns with "verticalAlignment":null made the fix itself invalid, and the gate declined Home's whole fix ("23 invalid block(s), which the block editor cannot fix"). The merge now keeps only values of the attribute's type, as WordPress reads them. Replayed against the live editor, that Home fix validates (fixInvalid 1 -> 0) and saves for all but four images. --- apps/cli/ai/block-validator.ts | 4 ++- apps/cli/ai/tests/block-validator.test.ts | 32 ++++++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/apps/cli/ai/block-validator.ts b/apps/cli/ai/block-validator.ts index 7da6019ebb..bc584be5bb 100644 --- a/apps/cli/ai/block-validator.ts +++ b/apps/cli/ai/block-validator.ts @@ -397,7 +397,9 @@ export async function validateBlocks( const parsed = migration?.attributes ?? block.attributes; const withComment = { ...parsed }; for ( const [ key, value ] of Object.entries( sameBlock ? raw.attrs ?? {} : {} ) ) { - if ( withComment[ key ] === undefined && blockType.attributes?.[ key ] ) { + // A value of the wrong type WordPress ignores, and so does the fix. + const schema = blockType.attributes?.[ key ]; + if ( withComment[ key ] === undefined && schema && hasType( value, schema.type ) ) { withComment[ key ] = value; } } diff --git a/apps/cli/ai/tests/block-validator.test.ts b/apps/cli/ai/tests/block-validator.test.ts index 92b0dd38e6..e2c2f3a387 100644 --- a/apps/cli/ai/tests/block-validator.test.ts +++ b/apps/cli/ai/tests/block-validator.test.ts @@ -67,29 +67,41 @@ describe.runIf( siteUrl )( 'validating in the block editor (live)', () => { 60_000 ); + const cover = ( html: string ) => + `\n${ html }\n`; + const coverIssue = 'core/cover\'s "minHeight" is of type number, so WordPress ignores "100dvh".'; + it.each( [ { name: 'leaves it out of the fix', - html: '
', + markup: cover( + '
' + ), + issue: coverIssue, losses: [], }, { name: 'keeps the block as written when it renders the value too', - html: '
', + markup: cover( + '
' + ), + issue: coverIssue, losses: [ { place: '/0', blockName: 'core/cover', lost: 'drop the styles min-height:100dvh' }, ], }, + { + name: 'leaves a value the block markup does not hold out of the fix', + markup: + '\n
\n', + issue: 'core/columns\'s "verticalAlignment" is of type string, so WordPress ignores null.', + losses: [], + }, ] )( 'reports an attribute of the wrong type, and $name', - async ( { html, losses } ) => { - const report = await validateBlocks( - `\n${ html }\n`, - siteUrl! - ); - expect( report.results[ 0 ].issues ).toContain( - 'core/cover\'s "minHeight" is of type number, so WordPress ignores "100dvh".' - ); + async ( { markup, issue, losses } ) => { + const report = await validateBlocks( markup, siteUrl! ); + expect( report.results[ 0 ].issues ).toContain( issue ); expect( report.proposedFix?.losses ).toEqual( losses ); expect( report.proposedFix?.report.invalidBlocks ).toBe( 0 ); }, From f04bb184495c95a1f4c4a2034fe03d8ce2d346b7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 24 Oct 2026 21:27:48 -0400 Subject: [PATCH 088/199] Studio Code: check that the posts page is a page without content WP_Query::parse_query queries the posts index at the page Settings -> Reading names as page_for_posts (is_page false, is_home true), so that page's content shows nowhere, and the editor says so ("You are currently editing the page that shows your latest posts.", edit-form-advanced.php). In run 13's yoga case the agent named the Classes page as the posts page and spent four calls finding why /classes/ showed the sample blog. postsPageContent is an end-of-turn check beside frontPageIsPostsPage, which keeps the case of the front page. --- apps/cli/ai/open-problems.ts | 26 ++++++++++++++++++ apps/cli/ai/tests/open-problems.test.ts | 36 ++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/cli/ai/open-problems.ts b/apps/cli/ai/open-problems.ts index 300c0aa0a8..30c8ca5a8a 100644 --- a/apps/cli/ai/open-problems.ts +++ b/apps/cli/ai/open-problems.ts @@ -36,6 +36,7 @@ export class OpenProblems { private readonly siteChecks: Array< ( sitePath: string ) => Promise< string | undefined > > = [ unrenderedFrontPage, frontPageIsPostsPage, + postsPageContent, homeSectionLinks, ] ) {} @@ -118,6 +119,31 @@ export async function frontPageIsPostsPage( sitePath: string ): Promise< string : undefined; } +/** + * Settings → Reading naming a page with content as the posts page, where + * WordPress shows the posts index instead (WP_Query::parse_query). The same + * page as the front page is frontPageIsPostsPage's. + */ +export async function postsPageContent( sitePath: string ): Promise< string | undefined > { + const page = await readWordPressSite( sitePath, ( site ) => { + const postsPage = Number( site.option( 'page_for_posts' ) ); + if ( + site.option( 'show_on_front' ) !== 'page' || + postsPage === Number( site.option( 'page_on_front' ) ) + ) { + return undefined; + } + const [ post ] = site.query< { title: string; content: string } >( + `SELECT post_title AS title, post_content AS content FROM ${ site.prefix }posts WHERE ID = ?`, + postsPage + ); + return post?.content.trim() ? { id: postsPage, title: post.title } : undefined; + } ); + return page + ? `Settings → Reading names page ${ page.id } "${ page.title }", which has content, as the posts page (page_for_posts): WordPress queries the posts index at that page, so its content shows nowhere (WP_Query::parse_query in wp-includes/class-wp-query.php), and the editor shows "You are currently editing the page that shows your latest posts." (wp-admin/edit-form-advanced.php). Set page_for_posts to an empty page for the posts index, or to 0 for none.` + : undefined; +} + /** * A front-page template of the active theme that renders no page's content, * which WordPress shows at the front page whatever Settings → Reading picks diff --git a/apps/cli/ai/tests/open-problems.test.ts b/apps/cli/ai/tests/open-problems.test.ts index 8275addd85..7998927a96 100644 --- a/apps/cli/ai/tests/open-problems.test.ts +++ b/apps/cli/ai/tests/open-problems.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { describeOpenProblems, frontPageIsPostsPage, OpenProblems } from 'cli/ai/open-problems'; +import { + describeOpenProblems, + frontPageIsPostsPage, + OpenProblems, + postsPageContent, +} from 'cli/ai/open-problems'; import { createSite } from 'cli/ai/tests/fixtures/wordpress-site'; describe( 'OpenProblems', () => { @@ -56,3 +61,32 @@ describe( 'frontPageIsPostsPage', () => { ); } ); } ); + +describe( 'postsPageContent', () => { + it.each( [ + [ 'a page with content', { page_for_posts: '6' }, true ], + [ 'an empty page', { page_for_posts: '8' }, false ], + [ 'no posts page', { page_for_posts: '0' }, false ], + [ 'latest posts at the front', { show_on_front: 'posts', page_for_posts: '6' }, false ], + [ 'the front page, which frontPageIsPostsPage reports', { page_for_posts: '7' }, false ], + ] )( '%s', async ( _name, options, raised ) => { + const site = createSite(); + site.save( + 6, + 'page', + 'classes', + '

Yoga

', + 1, + 'publish', + 'Classes' + ); + site.save( 7, 'page', 'home', 'Welcome', 1, 'publish', 'Home' ); + site.save( 8, 'page', 'journal', ' \n', 1, 'publish', 'Journal' ); + site.setOptions( { show_on_front: 'page', page_on_front: '7', ...options } ); + expect( await postsPageContent( site.root ) ).toBe( + raised + ? 'Settings → Reading names page 6 "Classes", which has content, as the posts page (page_for_posts): WordPress queries the posts index at that page, so its content shows nowhere (WP_Query::parse_query in wp-includes/class-wp-query.php), and the editor shows "You are currently editing the page that shows your latest posts." (wp-admin/edit-form-advanced.php). Set page_for_posts to an empty page for the posts index, or to 0 for none.' + : undefined + ); + } ); +} ); From 93c3ff3767e5dc4723b4ea87751312d79fd7da8c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 25 Oct 2026 01:19:18 -0400 Subject: [PATCH 089/199] Studio Code: check that the header's links to pages are a navigation core/navigation is the block that renders a site's menu: a