@reqvire-org/ads 0.3.11 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- package/dist/capture/worker.mjs +38 -0
- package/dist/capture-store.mjs +123 -0
- package/dist/capture.mjs +117 -0
- package/dist/config.mjs +6 -1
- package/dist/mcp/catalog.mjs +3 -3
- package/dist/mcp/context.mjs +1 -1
- package/dist/mcp/mutations.mjs +26 -6
- package/dist/mcp/server.mjs +64 -17
- package/dist/project-documents.mjs +29 -6
- package/dist/scenarios.mjs +9 -3
- package/dist/showcase/client.bundle.js +18 -18
- package/dist/showcase/style.css +2 -0
- package/dist/skills/ads-break-ui/SKILL.md +12 -5
- package/dist/skills/ads-break-ui/references/captures.md +147 -0
- package/dist/skills/ads-break-ui/references/guarded-editing.md +68 -0
- package/dist/skills/ads-break-ui/references/scenarios.md +22 -0
- package/dist/skills/ads-designer/SKILL.md +13 -6
- package/dist/skills/ads-designer/references/captures.md +147 -0
- package/dist/skills/ads-designer/references/guarded-editing.md +68 -0
- package/dist/skills/ads-designer/references/scenarios.md +22 -0
- package/dist/skills/ads-import/SKILL.md +13 -6
- package/dist/skills/ads-import/references/captures.md +147 -0
- package/dist/skills/ads-import/references/guarded-editing.md +68 -0
- package/dist/skills/ads-import/references/implementation.md +3 -1
- package/dist/skills/ads-import/references/scenarios.md +22 -0
- package/dist/skills/ads-migrate/SKILL.md +12 -5
- package/dist/skills/ads-migrate/references/captures.md +147 -0
- package/dist/skills/ads-migrate/references/guarded-editing.md +68 -0
- package/dist/skills/ads-migrate/references/scenarios.md +22 -0
- package/dist/skills/ads-migrate/references/testing.md +5 -0
- package/dist/skills/ads-motion/SKILL.md +12 -5
- package/dist/skills/ads-motion/references/captures.md +147 -0
- package/dist/skills/ads-motion/references/guarded-editing.md +68 -0
- package/dist/skills/ads-motion/references/scenarios.md +22 -0
- package/dist/units.mjs +7 -6
- package/dist/verification/browser.mjs +8 -3
- package/dist/verification/playwright-reporter.cjs +23 -8
- package/dist/verification.mjs +137 -33
- package/docs/ADS.md +9 -0
- package/docs/ARCHITECTURE.md +44 -3
- package/docs/BEHAVIORS.md +57 -3
- package/docs/CHANGELOG.md +28 -0
- package/docs/GUIDE.md +48 -1
- package/docs/MCP.md +63 -2
- package/package.json +1 -1
package/dist/mcp/server.mjs
CHANGED
|
@@ -21,12 +21,21 @@ import { GUIDE_URI, projectContext, projectGuide, serverInstructions, workflow }
|
|
|
21
21
|
import { createMutationController, mutationLimits } from "./mutations.mjs";
|
|
22
22
|
import { prepareUnitPatch, storePreparedPatch } from "./edit-preparation.mjs";
|
|
23
23
|
import { reconcileTokenSamples } from "../token-fixtures.mjs";
|
|
24
|
+
import { createCaptureController } from "../capture.mjs";
|
|
25
|
+
import { CAPTURES_URI, captureSpec, screenshotFields } from "../capture-store.mjs";
|
|
26
|
+
import { previewSourceRevision } from "../preview-inputs.mjs";
|
|
24
27
|
|
|
25
28
|
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
26
29
|
class InvalidParamsError extends ProtocolError {
|
|
27
30
|
constructor(message) { super(INVALID_PARAMS, message); }
|
|
28
31
|
}
|
|
29
32
|
const json = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value });
|
|
33
|
+
function captureResponse(value, resourceLinks = false) {
|
|
34
|
+
const result = json(value);
|
|
35
|
+
if (!resourceLinks) return result; // Older protocol clients retain text/structured URI discovery.
|
|
36
|
+
for (const capture of value.captures ?? value.screenshots?.captures ?? []) for (const slot of ["current", "candidate"]) if (capture[slot]) result.content.push({ type: "resource_link", uri: capture[slot].resourceUri, mimeType: "image/png", name: `${slot === "current" ? "Current" : "Candidate"}: ${capture.scenario}${capture.target ? ` / ${capture.target}` : ""}` });
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
30
39
|
const empty = z.object({}).strict();
|
|
31
40
|
const query = z.object({ query: z.string().max(200).optional(), limit: z.number().int().min(1).max(50).default(20) }).strict();
|
|
32
41
|
const annotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
@@ -37,7 +46,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
37
46
|
const root = path.resolve(directory);
|
|
38
47
|
const cache = inspection ?? {};
|
|
39
48
|
const skills = cache.skills ??= createSkillStore(skillDirectory);
|
|
40
|
-
const
|
|
49
|
+
const captures = cache.captures ??= createCaptureController(root);
|
|
50
|
+
const edits = cache.edits ??= createMutationController(root, { captures });
|
|
41
51
|
const verification = cache.verification ??= createVerificationController(root);
|
|
42
52
|
let options = loadConfig(root);
|
|
43
53
|
const signature = `${root}:${JSON.stringify(options.config)}`;
|
|
@@ -62,6 +72,11 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
62
72
|
extensions: { "io.modelcontextprotocol/ui": { mimeTypes: [APP_MIME] }, ...(era === "modern" ? { [SKILLS_EXTENSION]: { directoryRead: true } } : {}) } },
|
|
63
73
|
});
|
|
64
74
|
const getRegistry = () => cache.registry ??= inspectScenarios(options);
|
|
75
|
+
function captureInventory() {
|
|
76
|
+
const current = loadConfig(root), registry = inspectScenarios(current);
|
|
77
|
+
return { ...registry, revision: previewSourceRevision(current) };
|
|
78
|
+
}
|
|
79
|
+
function captureIndex() { const inventory = captureInventory(); return captures.store.list(inventory.scenarios, inventory.revision, { prune: !inventory.diagnostics.length }); }
|
|
65
80
|
const scenarioApps = () => getRegistry().scenarios.map(item => ({ id: item.id, title: item.title, kind: item.kind, ...(item.samples ? { samples: item.samples } : {}), file: item.artifact }));
|
|
66
81
|
const tokenScenarios = () => getRegistry().scenarios.filter(item => item.kind === "tokens").map(item => ({ id: item.id, title: item.title, count: item.tokens.length }));
|
|
67
82
|
const allApps = () => [...options.config.mcp.apps, ...scenarioApps()];
|
|
@@ -95,12 +110,14 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
95
110
|
{ uri: "ads://guides/behaviors", name: "Controller and behavioral verification guide", mimeType: "text/markdown" },
|
|
96
111
|
{ uri: "ads://guides/atomic-styles", name: "Atomic styles: composition conflicts and custom-property extraction", mimeType: "text/markdown" },
|
|
97
112
|
{ uri: "ads://guides/scenarios", name: "Scenario coverage and public class-helper contracts", mimeType: "text/markdown" },
|
|
113
|
+
{ uri: "ads://guides/captures", name: "On-demand captures and candidate visual review", mimeType: "text/markdown" },
|
|
98
114
|
{ uri: `${PROJECT_URI}/status`, name: "ADS project status", mimeType: "application/json" },
|
|
99
115
|
{ uri: BUILD_URI, name: "Preview build status and diagnostics", mimeType: "application/json" },
|
|
100
116
|
{ uri: `${PROJECT_URI}/tokens`, name: "Project token values and themes", mimeType: "application/json" },
|
|
101
117
|
{ uri: `${PROJECT_URI}/catalog`, name: "Public components and patterns", mimeType: "application/json" },
|
|
102
118
|
{ uri: `${PROJECT_URI}/scenarios`, name: "Independently mountable scenarios", mimeType: "application/json" },
|
|
103
119
|
{ uri: BEHAVIOR_URI, name: "Behavior contracts, demonstrations and current verification evidence", mimeType: "application/json" },
|
|
120
|
+
{ uri: CAPTURES_URI, name: "On-demand current and candidate screenshots", mimeType: "application/json" },
|
|
104
121
|
{ uri: `${PROJECT_URI}/checks`, name: "Current ADS architecture diagnostics", mimeType: "application/json" },
|
|
105
122
|
...documents.filter(document => promoted.has(document.uri)).sort((a, b) => Number(b.uri === context.publicEntryUri) - Number(a.uri === context.publicEntryUri)).map(({ file: _file, ...document }) => document),
|
|
106
123
|
{ uri: TOKENS_APP_URI, name: "Experimental token widget", mimeType: APP_MIME },
|
|
@@ -126,6 +143,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
126
143
|
previews: previews(), skills: skills.skills.map(({ uri, frontmatter }) => ({ uri, name: frontmatter.name, description: frontmatter.description })),
|
|
127
144
|
scenarios: { count: getRegistry().scenarios.length, diagnostics: getRegistry().diagnostics, resourceUri: `${PROJECT_URI}/scenarios`, tool: "ads_show_scenario" },
|
|
128
145
|
behaviors: { resourceUri: BEHAVIOR_URI, discoveryTool: "ads_behaviors", verificationTool: "ads_verify", ...verification.status() },
|
|
146
|
+
captures: { resourceUri: CAPTURES_URI, tool: "ads_capture", clearTool: "ads_capture_clear", ...captures.status() },
|
|
129
147
|
experimental: { apps: true, nativeSkills: era === "modern", nativeSkillActivation: "host-controlled" } };
|
|
130
148
|
}
|
|
131
149
|
async function read(uri, signal) {
|
|
@@ -133,6 +151,7 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
133
151
|
if (uri === "ads://guides/behaviors") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../../docs/BEHAVIORS.md", import.meta.url), "utf8") }] };
|
|
134
152
|
if (uri === "ads://guides/atomic-styles") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../skills/ads-designer/references/atomic-styles.md", import.meta.url), "utf8") }] };
|
|
135
153
|
if (uri === "ads://guides/scenarios") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../skills/ads-designer/references/scenarios.md", import.meta.url), "utf8") }] };
|
|
154
|
+
if (uri === "ads://guides/captures") return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(new URL("../skills/ads-designer/references/captures.md", import.meta.url), "utf8") }] };
|
|
136
155
|
if (skills.files.has(uri)) return { contents: [skills.read(uri)] };
|
|
137
156
|
let value;
|
|
138
157
|
if (uri === `${PROJECT_URI}/status`) value = status();
|
|
@@ -142,11 +161,16 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
142
161
|
else if (uri === `${PROJECT_URI}/scenarios`) value = getRegistry();
|
|
143
162
|
else if (uri === `${PROJECT_URI}/checks`) value = checkProject(root);
|
|
144
163
|
else if (uri === BEHAVIOR_URI) value = behaviorStatus(options);
|
|
164
|
+
else if (uri === CAPTURES_URI) value = captureIndex();
|
|
145
165
|
else if (uri === PREVIEW_MANIFEST_URI && options.config.mcp.previewManifest) {
|
|
146
166
|
value = readPreviewManifest(previewOptions());
|
|
147
167
|
if (!value) throw new Error("Preview resources are not built yet. Invoke ads_show_scenario to start the managed compiler.");
|
|
148
168
|
}
|
|
149
169
|
if (value !== undefined) return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(value) }] };
|
|
170
|
+
if (uri.startsWith("ads://captures/")) {
|
|
171
|
+
try { const inventory = captureInventory(); return { contents: [captures.store.readResource(uri, inventory.scenarios, inventory.revision, { allowUnknownTargets: !!inventory.diagnostics.length })] }; }
|
|
172
|
+
catch (error) { if (error.code === "ADS_CAPTURE_NOT_FOUND") throw new ResourceNotFoundError(uri, error.message); throw error; }
|
|
173
|
+
}
|
|
150
174
|
if (uri === TOKENS_APP_URI) return { contents: [{ uri, mimeType: APP_MIME, text: tokensApp(tokenWidgetData(inspectTokens(root))), _meta: appMetadata({ nested: true }) }] };
|
|
151
175
|
if (uri === SCENARIO_APP_URI) return { contents: [{ uri, mimeType: APP_MIME, text: scenarioApp(), _meta: appMetadata({ nested: true }) }] };
|
|
152
176
|
if (uri === PREVIEW_APP_URI && options.config.mcp.previewManifest) return { contents: [{ uri, mimeType: APP_MIME, text: previewApp(), _meta: appMetadata() }] };
|
|
@@ -174,8 +198,9 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
174
198
|
return build;
|
|
175
199
|
}
|
|
176
200
|
|
|
177
|
-
async function applyEdits(args,signal) {
|
|
178
|
-
const result = await edits.apply(args,signal);
|
|
201
|
+
async function applyEdits(args,signal,progress) {
|
|
202
|
+
const result = await edits.apply(args,signal,progress);
|
|
203
|
+
if (result.screenshots) await refresh();
|
|
179
204
|
if (result.applied) {
|
|
180
205
|
try {
|
|
181
206
|
if (result.changes.some(change => change.path === "ds.config.json") || result.unit === "configuration" || result.units?.includes("configuration")) await previewWatch?.reconfigure?.(loadConfig(root).config.mcp,true);
|
|
@@ -183,11 +208,15 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
183
208
|
await refresh(true);
|
|
184
209
|
} catch (error) {result.warnings=[...(result.warnings ?? []),{rule:"mutation-notification",message:`Changes were applied, but preview refresh failed: ${error.message.slice(0,2000)}. Recheck ads_status and reopen the scenario.`}];}
|
|
185
210
|
}
|
|
186
|
-
return {...
|
|
211
|
+
return {...captureResponse(result, era === "modern"),...(!result.ok ? {isError:true} : {})};
|
|
187
212
|
}
|
|
188
213
|
function tools() {
|
|
189
214
|
return [
|
|
190
215
|
{ name: "ads_status", description: "Start here for UI/ADS work: components, patterns, tokens/themes/branding, layout, behavior, motion and showcase review. Returns the exact project root/public API, workflow, project commands, editing mode and build diagnostics. When mutations.enabled, use guarded ads_apply_patch for ADS edits; otherwise use local editing tools.", schema: empty, call: () => json(status()) },
|
|
216
|
+
{ name: "ads_capture", title: "Capture a design-system scenario", description: "Capture one discovered scenario or its named captureTargets element inside the real MCP App iframe, excluding showcase navigation. Uses project-local Playwright/browser and an isolated host; installs nothing. Returns PNG resource URIs and source/build/settings metadata, never image bytes in text. Successful standalone capture replaces current and clears a prior candidate for that target. No screenshots are created until requested. Inspect ads://project/captures for stale/current evidence. Appearance is not behavior verification.", schema: captureSpec, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, call: async (args, signal, progress) => {
|
|
217
|
+
const result = await captures.run({ captures: [args] }, signal, progress); await refresh(); return captureResponse(result, era === "modern");
|
|
218
|
+
} },
|
|
219
|
+
{ name: "ads_capture_clear", description: "Delete generated screenshot slots for one scenario/target. candidateOnly discards a proposal while retaining current. Source is unchanged. No TTL/history is kept; each target has at most current and candidate.", schema: z.object({ scenario: z.string().min(1).max(256), target: z.string().regex(/^[a-zA-Z0-9][\w.-]{0,79}$/).optional(), candidateOnly: z.boolean().default(false) }).strict(), annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, call: async args => { const result = captures.store.clear(args); await refresh(); return json(result); } },
|
|
191
220
|
{ name: "ads_catalog", description: "Before creating/changing UI or behavior, find existing public components/patterns/controllers and hooks with a small query/limit. Returns import alias and exact source/guide URIs so you can reuse or edit the owner. Read ads_status first for workspace/workflow. Metadata only; does not render UI.", schema: query.extend({ kind: z.enum(["components", "patterns", "controllers"]).optional() }), call: ({ query: search = "", limit, kind }) => {
|
|
192
221
|
const matches = getCatalog().filter((item) => (!kind || item.kind === kind) && item.name.toLowerCase().includes(search.toLowerCase()));
|
|
193
222
|
const units = edits.units();
|
|
@@ -255,14 +284,14 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
255
284
|
demonstrations: demonstrations.slice(0, 10), demonstrationCount: demonstrations.length, checkCount: Object.keys(item.checks).length };
|
|
256
285
|
}), diagnostics: inventory.diagnostics.slice(0, 50), resourceUri: BEHAVIOR_URI });
|
|
257
286
|
} },
|
|
258
|
-
{ name: "ads_verify", description: "Run one owner's registered development behavior checks using its configured runner/profile. Executes trusted project commands with bounded output, cancellation and process-tree cleanup; installs nothing. Browser profiles use an isolated managed showcase/MCP App. React StrictMode requires a project development harness, not a production widget. Missing/skipped cases, stale inputs and process exit alone cannot produce passing evidence. Discover exact unit/check IDs with ads_behaviors first.", schema: z.object({ unit: z.string().min(1).max(256), checks: z.array(z.string().min(1).max(100)).min(1).max(100).optional() }).strict(), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, call: async (args, signal) => {
|
|
259
|
-
const result = await verification.run(args, signal);
|
|
287
|
+
{ name: "ads_verify", description: "Run one owner's registered development behavior checks using its configured runner/profile. Executes trusted project commands with bounded output, cancellation and process-tree cleanup; installs nothing. Progress and per-check timings are available through ads_status; a profile timeout bounds the whole run. Browser profiles use an isolated managed showcase/MCP App. React StrictMode requires a project development harness, not a production widget. Missing/skipped cases, stale inputs and process exit alone cannot produce passing evidence. Discover exact unit/check IDs with ads_behaviors first.", schema: z.object({ unit: z.string().min(1).max(256), checks: z.array(z.string().min(1).max(100)).min(1).max(100).optional() }).strict(), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, call: async (args, signal, progress) => {
|
|
288
|
+
const result = await verification.run(args, signal, progress);
|
|
260
289
|
await refresh();
|
|
261
290
|
return { ...json(result), ...(!result.ok ? { isError: true } : {}) };
|
|
262
291
|
} },
|
|
263
|
-
{ name: "ads_units", description: "Discover editable ADS units and their files: components/patterns with colocated previews; shared controllers; composition; tokens; foundation shared files; documentation; assets/brand, assets/fonts and asset groups; configuration with ds.config.json, package/build/lint configuration and registered project guard/compiler sources. List summaries with query, or pass an exact unit ID for its manifest. Manifests include companionFiles with permitted shared-entry or owner-only style-boundary bookkeeping; preserve peer permissions and unrelated configuration. Imported peers stay separate. New owners follow the existing colocation convention.", schema: query.extend({ unit: z.string().min(1).max(256).optional() }), call: ({ unit, query: search = "", limit }) => {
|
|
292
|
+
{ name: "ads_units", description: "Discover editable ADS units and their files: components/patterns with colocated previews; shared controllers; composition; tokens; foundation shared files; documentation including shared credits/license notices; assets/brand, assets/fonts and asset groups; configuration with ds.config.json, package/build/lint configuration and registered project guard/compiler sources. List summaries with query, or pass an exact unit ID for its manifest. Manifests include filePolicy for supported new documentation paths and companionFiles with permitted shared-entry or owner-only style-boundary bookkeeping; preserve peer permissions and unrelated configuration. Imported peers stay separate. New owners follow the existing colocation convention.", schema: query.extend({ unit: z.string().min(1).max(256).optional() }), call: ({ unit, query: search = "", limit }) => {
|
|
264
293
|
const matches = edits.units().filter(item => unit ? item.id === unit : item.id.toLowerCase().includes(search.toLowerCase()));
|
|
265
|
-
return json({ total: matches.length, units: matches.slice(0, limit).map(item => ({ id: item.id, kind: item.kind, fileCount: item.files.length, ...(unit ? { files: item.files.slice(0, 200), filesTruncated: item.files.length > 200, ...(item.companionFiles ? { companionFiles: item.companionFiles } : {}) } : {}) })) });
|
|
294
|
+
return json({ total: matches.length, units: matches.slice(0, limit).map(item => ({ id: item.id, kind: item.kind, fileCount: item.files.length, ...(unit ? { files: item.files.slice(0, 200), filesTruncated: item.files.length > 200, ...(item.companionFiles ? { companionFiles: item.companionFiles } : {}), ...(item.filePolicy ? { filePolicy: item.filePolicy } : {}) } : {}) })) });
|
|
266
295
|
} },
|
|
267
296
|
{ name: "ads_read_file", description: "Read a bounded range of ADS or registered configuration/extension source. Returns unit, full-file digest, startLine and nextOffset; default/max is 12000 UTF-16 characters. Binary assets return byte count/digest and no encoded content. Paths come from ads_units or discovery. Missing new paths return digest null. Read only relevant context before preparing a Git patch.", schema: z.object({ path: z.string().min(1).max(500), offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(mutationLimits.readChars).default(mutationLimits.readChars) }).strict(), call: ({ path: file, ...range }) => json(edits.read(file, range)) },
|
|
268
297
|
...(mutations ? [{ name: "ads_prepare_patch", description: "Prepare a Git patch without a separate project mirror. Create copies only one owner plus its declared companions into a local draft; edit that directory with local file tools. Diff checks baseline/source freshness and owner paths, then writes a patchFile under .ds/patches. Discard removes the draft. Live source is never changed; ads_apply_patch still validates all gates. Remote clients can continue sending inline Git diffs instead.", schema:z.object({unit:z.string().min(1).max(256),action:z.enum(["create","diff","discard"]).default("create"),draft:z.string().max(100).optional()}).strict(), call:args=>json(prepareUnitPatch(options,args)) },
|
|
@@ -270,11 +299,11 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
270
299
|
const {patch,...result}=reconcileTokenSamples(options,args);
|
|
271
300
|
return json({...result,removed:result.removed.slice(0,50),unresolved:result.unresolved.slice(0,50),unresolvedCount:result.unresolved.length,...storePreparedPatch(options,patch)});
|
|
272
301
|
}},
|
|
273
|
-
{ name: "ads_apply_patch", description: "Apply one Git diff to one ADS unit from ads_units. Pass exactly one of patch (inline) or patchFile (a regular .patch/.diff under .ds/patches/, for large/binary diffs without model-context bytes). Text uses --- a/path, +++ b/path and exact @@ hunks; /dev/null creates/deletes. Git binary patches require git diff --binary --full-index and installed Git. A unit owns its implementation, previews and fixtures; imported peers are separate. Foundation, documentation, assets and configuration are editable too. Configuration changes may register/edit project guards and builders; the proposed schema, mandatory core/Oxlint and candidate project checks must pass. Only selected-unit public re-exports may change; preserve unrelated statements. A real selected-unit re-export may remove a redundant export {}; placeholder. A tokens patch may include its companionFiles foundation entry only to add/remove canonical imports required by token files created/deleted in this patch, preserving other imports/comments and canonical order. A runtime owner patch may also include its ds.config.json companion solely for that owner's inlineStyleFiles/styleWrites entries alongside its source. Unused permissions fail validation. Set dryRun true to validate without writing. For genuinely coupled owner changes use ads_apply_transaction with one patch per owner. Fix rejected patches and inspect current previews after acceptance.",
|
|
274
|
-
schema: z.object({ unit: z.string().min(1).max(256), dryRun: z.boolean(), patch: z.string().min(1).max(mutationLimits.patchBytes).optional(), patchFile: z.string().min(1).max(500).optional() }).strict().refine(args => (args.patch !== undefined) !== (args.patchFile !== undefined), "Provide exactly one of patch or patchFile."),
|
|
302
|
+
{ name: "ads_apply_patch", description: "Apply one Git diff to one ADS unit from ads_units. Pass exactly one of patch (inline) or patchFile (a regular .patch/.diff under .ds/patches/, for large/binary diffs without model-context bytes). Text uses --- a/path, +++ b/path and exact @@ hunks; /dev/null creates/deletes. Git binary patches require git diff --binary --full-index and installed Git. A unit owns its implementation, previews and fixtures; imported peers are separate. Foundation, documentation, assets and configuration are editable too. Configuration changes may register/edit project guards and builders; the proposed schema, mandatory core/Oxlint and candidate project checks must pass. Only selected-unit public re-exports may change; preserve unrelated statements. A real selected-unit re-export may remove a redundant export {}; placeholder. A tokens patch may include its companionFiles foundation entry only to add/remove canonical imports required by token files created/deleted in this patch, preserving other imports/comments and canonical order. A runtime owner patch may also include its ds.config.json companion solely for that owner's inlineStyleFiles/styleWrites entries alongside its source. Unused permissions fail validation. Set dryRun true to validate without writing. Optional screenshot:true plus captures:[{scenario,target?,theme?,viewport?}] captures current/candidate only after gates pass. For genuinely coupled owner changes use ads_apply_transaction with one patch per owner. Fix rejected patches and inspect current previews after acceptance.",
|
|
303
|
+
schema: z.object({ unit: z.string().min(1).max(256), dryRun: z.boolean(), patch: z.string().min(1).max(mutationLimits.patchBytes).optional(), patchFile: z.string().min(1).max(500).optional(), ...screenshotFields }).strict().refine(args => (args.patch !== undefined) !== (args.patchFile !== undefined), "Provide exactly one of patch or patchFile."),
|
|
275
304
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, _meta: { ui: { visibility: ["model"] } },
|
|
276
305
|
call: applyEdits,
|
|
277
|
-
}, {name:"ads_apply_transaction",description:"Apply genuinely coupled ADS changes as 2–8 distinct owner Git patches. Each patch must obey its unit and narrow companion rules. Patches run in order; later patches use the earlier candidate's shared-entry content. Core guards, registered Oxlint/project checks and affected behavior checks validate the complete final candidate before any live source is written. Maximum 32 distinct files/16 MiB combined. Dry runs, failures and cancellation publish nothing; publication rolls back on write failure. Prefer ads_apply_patch for independent edits.",schema:z.object({patches:z.array(ownerPatch).min(2).max(mutationLimits.units),dryRun:z.boolean()}).strict(),annotations:{readOnlyHint:false,destructiveHint:true,idempotentHint:false,openWorldHint:false},_meta:{ui:{visibility:["model"]}},call:applyEdits}] : []),
|
|
306
|
+
}, {name:"ads_apply_transaction",description:"Apply genuinely coupled ADS changes as 2–8 distinct owner Git patches. Each patch must obey its unit and narrow companion rules. Patches run in order; later patches use the earlier candidate's shared-entry content. Core guards, registered Oxlint/project checks and affected behavior checks validate the complete final candidate before any live source is written. Maximum 32 distinct files/16 MiB combined. Dry runs, failures and cancellation publish nothing; publication rolls back on write failure. Optional screenshot:true with dryRun:true and explicit captures selects current/candidate images. Prefer ads_apply_patch for independent edits.",schema:z.object({patches:z.array(ownerPatch).min(2).max(mutationLimits.units),dryRun:z.boolean(),...screenshotFields}).strict(),annotations:{readOnlyHint:false,destructiveHint:true,idempotentHint:false,openWorldHint:false},_meta:{ui:{visibility:["model"]}},call:applyEdits}] : []),
|
|
278
307
|
...options.config.mcp.apps.map((app) => ({ name: `ads_show_${app.id.replaceAll("-", "_")}`, title: app.title, description: `${app.description ?? app.title}. Renders the project's built ${app.kind} examples through MCP Apps; text fallback provides the showcase URL.`, schema: empty, _meta: { ui: { resourceUri: appUri(app.id) } }, call: async (_args, signal) => {
|
|
279
308
|
// Validate readiness without embedding executable HTML in model results.
|
|
280
309
|
const build = await previewBuild(app, signal);
|
|
@@ -295,7 +324,12 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
295
324
|
const input = tool.schema.safeParse(args ?? {});
|
|
296
325
|
if (!input.success) throw new InvalidParamsError(input.error.message);
|
|
297
326
|
previewWatch?.start();
|
|
298
|
-
|
|
327
|
+
let progress = 0;
|
|
328
|
+
const token = context.mcpReq._meta?.progressToken;
|
|
329
|
+
const notify = token === undefined ? undefined : value => {
|
|
330
|
+
void context.mcpReq.notify({ method: "notifications/progress", params: { progressToken: token, progress: ++progress, message: value.message ?? `${value.profile}: ${value.completed}/${value.total} completed; ${value.active.map(item => `${item.unit}#${item.check}`).join(", ") || "no active check reported"}; ${value.elapsedMs} ms elapsed` } }).catch(() => {});
|
|
331
|
+
};
|
|
332
|
+
try { return await tool.call(input.data, context.mcpReq.signal, notify); }
|
|
299
333
|
catch (error) { return { isError: true, content: [{ type: "text", text: error.message }], ...(error.build ? { structuredContent: { message: error.message, build: error.build } } : {}) }; }
|
|
300
334
|
});
|
|
301
335
|
server.setRequestHandler("resources/list", ({ params = {} }) => {
|
|
@@ -304,10 +338,13 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
304
338
|
const listing = page(resources(), cursor, "resources");
|
|
305
339
|
return { resources: listing.items, ...(listing.nextCursor ? { nextCursor: listing.nextCursor } : {}) };
|
|
306
340
|
});
|
|
307
|
-
server.setRequestHandler("resources/templates/list", () => ({ resourceTemplates: [] }));
|
|
341
|
+
server.setRequestHandler("resources/templates/list", () => ({ resourceTemplates: [{ uriTemplate: "ads://captures/{key}/{slot}", name: "Current/candidate screenshot", description: "Exact keys and available slots come from ads://project/captures. Slots are replaced in place, not revision history.", mimeType: "image/png" }] }));
|
|
308
342
|
server.setRequestHandler("resources/read", ({ params: { uri } }, context) => read(uri, context.mcpReq.signal));
|
|
309
343
|
server.setRequestHandler("resources/subscribe", ({ params: { uri } }) => {
|
|
310
|
-
if (!resources().some((entry) => entry.uri === uri))
|
|
344
|
+
if (!resources().some((entry) => entry.uri === uri)) {
|
|
345
|
+
if (!uri.startsWith("ads://captures/")) throw new ResourceNotFoundError(uri, "Unknown ADS resource subscription.");
|
|
346
|
+
try { const inventory = captureInventory(); captures.store.readResource(uri, inventory.scenarios, inventory.revision, { allowUnknownTargets: !!inventory.diagnostics.length }); } catch { throw new ResourceNotFoundError(uri, "Unknown or absent screenshot slot."); }
|
|
347
|
+
}
|
|
311
348
|
subscribed.add(uri); return {};
|
|
312
349
|
});
|
|
313
350
|
server.setRequestHandler("resources/unsubscribe", ({ params: { uri } }) => { subscribed.delete(uri); return {}; });
|
|
@@ -343,6 +380,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
343
380
|
let previousArtifacts = artifactFingerprint();
|
|
344
381
|
let previousAssets = assetFingerprint();
|
|
345
382
|
let previousBuild = JSON.stringify(builds());
|
|
383
|
+
let previousCaptures = captures.store.fingerprint();
|
|
384
|
+
let previousCaptureUris = captures.store.resourceUris();
|
|
346
385
|
async function refresh(force = false) {
|
|
347
386
|
// Polling covers replaced files and new directories consistently across OSes.
|
|
348
387
|
cache.documents = undefined;
|
|
@@ -354,7 +393,8 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
354
393
|
const buildChanged = build !== previousBuild;
|
|
355
394
|
const artifactsChanged = artifacts !== previousArtifacts;
|
|
356
395
|
const assetsChanged = assets !== previousAssets;
|
|
357
|
-
|
|
396
|
+
const capturesChanged = captures.store.fingerprint() !== previousCaptures;
|
|
397
|
+
if (!filesChanged && !buildChanged && !artifactsChanged && !assetsChanged && !capturesChanged) return false;
|
|
358
398
|
if (filesChanged) {
|
|
359
399
|
const updated = loadConfig(root);
|
|
360
400
|
await previewWatch?.reconfigure?.(updated.config.mcp);
|
|
@@ -372,14 +412,20 @@ export function createAdsMcp(directory, { era = "legacy", skillDirectory, pollIn
|
|
|
372
412
|
previousBuild = JSON.stringify(builds());
|
|
373
413
|
const targets = filesChanged ? era === "modern" ? resources().map((entry) => entry.uri).filter((uri) => !skills.files.has(uri)) : [...subscribed]
|
|
374
414
|
: [BUILD_URI, BEHAVIOR_URI, `${PROJECT_URI}/status`, ...(options.config.mcp.previewManifest ? [PREVIEW_MANIFEST_URI] : []), ...(artifactsChanged ? allApps().map(app => appUri(app.id)) : [])].filter((uri) => era === "modern" || subscribed.has(uri));
|
|
375
|
-
|
|
415
|
+
if (capturesChanged || filesChanged) {
|
|
416
|
+
const current = (captures.store.fingerprint() ? captureIndex().captures : []).flatMap(record => [record.current?.resourceUri, record.candidate?.resourceUri].filter(Boolean));
|
|
417
|
+
targets.push(...[CAPTURES_URI, ...previousCaptureUris, ...current].filter(uri => era === "modern" || subscribed.has(uri)));
|
|
418
|
+
previousCaptureUris = current;
|
|
419
|
+
previousCaptures = captures.store.fingerprint();
|
|
420
|
+
}
|
|
421
|
+
for (const uri of new Set(targets)) { if (notifications) notifications.resourceUpdated(uri); else await server.sendResourceUpdated({ uri }); }
|
|
376
422
|
return true;
|
|
377
423
|
}
|
|
378
424
|
const timer = pollInterval > 0 ? setInterval(() => { refresh().catch((error) => process.stderr.write(`ads mcp: ${error.message}\n`)); }, pollInterval) : null;
|
|
379
425
|
timer?.unref();
|
|
380
426
|
server.oninitialized = () => previewWatch?.start();
|
|
381
427
|
server.onclose = () => { if (timer) clearInterval(timer); };
|
|
382
|
-
return { server, refresh, async close() { if (timer) clearInterval(timer); if (!inspection) await verification.close(); await server.close(); } };
|
|
428
|
+
return { server, refresh, async close() { if (timer) clearInterval(timer); if (!inspection) { await verification.close(); await captures.close(); } await server.close(); } };
|
|
383
429
|
}
|
|
384
430
|
|
|
385
431
|
export async function startAdsMcp(directory = process.cwd(), { stdio = true, showcase = true, port = 0, mutations = false } = {}) {
|
|
@@ -405,6 +451,7 @@ export async function startAdsMcp(directory = process.cwd(), { stdio = true, sho
|
|
|
405
451
|
Promise.resolve().then(() => host?.close()),
|
|
406
452
|
previewWatch.stop(),
|
|
407
453
|
Promise.resolve().then(() => inspection.verification?.close()),
|
|
454
|
+
Promise.resolve().then(() => inspection.captures?.close()),
|
|
408
455
|
Promise.resolve().then(() => handle?.close()),
|
|
409
456
|
]);
|
|
410
457
|
for (const event of ["end", "close", "error"]) process.stdin.removeListener(event, disconnect);
|
|
@@ -2,15 +2,31 @@ import { existsSync, lstatSync, readdirSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
const rootNames = ["README.md", "ADS.md", "DS.md", "ARCHITECTURE.md", "Architecture.md", "GUIDE.md", "MCP.md", "CHANGELOG.md"];
|
|
5
|
+
const noticeStem = /^(?:CREDITS|LICEN[CS]ES?|NOTICES?|COPYING|COPYRIGHT)(?:[-_][A-Za-z0-9][A-Za-z0-9._-]*)?$/i;
|
|
6
|
+
const textExtension = /\.(?:md|txt)$/i;
|
|
7
|
+
|
|
8
|
+
export const documentationFilePolicy = "Project guides; root CREDITS, LICENSE/LICENCE, NOTICE, COPYING and COPYRIGHT notices (optional -/_ qualifiers); licenses/ collections, also under docs/ or doc/. Notices use .md, .txt, or conventional extensionless names and valid UTF-8. The same document paths may live at the ADS root. Component-local notices remain with their owner; code, configuration, hidden and generated paths are excluded.";
|
|
9
|
+
|
|
10
|
+
function noticeName(name) {
|
|
11
|
+
const extension = path.posix.extname(name);
|
|
12
|
+
return (!extension || textExtension.test(name)) && noticeStem.test(extension ? name.slice(0,-extension.length) : name);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isProjectNotice(file) {
|
|
16
|
+
if (!file.includes("/")) return noticeName(file);
|
|
17
|
+
return /^(?:(?:docs|doc)\/)?licenses\/.+$/i.test(file) && (textExtension.test(file) || noticeName(path.posix.basename(file)));
|
|
18
|
+
}
|
|
5
19
|
|
|
6
20
|
/** Recognize documentation ownership even before a new guide exists. */
|
|
7
21
|
export function isProjectDocument(file) {
|
|
8
|
-
|
|
22
|
+
if (file.includes("\\") || file.includes("\0") || file.split("/").some(part => !part || part.startsWith("."))) return false;
|
|
23
|
+
return rootNames.includes(file) || isProjectNotice(file) || /^(?:docs|doc)\/.+\.md$/i.test(file);
|
|
9
24
|
}
|
|
10
25
|
|
|
11
|
-
/** Shared
|
|
12
|
-
export function
|
|
13
|
-
|
|
26
|
+
/** Shared document discovery for MCP resources and editable units. */
|
|
27
|
+
export function projectDocumentFiles(root) {
|
|
28
|
+
if (!existsSync(root) || !lstatSync(root).isDirectory()) return [];
|
|
29
|
+
const files = [];
|
|
14
30
|
function walk(relative) {
|
|
15
31
|
const directory = path.join(root, relative);
|
|
16
32
|
if (!existsSync(directory) || !lstatSync(directory).isDirectory()) return;
|
|
@@ -21,7 +37,14 @@ export function projectGuideFiles(root) {
|
|
|
21
37
|
else if (entry.isFile() && isProjectDocument(file)) files.push(file);
|
|
22
38
|
}
|
|
23
39
|
}
|
|
24
|
-
|
|
25
|
-
|
|
40
|
+
for (const entry of readdirSync(root,{withFileTypes:true})) {
|
|
41
|
+
if (entry.isFile() && isProjectDocument(entry.name)) files.push(entry.name);
|
|
42
|
+
else if (entry.isDirectory() && /^(?:docs|doc|licenses)$/i.test(entry.name)) walk(entry.name);
|
|
43
|
+
}
|
|
26
44
|
return files.sort();
|
|
27
45
|
}
|
|
46
|
+
|
|
47
|
+
/** Provenance stays discoverable without loading notice inventories as workflow guides. */
|
|
48
|
+
export function projectGuideFiles(root) {
|
|
49
|
+
return projectDocumentFiles(root).filter(file => !isProjectNotice(file));
|
|
50
|
+
}
|
package/dist/scenarios.mjs
CHANGED
|
@@ -85,8 +85,9 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
const fixtureBase = path.basename(file).startsWith("Scenario.") ? path.join(path.dirname(file), "fixture") : file.replace(/\.scenario\.[^.]+$/, ".fixture");
|
|
88
|
-
|
|
89
|
-
if (!
|
|
88
|
+
const fixture = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs", ".json"].map(extension => fixtureBase + extension).find(existsSync);
|
|
89
|
+
if (!fixture) report(file, "Keep this scenario's inputs beside it: fixture.ts (directory entry) or <Owner>.fixture.ts (flat entry). An empty fixture is valid for a component without inputs.");
|
|
90
|
+
if (!metadata || Object.keys(metadata).some((key) => !["title", "kind", "covers", "exercises", "tokens", "samples", "captureTargets"].includes(key)) || typeof metadata.title !== "string" || !metadata.title.trim() || !Array.isArray(metadata.covers) || !metadata.covers.length || metadata.covers.some((name) => typeof name !== "string" || !known.get(name)?.renderable)
|
|
90
91
|
|| Object.hasOwn(metadata, "tokens") && (!Array.isArray(metadata.tokens) || metadata.tokens.some(name => typeof name !== "string" || !name.startsWith("--")))
|
|
91
92
|
|| kind === "tokens" && (Object.hasOwn(metadata, "tokens") || !validTokenSamples(metadata.samples) || !metadata.covers.some(name => known.get(name)?.kind === "patterns"))
|
|
92
93
|
|| Object.hasOwn(metadata, "samples") && !validTokenSamples(metadata.samples)) {
|
|
@@ -95,6 +96,11 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
95
96
|
// A bad behavioral entry must fail validation without erasing independent
|
|
96
97
|
// render evidence and manufacturing missing-coverage errors for real UI.
|
|
97
98
|
const exercises = [];
|
|
99
|
+
let captureTargets;
|
|
100
|
+
if (Object.hasOwn(metadata, "captureTargets")) {
|
|
101
|
+
if (!metadata.captureTargets || typeof metadata.captureTargets !== "object" || Array.isArray(metadata.captureTargets) || Object.keys(metadata.captureTargets).length > 20 || Object.entries(metadata.captureTargets).some(([name, selector]) => !/^[a-zA-Z0-9][\w.-]{0,79}$/.test(name) || typeof selector !== "string" || !selector.trim() || selector.length > 500)) report(file, "captureTargets must map up to 20 named targets to nonempty CSS selectors (max 500 characters), scoped to this scenario's App iframe. Each selected target must resolve to exactly one visible element at capture time.");
|
|
102
|
+
else captureTargets = metadata.captureTargets;
|
|
103
|
+
}
|
|
98
104
|
if (Object.hasOwn(metadata, "exercises")) {
|
|
99
105
|
if (!Array.isArray(metadata.exercises)) report(file, "Scenario exercises must be a static array naming public behavioral APIs.");
|
|
100
106
|
else for (const [index, name] of metadata.exercises.entries()) {
|
|
@@ -111,7 +117,7 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
111
117
|
for (const name of tokens) if (tokenInventory.tokens.find(token => token.name === name)?.owner !== owner) report(file, `Token ${name} needs a preview beside its defining owner. Shared tokens belong under the configured token directory; local tokens stay with their component/pattern.`);
|
|
112
118
|
}
|
|
113
119
|
const stylesheets = kind === "tokens" ? scoped.definitions.filter(entry => entry.unit === scoped.inventory.owner(relative)).map(entry => entry.file) : [];
|
|
114
|
-
scenarios.push({ id, kind, title: metadata.title, covers: [...new Set(metadata.covers)], ...(Object.hasOwn(metadata, "exercises") ? { exercises: [...new Set(exercises)] } : {}), tokens, ...(metadata.samples ? { samples: metadata.samples } : {}), ...(stylesheets.length ? { stylesheets } : {}), file: path.relative(root, file).replaceAll("\\", "/"), artifact: path.relative(root, scenarioArtifact(root, id)).replaceAll("\\", "/") });
|
|
120
|
+
scenarios.push({ id, kind, title: metadata.title, ...(fixture ? { fixture: path.relative(root, fixture).replaceAll("\\", "/") } : {}), covers: [...new Set(metadata.covers)], ...(captureTargets ? { captureTargets } : {}), ...(Object.hasOwn(metadata, "exercises") ? { exercises: [...new Set(exercises)] } : {}), tokens, ...(metadata.samples ? { samples: metadata.samples } : {}), ...(stylesheets.length ? { stylesheets } : {}), file: path.relative(root, file).replaceAll("\\", "/"), artifact: path.relative(root, scenarioArtifact(root, id)).replaceAll("\\", "/") });
|
|
115
121
|
}
|
|
116
122
|
return { scenarios: scenarios.sort((a, b) => a.id.localeCompare(b.id)), diagnostics };
|
|
117
123
|
}
|