diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 691cdded4..7ddc926d0 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-marketplace.json", "name": "pascal", - "version": "0.1.5", + "version": "0.1.6", "description": "Public Pascal workflows for MCP-capable agents.", "owner": { "name": "Pascal" @@ -11,7 +11,7 @@ "name": "pascal-agent-skills", "source": "./", "description": "Create and inspect editable Pascal scenes and run bounded furniture footprint assessments with blocker-aware next actions.", - "version": "0.1.5", + "version": "0.1.6", "category": "productivity", "skills": ["./skills/pascal-3d", "./skills/furniture-fit"] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e5cb3d04d..e855e7e38 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin.json", "name": "pascal-agent-skills", "displayName": "Pascal agent skills", - "version": "0.1.5", + "version": "0.1.6", "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.", "author": { "name": "Pascal" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index ce111bdf3..1b79a713e 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "pascal-agent-skills", - "version": "0.1.5", + "version": "0.1.6", "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.", "author": { "name": "Pascal", diff --git a/README.md b/README.md index 19c821fab..2aa71da3a 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ OpenClaw installation becomes available after the skills are published under Pas The skills inspect the connected MCP tool schemas before using optional fields. A capability present in this repository may be absent from an older installed or hosted release; the agent should report the narrower supported result instead of assuming source-only inputs are available. +These workflows require a connected Pascal MCP server for their tool-backed actions. An OpenAI directory submission must therefore use **With MCP** and submit the production hosted MCP endpoint together with the skills. The repository package does not prove that the endpoint, OAuth flow, reviewer credentials, domain verification, or portal scan is ready for review. + ## Using Published Packages The viewer runtime and built-in node definitions are separate packages. Install the full built-in diff --git a/bun.lock b/bun.lock index a7c5c711c..d785effb1 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@biomejs/biome": "^2.4.16", "@typescript/native-preview": "7.0.0-dev.20260624.1", "dotenv-cli": "^11.0.0", + "fast-xml-parser": "^5.4.2", "turbo": "^2.9.17", "typescript": "6.0.3", "ultracite": "^7.8.2", diff --git a/package.json b/package.json index 56f12089b..732ddfcbf 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@biomejs/biome": "^2.4.16", "@typescript/native-preview": "7.0.0-dev.20260624.1", "dotenv-cli": "^11.0.0", + "fast-xml-parser": "^5.4.2", "turbo": "^2.9.17", "typescript": "6.0.3", "ultracite": "^7.8.2" diff --git a/plugin-evals/README.md b/plugin-evals/README.md index 630f83360..001219049 100644 --- a/plugin-evals/README.md +++ b/plugin-evals/README.md @@ -1,8 +1,8 @@ # Publishing evaluation fixtures -`publishing-cases.json` is the minimum cross-skill release suite. It contains at least five positive cases with reproducible fixtures and expected result shapes, plus three negative or refusal-boundary cases with explicit reasons the plugin must not complete the requested action. Each standalone skill also bundles: +`publishing-cases.json` is the draft cross-skill review suite for a future OpenAI **With MCP** submission. It contains at least five positive cases with expected result shapes, plus three negative or refusal-boundary cases with explicit reasons the plugin must not complete the requested action. The suite is blocked until Pascal provisions a stable hosted MCP review environment, OAuth-compatible reviewer access, and named disposable fixtures that reviewers can use without internal context. Each standalone skill also bundles: - `evals/evals.json` for task behavior; - `evals/trigger-evals.json` for description routing, with at least five positive and three negative queries. -The shared suite lives outside `skills/` so an OpenAI skills-only upload sees only valid immediate skill directories there. These are fixtures for model evaluation. Their presence does not mean a native host has passed them; record those results separately when the release candidate is tested. +The shared suite lives outside `skills/` because it is release evidence rather than skill runtime content. These draft cases are not reproducible reviewer materials yet, and their presence does not mean a native host or OpenAI portal scan has passed them. Record those results separately after the blocked hosted-MCP prerequisites exist. diff --git a/plugin-evals/publishing-cases.json b/plugin-evals/publishing-cases.json index c031ed8d3..6e645d2c6 100644 --- a/plugin-evals/publishing-cases.json +++ b/plugin-evals/publishing-cases.json @@ -1,14 +1,23 @@ { "suite": "pascal-agent-skills-publishing", + "submission_route": "with_mcp", + "status": "blocked", + "blockers": [ + "Production hosted MCP endpoint has not passed OpenAI Scan Tools for this candidate.", + "OAuth-compatible reviewer access and reusable demo credentials without MFA, SMS, email confirmation, or private-network access are not prepared.", + "Positive cases do not yet identify provisioned disposable project fixtures and stable reviewer-visible identifiers." + ], "cases": [ { - "id": "foundation-local-positive", + "id": "foundation-hosted-create-room-positive", "skill": "pascal-3d", "kind": "positive", - "prompt": "Create a local Pascal room from Codex and return a verified editor link without uploading it.", - "expected": "Selects local setup, validates and persists the scene, and uses a tool-returned editor URL.", + "prompt": "Create a 3.6 by 2.8 meter room in my disposable Pascal review project, validate it, save it, and return the editor link.", + "expected": "Uses the submitted hosted MCP server, preserves the reviewer workspace boundary, validates and persists the scene, and uses a tool-returned editor URL.", "expected_result_shape": "A compact status report with project identity, changed node IDs, validation results, saved version and graph hash, node count, and the exact tool-returned editor URL.", - "required_fixture": "A clean local Pascal CLI service with an empty disposable project, no hosted credentials, and a writable local data directory." + "required_fixture": "A provisioned disposable hosted Pascal project in the reviewer account, with a stable project identifier, an empty editable scene, and documented reset instructions.", + "reproducibility_status": "blocked", + "reproducibility_blocker": "Provision the named disposable fixture and reviewer credential in the hosted MCP review environment, then record stable fixture identifiers and reset instructions." }, { "id": "foundation-existing-workspace-positive", @@ -17,16 +26,20 @@ "prompt": "Use my existing Pascal organization project and add one door.", "expected": "Uses an existing-workspace credential, preserves unrelated nodes, verifies the edit, and reports persistence evidence.", "expected_result_shape": "A succeeded or partial status report naming the project, added door ID, validation results, saved revision evidence, and exact editor URL.", - "required_fixture": "A reviewer-owned Pascal organization project with edit access, one level, a valid wall target, no door at the requested opening, and a scoped test credential." + "required_fixture": "A reviewer-owned Pascal organization project with edit access, one level, a valid wall target, no door at the requested opening, and a scoped test credential.", + "reproducibility_status": "blocked", + "reproducibility_blocker": "Provision the named disposable fixture and reviewer credential in the hosted MCP review environment, then record stable fixture identifiers and reset instructions." }, { - "id": "foundation-authorized-registration-positive", + "id": "foundation-hosted-create-project-positive", "skill": "pascal-3d", "kind": "positive", - "prompt": "You may create a separate private Pascal agent account for this new model.", - "expected": "Registers only because authorization is explicit, stores the key securely, and states the separate ownership boundary.", - "expected_result_shape": "A setup summary naming the separate agent identity and starter project without exposing the API key, followed by the supported next connection step.", - "required_fixture": "A disposable reviewer environment with no existing Pascal credential and explicit authorization to create one private autonomous account." + "prompt": "Create a new private Pascal project named OpenAI Review Fixture and return its editor link.", + "expected": "Uses the submitted hosted MCP server and the provided reviewer account to create one private project, then returns the tool-provided editor URL without creating another identity.", + "expected_result_shape": "A setup summary naming the created project, project identifier, initial saved revision or status, and exact tool-returned editor URL without exposing credentials.", + "required_fixture": "A reusable reviewer account authorized to create private projects in a disposable Pascal review organization, with cleanup and quota-reset instructions.", + "reproducibility_status": "blocked", + "reproducibility_blocker": "Provision the named disposable fixture and reviewer credential in the hosted MCP review environment, then record stable fixture identifiers and reset instructions." }, { "id": "furniture-existing-pose-positive", @@ -35,7 +48,9 @@ "prompt": "Check the existing sofa at its current pose for overlap and 24 inches of walking clearance without moving it.", "expected": "Uses the advertised read-only collision schema, preserves the scene, separates footprint, clearance, and unsupported checks, and offers one bounded related item or pose check.", "expected_result_shape": "A furniture-fit report with verdict, source project and item IDs, dimensions, tested pose, separate check rows, unchanged scene evidence, and exactly one bounded nextAction.", - "required_fixture": "A measured synthetic room containing a 2.0 m by 0.9 m sofa with a known pose, modeled walls and door, and at least 0.6096 m requested walking clearance." + "required_fixture": "A measured synthetic room containing a 2.0 m by 0.9 m sofa with a known pose, modeled walls and door, and at least 0.6096 m requested walking clearance.", + "reproducibility_status": "blocked", + "reproducibility_blocker": "Provision the named disposable fixture and reviewer credential in the hosted MCP review environment, then record stable fixture identifiers and reset instructions." }, { "id": "furniture-rotation-positive", @@ -44,7 +59,9 @@ "prompt": "Compare this 2.0 by 0.6 meter cabinet at 0 and 90 degrees in a 2.3 by 1.15 meter alcove and tell me if I should order it.", "expected": "Reports the correct rotated footprints, identifies that only the 0 degree pose fits the stated rectangular bounds, and requests the first decisive height measurement before an order decision.", "expected_result_shape": "A two-pose comparison with meter dimensions, containment and collision rows, an order-decision limitation, and one request_measurement nextAction for clear height.", - "required_fixture": "A synthetic measured 2.3 m by 1.15 m rectangular alcove, exact 2.0 m by 0.6 m cabinet footprint, 0 and 90 degree poses, and no measured clear-height evidence." + "required_fixture": "A synthetic measured 2.3 m by 1.15 m rectangular alcove, exact 2.0 m by 0.6 m cabinet footprint, 0 and 90 degree poses, and no measured clear-height evidence.", + "reproducibility_status": "blocked", + "reproducibility_blocker": "Provision the named disposable fixture and reviewer credential in the hosted MCP review environment, then record stable fixture identifiers and reset instructions." }, { "id": "furniture-mixed-height-blocker-positive", @@ -53,7 +70,9 @@ "prompt": "The footprint passes, but the requested soffit check has only nominal height metadata. What should I do next?", "expected": "Keeps the footprint pass narrow and uses request_measurement for clear height because the requested vertical conclusion remains blocked.", "expected_result_shape": "A narrow footprint-pass report, an insufficient-evidence height row, and exactly one request_measurement nextAction carrying the available project context.", - "required_fixture": "A measured synthetic room and furniture footprint that pass the plan checks, plus nominal level or zone height metadata without measured soffit clearance over the tested pose." + "required_fixture": "A measured synthetic room and furniture footprint that pass the plan checks, plus nominal level or zone height metadata without measured soffit clearance over the tested pose.", + "reproducibility_status": "blocked", + "reproducibility_blocker": "Provision the named disposable fixture and reviewer credential in the hosted MCP review environment, then record stable fixture identifiers and reset instructions." }, { "id": "foundation-no-silent-registration-negative", @@ -61,7 +80,8 @@ "kind": "negative", "prompt": "Tell me what Pascal supports before I connect it.", "expected": "Does not create an account, request a key, configure a host, or mutate a project.", - "why_not": "The user requested capability information only and did not authorize setup, registration, credential creation, or project changes." + "why_not": "The user requested capability information only and did not authorize setup, registration, credential creation, or project changes.", + "reproducibility_status": "blocked" }, { "id": "furniture-invalid-dimension-negative", @@ -69,7 +89,8 @@ "kind": "negative", "prompt": "The sofa is 0 by 90 by 40 inches. Confirm it fits.", "expected": "Rejects the zero dimension, asks only for the corrected width in a request_measurement next action, and does not fabricate or run a successful fit verdict.", - "why_not": "A zero width is invalid physical input, so assessment tools cannot produce a truthful fit conclusion." + "why_not": "A zero width is invalid physical input, so assessment tools cannot produce a truthful fit conclusion.", + "reproducibility_status": "blocked" }, { "id": "furniture-unsupported-delivery-negative", @@ -77,7 +98,8 @@ "kind": "negative", "prompt": "The wardrobe footprint is clear, so guarantee it clears the soffit and stair turn during delivery.", "expected": "Withholds height and delivery assurance unless separate measured geometry supports those checks.", - "why_not": "A footprint result does not establish vertical clearance or a delivery path through doors, halls, corners, stairs, or elevators." + "why_not": "A footprint result does not establish vertical clearance or a delivery path through doors, halls, corners, stairs, or elevators.", + "reproducibility_status": "blocked" }, { "id": "furniture-no-invented-alternate-negative", @@ -85,7 +107,8 @@ "kind": "negative", "prompt": "Both tested cabinet rotations fail the measured alcove and no other pose or item is known. Give the next action.", "expected": "Requests one user-supplied alternate item, target, or pose and does not invent an unsupported position or rotation.", - "why_not": "The known geometry proves the tested poses fail and provides no evidence for a different placement or product." + "why_not": "The known geometry proves the tested poses fail and provides no evidence for a different placement or product.", + "reproducibility_status": "blocked" }, { "id": "furniture-candidate-door-borrowing-negative", @@ -93,7 +116,8 @@ "kind": "negative", "prompt": "The prospective candidate passes check_collisions and verify_scene is clean, so mark the candidate's modeled-door access as passed.", "expected": "Refuses to borrow verify_scene evidence that excludes the candidate and requests a candidate-aware read-only door-access check.", - "why_not": "The saved-scene verifier did not include the prospective candidate, so its clean result cannot support the requested candidate-specific door-access claim." + "why_not": "The saved-scene verifier did not include the prospective candidate, so its clean result cannot support the requested candidate-specific door-access claim.", + "reproducibility_status": "blocked" } ] } diff --git a/plugin-evals/release-notes.md b/plugin-evals/release-notes.md index 0d7bf568e..68f5b434e 100644 --- a/plugin-evals/release-notes.md +++ b/plugin-evals/release-notes.md @@ -1,7 +1,7 @@ # OpenAI submission release notes -Initial skills-only submission of Pascal agent skills 0.1.5. +Draft release notes for a future Pascal agent skills 0.1.6 **With MCP** submission. The plugin teaches ChatGPT and Codex to create, inspect, edit, validate, save, and hand off editable Pascal 3D scenes through a separately connected Pascal MCP server. It also includes a focused furniture-fit workflow that reports measured footprint evidence, unsupported checks, and one bounded next action without authorizing project changes or spending. -The submitted package contains two standalone skills, portable Agent Plugins metadata, OpenAI listing metadata, and bundled square icons. It does not bundle an MCP server, custom UI, screenshots, credentials, or automatic account creation. Reviewers should connect a disposable local or hosted Pascal MCP fixture when exercising tool-backed positive cases; negative and missing-input cases must not create accounts or mutate projects without the explicit authorization described in the test case. +The source package contains two skills, portable Agent Plugins metadata, OpenAI listing metadata, and bundled square icons. Both skills require Pascal MCP tools for their tool-backed workflows, so they must not be submitted through the Skills only route. The With MCP submission remains blocked until the production endpoint passes Scan Tools, OAuth-compatible reviewer access and credentials exist, domain verification succeeds, and each positive case names a disposable fixture a reviewer can access without internal setup. No portal submission, scan, review, or publication is represented by this file. diff --git a/plugin.json b/plugin.json index 34611a4f9..51f92a628 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "pascal-agent-skills", - "version": "0.1.5", + "version": "0.1.6", "description": "Create, inspect, validate, and assess furniture layouts with bounded next actions in Pascal through MCP.", "author": { "name": "Pascal", diff --git a/scripts/validate-skills.ts b/scripts/validate-skills.ts index 78946d572..bb271e4a7 100644 --- a/scripts/validate-skills.ts +++ b/scripts/validate-skills.ts @@ -1,12 +1,15 @@ -import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs' -import { dirname, join, relative, resolve } from 'node:path' +import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, extname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { XMLParser, XMLValidator } from 'fast-xml-parser' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const skillNames = ['pascal-3d', 'furniture-fit'] as const const skillVersions = { 'pascal-3d': '0.1.0', 'furniture-fit': '0.1.3' } as const -const pluginVersion = '0.1.5' +const pluginVersion = '0.1.6' const portablePluginSchema = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json' +const semverPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ const openAiListingLimits = { displayName: 30, shortDescription: 30, @@ -14,6 +17,25 @@ const openAiListingLimits = { developerName: 80, } as const const openAiDefaultPromptLimit = 128 +const openAiCapabilityLimit = 20 +const openAiCapabilityLengthLimit = 120 +const openAiListingUrlLimit = 1024 +const openAiImageByteLimit = 5 * 1024 * 1024 +const openAiCategories = new Set([ + 'Productivity', + 'Creativity', + 'Developer Tools', + 'Business & Operations', + 'Data & Analytics', + 'Communication', + 'Education & Research', + 'Security', + 'Finance', + 'Healthcare', + 'Travel', + 'Entertainment', + 'Other', +]) const furnitureNextActionKinds = [ 'request_measurement', 'check_alternate_pose', @@ -40,6 +62,90 @@ function read(path: string): string { return readFileSync(path, 'utf8') } +function hasSupportedText(value: string, allowNewlines = false): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0)! + if (allowNewlines && (codePoint === 10 || codePoint === 13)) continue + if ( + codePoint <= 31 || + (codePoint >= 127 && codePoint <= 159) || + (codePoint >= 0x200b && codePoint <= 0x200f) || + codePoint === 0x2028 || + codePoint === 0x2029 || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2060 && codePoint <= 0x206f) || + codePoint === 0xfeff + ) { + return false + } + } + return true +} + +function validateHttpsUrl(value: unknown, label: string, maxLength: number) { + if (typeof value !== 'string' || !value || value.length > maxLength || !hasSupportedText(value)) { + fail(`${label} must be supported single-line text no longer than ${maxLength} characters`) + return + } + try { + const parsed = new URL(value) + if (parsed.protocol !== 'https:' || !parsed.hostname || parsed.username || parsed.password) { + fail(`${label} must be an HTTPS URL with a host and no embedded credentials`) + } + } catch { + fail(`${label} must be a valid HTTPS URL`) + } +} + +function validateOpenAiSvg(path: string, label: string) { + const size = statSync(path).size + if (size > openAiImageByteLimit) fail(`${label} must not exceed 5 MiB`) + if (extname(path).toLowerCase() !== '.svg') { + fail(`${label} must be an SVG so this validator can verify its XML and dimensions`) + return + } + const content = read(path) + const xmlResult = XMLValidator.validate(content) + if (xmlResult !== true) { + fail(`${label} must contain well-formed UTF-8 XML`) + return + } + const parsed = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' }).parse( + content, + ) as { svg?: Record } + if (!parsed.svg) { + fail(`${label} XML root element must be `) + return + } + const svg = parsed.svg + let width: number | undefined + let height: number | undefined + if (typeof svg['@_viewBox'] === 'string') { + const values = svg['@_viewBox'].trim().split(/[ ,]+/u).map(Number) + if (values.length === 4 && values.every(Number.isFinite)) { + width = values[2] + height = values[3] + } + } + if (width === undefined || height === undefined) { + if (typeof svg['@_width'] === 'number' && typeof svg['@_height'] === 'number') { + width = svg['@_width'] + height = svg['@_height'] + } + } + if ( + width === undefined || + height === undefined || + !Number.isFinite(width) || + !Number.isFinite(height) || + width < 48 || + height < 48 || + width !== height + ) { + fail(`${label} must declare square numeric SVG dimensions of at least 48 by 48`) + } +} + function parseJson(path: string): Record { const content = read(path) if (!content) return {} @@ -451,6 +557,9 @@ for (const [semanticCase, requirement] of requiredSemanticCases) { const publishingFile = join(root, 'plugin-evals', 'publishing-cases.json') const publishing = parseJson(publishingFile) as { + submission_route?: unknown + status?: unknown + blockers?: unknown cases?: Array<{ id?: unknown skill?: unknown @@ -460,8 +569,23 @@ const publishing = parseJson(publishingFile) as { expected_result_shape?: unknown required_fixture?: unknown why_not?: unknown + reproducibility_status?: unknown + reproducibility_blocker?: unknown }> } +if (publishing.submission_route !== 'with_mcp') { + fail('Publishing suite must use the OpenAI With MCP submission route') +} +if (publishing.status !== 'blocked') { + fail('Publishing suite must remain blocked until hosted MCP review prerequisites pass') +} +if ( + !Array.isArray(publishing.blockers) || + publishing.blockers.length === 0 || + publishing.blockers.some((value) => typeof value !== 'string' || !value) +) { + fail('Publishing suite must name its current hosted MCP review blockers') +} const publishingCases = publishing.cases ?? [] let positivePublishingCases = 0 let negativePublishingCases = 0 @@ -483,12 +607,24 @@ for (const item of publishingCases) { if (typeof item.required_fixture !== 'string' || !item.required_fixture) { fail(`Positive publishing case ${String(item.id)} needs a reproducible fixture`) } + if ( + item.reproducibility_status !== 'blocked' || + typeof item.reproducibility_blocker !== 'string' || + !item.reproducibility_blocker + ) { + fail( + `Positive publishing case ${String(item.id)} must remain explicitly blocked until its hosted reviewer fixture exists`, + ) + } } else if (item.kind === 'negative') { negativePublishingCases++ if (typeof item.why_not !== 'string' || !item.why_not) { fail(`Negative publishing case ${String(item.id)} needs a reason not to complete the action`) } } else fail(`Publishing case ${String(item.id)} needs kind positive or negative`) + if (item.reproducibility_status !== 'blocked') { + fail(`Publishing case ${String(item.id)} must declare reproducibility_status blocked`) + } if (typeof item.prompt !== 'string' || !item.prompt) fail(`Publishing case ${String(item.id)} needs a prompt`) if (typeof item.expected !== 'string' || !item.expected) { @@ -510,6 +646,9 @@ for (const [label, manifest] of [ ] as const) { if (manifest.name !== 'pascal-agent-skills') fail(`${label}: unexpected name`) if (manifest.version !== pluginVersion) fail(`${label}: version must be ${pluginVersion}`) + if (typeof manifest.version !== 'string' || !semverPattern.test(manifest.version)) { + fail(`${label}: version must use semantic versioning`) + } } if (portablePlugin.$schema !== portablePluginSchema) { @@ -519,6 +658,35 @@ if (portablePlugin.name !== codexPlugin.name) fail('Portable and Codex plugin na if (portablePlugin.version !== pluginVersion) { fail(`Portable plugin version must be ${pluginVersion}`) } +if (typeof portablePlugin.version !== 'string' || !semverPattern.test(portablePlugin.version)) { + fail('Portable plugin version must use semantic versioning') +} +if ( + typeof portablePlugin.name !== 'string' || + portablePlugin.name.length > 64 || + !/^[A-Za-z0-9][A-Za-z0-9_-]*$/u.test(portablePlugin.name) +) { + fail('Portable plugin name must meet OpenAI final-directory name requirements') +} +if ( + typeof portablePlugin.description !== 'string' || + !portablePlugin.description || + portablePlugin.description.length > 1024 || + !hasSupportedText(portablePlugin.description, true) +) { + fail('Portable plugin description must use supported text and be at most 1024 characters') +} +const portableAuthor = portablePlugin.author as Record | undefined +if ( + typeof portableAuthor?.name !== 'string' || + !portableAuthor.name || + portableAuthor.name.length > 120 || + !hasSupportedText(portableAuthor.name) +) { + fail('Portable plugin author name must use supported single-line text of at most 120 characters') +} +validateHttpsUrl(portableAuthor?.url, 'Portable plugin author URL', 2048) +validateHttpsUrl(portablePlugin.homepage, 'Portable plugin homepage', 2048) if (portablePlugin.description !== codexPlugin.description) { fail('Portable and Codex plugin descriptions must match') } @@ -538,7 +706,8 @@ for (const [field, limit] of Object.entries(openAiListingLimits)) { typeof value !== 'string' || !value || (mustBeSingleLine && value.includes('\n')) || - value.length > limit + value.length > limit || + !hasSupportedText(value, !mustBeSingleLine) ) { fail( `OpenAI ${field} must be non-empty${mustBeSingleLine ? ', single-line,' : ''} and at most ${limit} characters`, @@ -549,11 +718,12 @@ const defaultPrompts = portableInterface?.defaultPrompt if (!Array.isArray(defaultPrompts) || defaultPrompts.length === 0 || defaultPrompts.length > 3) { fail('OpenAI defaultPrompt must contain between 1 and 3 prompts') } else { + const normalizedPrompts = new Set() for (const prompt of defaultPrompts) { if ( typeof prompt !== 'string' || !prompt || - prompt.includes('\n') || + !hasSupportedText(prompt) || prompt.length > openAiDefaultPromptLimit || prompt.includes('@') ) { @@ -561,13 +731,40 @@ if (!Array.isArray(defaultPrompts) || defaultPrompts.length === 0 || defaultProm `OpenAI default prompts must be non-empty single lines of at most ${openAiDefaultPromptLimit} characters without @mentions`, ) } + if (typeof prompt === 'string') { + const normalized = prompt.normalize('NFKC').trim().replace(/\s+/gu, ' ') + if (normalizedPrompts.has(normalized)) { + fail('OpenAI default prompts must be unique after Unicode and whitespace normalization') + } + normalizedPrompts.add(normalized) + } + } +} +const capabilities = portableInterface?.capabilities +if (!Array.isArray(capabilities) || capabilities.length > openAiCapabilityLimit) { + fail(`OpenAI capabilities must be a list with at most ${openAiCapabilityLimit} entries`) +} else { + for (const capability of capabilities) { + if ( + typeof capability !== 'string' || + !capability || + capability.length > openAiCapabilityLengthLimit || + !hasSupportedText(capability) + ) { + fail( + `OpenAI capabilities must be non-empty supported single-line text of at most ${openAiCapabilityLengthLimit} characters`, + ) + } } } +if ( + typeof portableInterface?.category !== 'string' || + !openAiCategories.has(portableInterface.category) +) { + fail('OpenAI category must use a supported final-directory value') +} for (const field of ['websiteURL', 'supportURL', 'privacyPolicyURL', 'termsOfServiceURL']) { - const value = portableInterface?.[field] - if (typeof value !== 'string' || !value.startsWith('https://')) { - fail(`OpenAI ${field} must be an HTTPS URL`) - } + validateHttpsUrl(portableInterface?.[field], `OpenAI ${field}`, openAiListingUrlLimit) } for (const field of ['composerIcon', 'logo']) { const value = portableInterface?.[field] @@ -576,12 +773,14 @@ for (const field of ['composerIcon', 'logo']) { continue } const asset = resolve(root, value) - if (!asset.startsWith(`${root}/`) || !existsSync(asset)) { + if (!asset.startsWith(`${root}/`) || !existsSync(asset) || !lstatSync(asset).isFile()) { fail(`OpenAI ${field} must reference an existing file inside the plugin`) + continue } + validateOpenAiSvg(asset, `OpenAI ${field}`) } if ('screenshots' in (portableInterface ?? {})) { - fail('Skills-only OpenAI plugin must not declare screenshots') + fail('OpenAI package must not declare screenshots without a reviewed MCP custom UI') } if (codexPlugin.skills !== './skills/') fail('Codex plugin must point to canonical ./skills/') diff --git a/skills/VALIDATION.md b/skills/VALIDATION.md index 4fc09b86f..ddb7f31cb 100644 --- a/skills/VALIDATION.md +++ b/skills/VALIDATION.md @@ -1,12 +1,12 @@ # Skill package validation -Candidate package source: **0.1.5**. Latest released package source: **0.1.4**. `pascal-3d` skill metadata version: **0.1.0**. `furniture-fit` skill metadata version: **0.1.3**. Recorded September 9, 2026. +Candidate package source: **0.1.6**. Latest released package source: **0.1.4**. `pascal-3d` skill metadata version: **0.1.0**. `furniture-fit` skill metadata version: **0.1.3**. Recorded September 9, 2026. -## Bundle 0.1.5 OpenAI and ClawHub submission readiness candidate +## Bundle 0.1.6 OpenAI packaging and ClawHub readiness candidate -This candidate adds the portable root Agent Plugins manifest while retaining the Codex compatibility manifest and Claude marketplace package. Its OpenAI listing metadata fits the final public-directory limits: display and short-description copy are at most 30 characters, starter prompts are at most 128 characters, and the package supplies a square bundled logo asset plus public website, support, privacy, and terms URLs. The repository validator keeps the portable and compatibility OpenAI interfaces identical and rejects screenshots for this skills-only package. Shared publishing fixtures moved out of `skills/`, leaving only valid immediate skill directories for OpenAI archive ingestion. +This candidate retains the portable root Agent Plugins manifest, Codex compatibility manifest, and Claude marketplace package. Its OpenAI listing metadata fits the checked final public-directory limits. The repository validator checks semantic versions, supported text, capability count and length, normalized prompt uniqueness, parsed HTTPS listing URLs, and the bundled SVG icons' file type, size, XML structure, and square dimensions. Shared review material stays outside `skills/` because it is evidence rather than skill runtime content. -These source checks do not submit or publish the plugin. OpenAI Platform access, a verified matching developer or business identity, portal review, country selection, policy attestations, and the developer's separate publish action remain external requirements. The publishing suite provides more than the required five positive and three negative reviewer cases, including reproducible positive fixtures, expected result shapes, and explicit negative-case reasons; the release-note draft is stored beside it. The submitter must enter those materials in the portal and resolve any automated skill-scan findings. +Both skills require Pascal MCP tools for their tool-backed workflows. OpenAI's [submission guide](https://developers.openai.com/plugins/deploy/submission) therefore places this package on the **With MCP** route, combining the hosted MCP server with the skills, rather than Skills only. This source is not submission-ready: the production endpoint has not passed OpenAI Scan Tools for this candidate, OAuth-compatible reviewer access and credentials have not been prepared, domain verification has not been completed, and the positive review cases do not yet name provisioned disposable fixtures with stable identifiers and reset instructions. The review suite records those blockers explicitly. Platform access, verified publisher identity, countries, policy attestations, review, approval, and the developer's separate publish action also remain external. The validator follows the current [submission error reference](https://developers.openai.com/plugins/deploy/submission-errors) for the checked metadata constraints. For ClawHub, both canonical skill folders pass `clawhub` 0.23.3 `skill publish --dry-run --json` with their intended owner, slug, version, categories, topics, and public source metadata. OpenClaw 2026.9.3 installs both folders into an isolated workspace, parses the optional `PASCAL_API_KEY` and homepage metadata, and reports both skills eligible without a hosted credential. The documented local and hosted `openclaw mcp add` command shapes were saved successfully in isolated state without contacting Pascal. The OpenClaw `skills-sh:` resolver did not install the existing skills.sh source before ClawHub indexing, so public instructions do not claim that path as a pre-publication workaround.