@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.
Files changed (46) hide show
  1. package/README.md +20 -1
  2. package/dist/capture/worker.mjs +38 -0
  3. package/dist/capture-store.mjs +123 -0
  4. package/dist/capture.mjs +117 -0
  5. package/dist/config.mjs +6 -1
  6. package/dist/mcp/catalog.mjs +3 -3
  7. package/dist/mcp/context.mjs +1 -1
  8. package/dist/mcp/mutations.mjs +26 -6
  9. package/dist/mcp/server.mjs +64 -17
  10. package/dist/project-documents.mjs +29 -6
  11. package/dist/scenarios.mjs +9 -3
  12. package/dist/showcase/client.bundle.js +18 -18
  13. package/dist/showcase/style.css +2 -0
  14. package/dist/skills/ads-break-ui/SKILL.md +12 -5
  15. package/dist/skills/ads-break-ui/references/captures.md +147 -0
  16. package/dist/skills/ads-break-ui/references/guarded-editing.md +68 -0
  17. package/dist/skills/ads-break-ui/references/scenarios.md +22 -0
  18. package/dist/skills/ads-designer/SKILL.md +13 -6
  19. package/dist/skills/ads-designer/references/captures.md +147 -0
  20. package/dist/skills/ads-designer/references/guarded-editing.md +68 -0
  21. package/dist/skills/ads-designer/references/scenarios.md +22 -0
  22. package/dist/skills/ads-import/SKILL.md +13 -6
  23. package/dist/skills/ads-import/references/captures.md +147 -0
  24. package/dist/skills/ads-import/references/guarded-editing.md +68 -0
  25. package/dist/skills/ads-import/references/implementation.md +3 -1
  26. package/dist/skills/ads-import/references/scenarios.md +22 -0
  27. package/dist/skills/ads-migrate/SKILL.md +12 -5
  28. package/dist/skills/ads-migrate/references/captures.md +147 -0
  29. package/dist/skills/ads-migrate/references/guarded-editing.md +68 -0
  30. package/dist/skills/ads-migrate/references/scenarios.md +22 -0
  31. package/dist/skills/ads-migrate/references/testing.md +5 -0
  32. package/dist/skills/ads-motion/SKILL.md +12 -5
  33. package/dist/skills/ads-motion/references/captures.md +147 -0
  34. package/dist/skills/ads-motion/references/guarded-editing.md +68 -0
  35. package/dist/skills/ads-motion/references/scenarios.md +22 -0
  36. package/dist/units.mjs +7 -6
  37. package/dist/verification/browser.mjs +8 -3
  38. package/dist/verification/playwright-reporter.cjs +23 -8
  39. package/dist/verification.mjs +137 -33
  40. package/docs/ADS.md +9 -0
  41. package/docs/ARCHITECTURE.md +44 -3
  42. package/docs/BEHAVIORS.md +57 -3
  43. package/docs/CHANGELOG.md +28 -0
  44. package/docs/GUIDE.md +48 -1
  45. package/docs/MCP.md +63 -2
  46. package/package.json +1 -1
@@ -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 edits = cache.edits ??= createMutationController(root);
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 {...json(result),...(!result.ok ? {isError:true} : {})};
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
- try { return await tool.call(input.data, context.mcpReq.signal); }
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)) throw new ResourceNotFoundError(uri, "Unknown ADS resource subscription.");
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
- if (!filesChanged && !buildChanged && !artifactsChanged && !assetsChanged) return false;
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
- for (const uri of targets) { if (notifications) notifications.resourceUpdated(uri); else await server.sendResourceUpdated({ uri }); }
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
- return rootNames.includes(file) || /^(?:docs|doc)\/.+\.md$/i.test(file) && !file.split("/").some(part => part.startsWith("."));
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 guide discovery for MCP resources, workflow context and editable units. */
12
- export function projectGuideFiles(root) {
13
- const files = rootNames.filter(file => existsSync(path.join(root, file)) && lstatSync(path.join(root, file)).isFile());
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
- walk("docs");
25
- walk("doc");
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
+ }
@@ -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
- if (![".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs", ".json"].some(extension => existsSync(fixtureBase + extension))) 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.");
89
- if (!metadata || Object.keys(metadata).some((key) => !["title", "kind", "covers", "exercises", "tokens", "samples"].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)
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
  }