@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
@@ -19,13 +19,55 @@ const slash = file => file.replaceAll("\\", "/");
19
19
  const resultFile = root => path.join(root, ".ds/behavior-results.json");
20
20
 
21
21
  /** Follow real project imports; package/configuration changes also invalidate evidence. */
22
- function inputContext(options) { return { inventory: inspectUnits(options), scenarios: inspectScenarios(options).scenarios }; }
22
+ function inputContext(options) {
23
+ const sources = new Map(), digests = new Map(), packages = new Map(), resolve = importResolver(options), resolutions = new Map();
24
+ return { inventory: inspectUnits(options), scenarios: inspectScenarios(options).scenarios,
25
+ seeds: [...configurationFiles(options)].map(file => path.join(options.root, file)).concat(sourceFiles(path.join(options.dsRoot, options.config.tokenDir)), path.join(options.dsRoot, options.config.stylesEntry)),
26
+ digest(file) { if (!digests.has(file)) digests.set(file, hash(readFileSync(file))); return digests.get(file); },
27
+ ast(file) { if (!sources.has(file)) sources.set(file, ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true)); return sources.get(file); },
28
+ resolve(specifier, file) { const key = `${file}\0${specifier}`; if (!resolutions.has(key)) resolutions.set(key, resolve(specifier, file)); return resolutions.get(key); },
29
+ hasSideEffects(file, record) {
30
+ let directory = path.dirname(file);
31
+ while (isInside(options.root, directory)) {
32
+ const manifest = path.join(directory, "package.json");
33
+ if (existsSync(manifest)) {
34
+ record(manifest);
35
+ if (!packages.has(manifest)) { try { packages.set(manifest, JSON.parse(readFileSync(manifest, "utf8")).sideEffects); } catch { packages.set(manifest, true); } }
36
+ const policy = packages.get(manifest), relative = slash(path.relative(directory, file));
37
+ if (policy === false) return false;
38
+ // Only the simple package sideEffects glob subset is narrowed. Unknown
39
+ // syntax and undeclared policies preserve the entire dependency graph.
40
+ if (!Array.isArray(policy) || policy.some(pattern => typeof pattern !== "string" || !/^[\w./*?-]+$/.test(pattern))) return true;
41
+ return policy.some(pattern => {
42
+ pattern = pattern.replace(/^\.\//, ""); if (!pattern.includes("/")) pattern = `**/${pattern}`;
43
+ const expression = pattern.match(/\*\*\/|\*\*|\*|\?|[^*?]+/g)?.map(part => part === "**/" ? "(?:.*/)?" : part === "**" ? ".*" : part === "*" ? "[^/]*" : part === "?" ? "[^/]" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("");
44
+ return new RegExp(`^${expression}$`).test(relative);
45
+ });
46
+ }
47
+ directory = path.dirname(directory);
48
+ }
49
+ return true;
50
+ },
51
+ };
52
+ }
23
53
 
24
54
  export function behaviorInputs(options, contract, check, context = inputContext(options)) {
25
- const files = new Set(), resolve = importResolver(options);
26
- function visit(file) {
55
+ const files = new Set(), demands = new Map(), effectsVisited = new Set();
56
+ const resolve = context.resolve;
57
+ function effects(file) {
58
+ if (effectsVisited.has(file) || !isInside(options.root, file) || file.includes(`${path.sep}node_modules${path.sep}`) || !existsSync(file)) return;
59
+ effectsVisited.add(file);
60
+ if (context.hasSideEffects(file, visit)) { visit(file); return; }
61
+ if (!/\.[cm]?[jt]sx?$/.test(file) || !lstatSync(file).isFile()) return;
62
+ if (lstatSync(file).isSymbolicLink() || realpathSync(file) !== file) throw new Error(`Behavior inputs cannot resolve through symlinks: ${file}`);
63
+ for (const statement of context.ast(file).statements) if ((ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) && statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier)) for (const target of resolve(statement.moduleSpecifier.text, file)) effects(target);
64
+ }
65
+ function visit(file, names = null) {
27
66
  file = path.resolve(file);
28
- if (files.has(file) || !isInside(options.root, file) || file.includes(`${path.sep}node_modules${path.sep}`) || !existsSync(file)) return;
67
+ if (!isInside(options.root, file) || file.includes(`${path.sep}node_modules${path.sep}`) || !existsSync(file)) return;
68
+ const prior = demands.get(file);
69
+ if (demands.has(file) && (prior === null || names && names.every(name => prior.has(name)))) return;
70
+ demands.set(file, names === null ? null : new Set([...(prior ?? []), ...names]));
29
71
  if (lstatSync(file).isSymbolicLink() || realpathSync(file) !== file) throw new Error(`Behavior inputs cannot resolve through symlinks: ${file}`);
30
72
  if (!lstatSync(file).isFile()) return;
31
73
  files.add(file);
@@ -39,12 +81,31 @@ export function behaviorInputs(options, contract, check, context = inputContext(
39
81
  css.walk(node => { if (node.type === "decl" || node.type === "atrule") valueParser(node.type === "decl" ? node.value : node.params).walk(value => { if (value.type === "function" && value.value.toLowerCase() === "url") localAsset(value.nodes.filter(part => part.type !== "space").map(part => part.value).join("")); }); });
40
82
  }
41
83
  if (!/\.[cm]?[jt]sx?$/.test(file)) return;
42
- const ast = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
84
+ const ast = context.ast(file);
85
+ const barrel = names && ast.statements.every(statement => ts.isEmptyStatement(statement) || ts.isImportDeclaration(statement) && !statement.importClause || ts.isExportDeclaration(statement) && (!statement.exportClause || ts.isNamedExports(statement.exportClause)) && (!statement.exportClause || !statement.exportClause.elements.length || statement.moduleSpecifier));
86
+ if (barrel) {
87
+ for (const statement of ast.statements) {
88
+ if (!statement.moduleSpecifier || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue;
89
+ for (const target of resolve(statement.moduleSpecifier.text, file)) {
90
+ if (ts.isImportDeclaration(statement) || !statement.exportClause) visit(target);
91
+ else {
92
+ const imported = statement.exportClause.elements.filter(entry => names.includes(entry.name.text)).map(entry => (entry.propertyName ?? entry.name).text);
93
+ if (imported.length) visit(target, imported);
94
+ effects(target);
95
+ }
96
+ }
97
+ }
98
+ return;
99
+ }
43
100
  function walk(node) {
44
101
  let specifier;
45
102
  if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) specifier = node.moduleSpecifier.text;
46
103
  if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require") && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0])) specifier = node.arguments[0].text;
47
- if (specifier) for (const target of resolve(specifier, file)) visit(target);
104
+ if (specifier) {
105
+ const clause = ts.isImportDeclaration(node) && node.importClause;
106
+ const names = clause && !clause.namedBindings ? clause.name ? ["default"] : null : clause?.namedBindings && ts.isNamedImports(clause.namedBindings) ? [...(clause.name ? ["default"] : []), ...clause.namedBindings.elements.map(entry => (entry.propertyName ?? entry.name).text)] : null;
107
+ for (const target of resolve(specifier, file)) visit(target, names);
108
+ }
48
109
  if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "URL" && node.arguments?.[0] && ts.isStringLiteralLike(node.arguments[0])) localAsset(node.arguments[0].text);
49
110
  if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && (!node.arguments[0] || !ts.isStringLiteralLike(node.arguments[0]))) {
50
111
  // Computed module selection cannot provide a precise dependency closure.
@@ -56,13 +117,11 @@ export function behaviorInputs(options, contract, check, context = inputContext(
56
117
  walk(ast);
57
118
  }
58
119
  for (const file of context.inventory.units.find(unit => unit.id === contract.unit)?.files ?? []) visit(path.join(options.root, file));
59
- for (const file of configurationFiles(options)) visit(path.join(options.root, file));
60
- for (const file of sourceFiles(path.join(options.dsRoot, options.config.tokenDir))) visit(file);
61
- visit(path.join(options.dsRoot, options.config.stylesEntry));
120
+ for (const file of context.seeds) visit(file);
62
121
  const scenario = check.scenario && context.scenarios.find(item => item.id === check.scenario);
63
122
  if (scenario) visit(path.join(options.root, scenario.file));
64
- const rows = [...files].sort().map(file => [slash(path.relative(options.root, file)), hash(readFileSync(file))]);
65
- for (const file of ["package-lock.json", "bun.lock", "bun.lockb", "pnpm-lock.yaml", "yarn.lock", "node_modules/.package-lock.json"]) rows.push([file, existsSync(path.join(options.root, file)) ? hash(readFileSync(path.join(options.root, file))) : null]);
123
+ const rows = [...files].sort().map(file => [slash(path.relative(options.root, file)), context.digest(file)]);
124
+ for (const file of ["package-lock.json", "bun.lock", "bun.lockb", "pnpm-lock.yaml", "yarn.lock", "node_modules/.package-lock.json"]) rows.push([file, existsSync(path.join(options.root, file)) ? context.digest(path.join(options.root, file)) : null]);
66
125
  rows.push(["profile", JSON.stringify(options.config.mcp.behaviorProfiles[check.profile])], ["node", process.version]);
67
126
  for (const file of ["./verification.mjs", "./verification/browser.mjs", "./verification/playwright-reporter.cjs"]) rows.push([`tool:${file}`, hash(readFileSync(new URL(file, import.meta.url)))]);
68
127
  return { revision: hash(JSON.stringify(rows)), files: rows.map(([file]) => file) };
@@ -104,9 +163,11 @@ export function affectedBehaviorUnits(options, changedFiles) {
104
163
  return contracts.filter(contract => Object.values(contract.checks).some(check => behaviorInputs(options, contract, check, context).files.some(file => changed.has(file)))).map(contract => contract.unit);
105
164
  }
106
165
 
107
- function execute(profile, args, root, env, signal) {
166
+ export function executeDevelopmentCommand(profile, args, root, env, signal) {
108
167
  return new Promise((resolve, reject) => {
109
- if (signal.aborted) { reject(new Error("Behavior verification cancelled.")); return; }
168
+ const label = profile.label ?? "Behavior verification";
169
+ if (signal.aborted) { reject(new Error(`${label} cancelled.`)); return; }
170
+ const startedAt = Date.now(), timeoutMs = profile.timeoutMs ?? 60000;
110
171
  let output = "", failure, child, guard, done = false;
111
172
  const kill = () => {
112
173
  if (!child?.pid) return;
@@ -121,28 +182,47 @@ function execute(profile, args, root, env, signal) {
121
182
  guard.once("error", error => { failure = error; kill(); });
122
183
  guard.once("exit", () => { if (!done) { failure ??= new Error("Verification ownership guard exited unexpectedly."); kill(); } });
123
184
  }
124
- const abort = () => { failure = new Error("Behavior verification cancelled."); kill(); };
185
+ const abort = () => { failure = Object.assign(new Error(`${label} cancelled.`), { reason: "cancelled" }); kill(); };
125
186
  signal.addEventListener("abort", abort, { once: true });
126
- const timeout = setTimeout(() => { failure = new Error("Behavior verification timed out. Inspect the registered runner and its browser/server cleanup."); kill(); }, profile.timeoutMs ?? 60000);
187
+ const timeout = setTimeout(() => { failure = Object.assign(new Error(`${label} timed out after ${timeoutMs} ms for the complete profile run, not each check.`), { reason: "timeout", timeoutMs }); kill(); }, timeoutMs);
127
188
  for (const stream of [child.stdout, child.stderr]) stream.on("data", chunk => { output = (output + chunk.toString()).slice(-12000); });
128
189
  child.once("error", error => { failure = error; });
129
190
  child.once("exit", kill);
130
191
  child.once("close", code => {
131
192
  done = true; kill(); guard?.stdin.end("disarm\n"); clearTimeout(timeout); signal.removeEventListener("abort", abort);
132
- if (failure) reject(Object.assign(failure, { output }));
133
- else resolve({ code, output });
193
+ const elapsedMs = Date.now() - startedAt;
194
+ if (failure) reject(Object.assign(failure, { output, elapsedMs }));
195
+ else resolve({ code, output, elapsedMs });
134
196
  });
135
197
  });
136
198
  }
137
199
 
200
+ function readReport(file, runId) {
201
+ if (!existsSync(file) || realpathSync(file) !== file || !lstatSync(file).isFile() || lstatSync(file).size > 1024 * 1024) throw new Error("Runner produced no valid bounded verification report. A successful exit alone is not passing evidence.");
202
+ const value = JSON.parse(readFileSync(file, "utf8"));
203
+ if (value.schemaVersion !== 1 || value.runId !== runId || !Array.isArray(value.cases) || value.cases.length > 500 || value.active !== undefined && (!Array.isArray(value.active) || value.active.length > 100)) throw new Error("Runner report has the wrong run ID/schema or too many cases.");
204
+ return value;
205
+ }
206
+
207
+ function runProgress(group, report, startedAt, profile, timeoutMs) {
208
+ const selected = group.map(check => ({ unit: check.unit, check: check.id, id: check.case, file: slash(path.posix.join(path.posix.dirname(slash(check.contract.file)), check.file)) }));
209
+ const cases = selected.map(check => {
210
+ const matches = report?.cases.filter(item => item?.id === check.id && item.file === check.file) ?? [];
211
+ const running = report?.active?.find(item => item?.id === check.id && item.file === check.file);
212
+ return { unit: check.unit, check: check.check, state: running ? "running" : matches.length ? "completed" : Array.isArray(report?.active) ? "not_started" : "not_reported", ...(running ? { elapsedMs: Math.max(0, Date.now() - Date.parse(running.startedAt)) || 0, workerIndex: running.workerIndex } : {}), ...(matches.length ? { reportedStatus: matches.at(-1).status, durationMs: matches.reduce((sum, item) => sum + (Number.isFinite(item.durationMs) ? Math.max(0, item.durationMs) : 0), 0) } : {}) };
213
+ });
214
+ return { profile, elapsedMs: Date.now() - startedAt, timeoutMs, total: cases.length, completed: cases.filter(item => item.state === "completed").length, active: cases.filter(item => item.state === "running"), pending: cases.filter(item => ["not_started", "not_reported"].includes(item.state)).length, cases, ...(Number.isSafeInteger(report?.workers) ? { workers: report.workers } : {}) };
215
+ }
216
+
138
217
  /** Registered development commands are trusted project code, never model-supplied commands. */
139
- export function createVerificationController(root) {
218
+ export function createVerificationController(root, { reportRoot = root, onProgress } = {}) {
140
219
  root = path.resolve(root);
141
- let active;
220
+ reportRoot = path.resolve(reportRoot);
221
+ let active, progress;
142
222
  return {
143
- status: () => ({ running: !!active }),
223
+ status: () => ({ running: !!active, ...(active && progress ? { progress: { ...progress, elapsedMs: Date.now() - progress.startedAt } } : {}) }),
144
224
  async close() { active?.abort(); await active?.completion; },
145
- async run({ unit, units, checks, persist = true } = {}, signal) {
225
+ async run({ unit, units, checks, persist = true } = {}, signal, notifyProgress) {
146
226
  if (active) return { ok: false, diagnostics: [{ rule: "behavior-busy", message: "A behavior verification is already running." }], results: [] };
147
227
  active = new AbortController();
148
228
  const controller = active;
@@ -162,7 +242,7 @@ export function createVerificationController(root) {
162
242
  for (const profileName of new Set(selection.map(check => check.profile))) {
163
243
  const profile = options.config.mcp.behaviorProfiles[profileName];
164
244
  const group = selection.filter(check => check.profile === profileName);
165
- const directory = path.join(root, ".ds/verification", randomUUID());
245
+ const directory = path.join(reportRoot, ".ds/verification", randomUUID());
166
246
  mkdirSync(directory, { recursive: true });
167
247
  if (realpathSync(directory) !== directory) throw new Error("Verification output cannot resolve through a symlink.");
168
248
  const runId = randomUUID(), report = path.join(directory, "report.json"), request = path.join(directory, "request.json");
@@ -179,31 +259,55 @@ export function createVerificationController(root) {
179
259
  const requestContext = { schemaVersion: 1, runId, projectRoot: root, framework: options.config.framework, profile: profileName, mode: profile.mode ?? "unit", checks: group.map(({ contract: _contract, ...check }) => check), scenarios };
180
260
  writeFileSync(request, JSON.stringify(requestContext));
181
261
  const args = [...profile.args];
182
- if (profile.runner === "playwright") args.push(...new Set(group.map(check => slash(path.relative(root, path.resolve(root, path.dirname(check.contract.file), check.file))))), `--reporter=${fileURLToPath(new URL("./verification/playwright-reporter.cjs", import.meta.url))}`, `--output=${path.join(directory, "artifacts")}`);
262
+ if (profile.runner === "playwright") {
263
+ const tags = [...new Set(group.map(check => check.case))].map(id => id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
264
+ args.push(...new Set(group.map(check => slash(path.relative(root, path.resolve(root, path.dirname(check.contract.file), check.file))))), `--grep=(?:^|\\s)@ads:(?:${tags.join("|")})(?=\\s|$)`, `--reporter=${fileURLToPath(new URL("./verification/playwright-reporter.cjs", import.meta.url))}`, `--output=${path.join(directory, "artifacts")}`);
265
+ }
183
266
  let run, reportValue, runnerError;
267
+ const startedAt = Date.now();
268
+ const update = value => {
269
+ progress = { ...runProgress(group, value, startedAt, profileName, profile.timeoutMs ?? 60000), startedAt };
270
+ onProgress?.(progress); notifyProgress?.(progress);
271
+ };
272
+ update();
273
+ let lastSnapshot = "", lastUpdate = Date.now();
274
+ const monitor = setInterval(() => {
275
+ let value;
276
+ try { value = readReport(report, runId); } catch { /* An absent/invalid intermediate snapshot cannot provide evidence. */ }
277
+ const snapshot = JSON.stringify(value);
278
+ if (snapshot !== lastSnapshot || Date.now() - lastUpdate >= 5000) { lastSnapshot = snapshot; lastUpdate = Date.now(); update(value); }
279
+ }, 250);
184
280
  try {
185
- run = await execute(profile, args, root, { ADS_VERIFY_REQUEST: request, ADS_VERIFY_REPORT: report, ADS_VERIFY_MODE: requestContext.mode }, signal);
186
- if (!existsSync(report) || realpathSync(report) !== report || lstatSync(report).size > 1024 * 1024) throw new Error("Runner produced no valid bounded verification report. A successful exit alone is not passing evidence.");
187
- reportValue = JSON.parse(readFileSync(report, "utf8"));
188
- if (reportValue.schemaVersion !== 1 || reportValue.runId !== runId || !Array.isArray(reportValue.cases) || reportValue.cases.length > 500) throw new Error("Runner report has the wrong run ID/schema or too many cases.");
189
- } catch (error) { runnerError = error; reportValue = undefined; diagnostics.push({ rule: "behavior-runner", message: error.message, output: error.output?.slice(-4000), profile: profileName }); }
281
+ run = await executeDevelopmentCommand(profile, args, root, { ADS_VERIFY_REQUEST: request, ADS_VERIFY_REPORT: report, ADS_VERIFY_MODE: requestContext.mode }, signal);
282
+ } catch (error) { runnerError = error; }
283
+ finally { clearInterval(monitor); }
284
+ try { reportValue = readReport(report, runId); }
285
+ catch (error) { runnerError ??= error; }
286
+ update(reportValue);
287
+ if (runnerError) {
288
+ const detail = `${progress.completed}/${progress.total} checks completed; ${progress.pending} without a completed/active result. Active: ${progress.active.map(item => `${item.unit}#${item.check} (${Math.round(item.elapsedMs)} ms)`).join(", ") || "none reported"}.`;
289
+ diagnostics.push({ rule: "behavior-runner", message: `${runnerError.message} ${detail}`, reason: runnerError.reason, elapsedMs: runnerError.elapsedMs ?? Date.now() - startedAt, progress, output: runnerError.output?.slice(-4000), profile: profileName });
290
+ }
190
291
  const suiteErrors = Array.isArray(reportValue?.errors) ? reportValue.errors.slice(0, 5).map(error => String(error).slice(0, 1000)) : [];
191
- const runnerFailure = run && (run.code !== 0 || suiteErrors.length) ? `Registered runner ${profileName} ${run.code !== 0 ? `exited with code ${run.code ?? "unknown"}` : "reported a suite error"}. ${suiteErrors.join("; ") || reportValue?.cases.filter(item => item?.status === "failed").slice(0, 3).map(item => `${item.id}: ${item.message || "failed without a case diagnostic"}`).join("; ") || "Inspect the runner output and verification report."}`.slice(0, 3000) : undefined;
292
+ const runnerFailure = run && (run.code !== 0 || suiteErrors.length || reportValue?.complete === false) ? `Registered runner ${profileName} ${run.code !== 0 ? `exited with code ${run.code ?? "unknown"}` : reportValue?.complete === false ? "left an incomplete report" : "reported a suite error"}. ${suiteErrors.join("; ") || reportValue?.cases.filter(item => item?.status === "failed").slice(0, 3).map(item => `${item.id}: ${item.message || "failed without a case diagnostic"}`).join("; ") || "Inspect the runner output and verification report."}`.slice(0, 3000) : undefined;
192
293
  if (runnerFailure) diagnostics.push({ rule: "behavior-runner", message: runnerFailure, output: run.output?.slice(-4000), profile: profileName });
193
294
  const afterOptions = loadConfig(root), afterContext = inputContext(afterOptions);
194
295
  for (const check of group) {
195
296
  const file = slash(path.relative(root, path.resolve(root, path.dirname(check.contract.file), check.file)));
196
297
  const matches = reportValue?.cases.filter(item => item && item.id === check.case && item.file === file) ?? [];
197
298
  const reportedStatus = matches.length && matches.every(item => item.status === "passed") ? "passed" : matches.length && matches.every(item => item.status === "skipped") ? "skipped" : "failed";
198
- let status = !run || runnerFailure ? "failed" : reportedStatus;
299
+ const interrupted = !!runnerError && !!reportValue;
300
+ let status = interrupted ? matches.some(item => item.status === "failed") ? "failed" : "incomplete" : !run || runnerError || runnerFailure ? "failed" : reportedStatus;
199
301
  if (runnerError?.code === "ENOENT") status = "unavailable";
200
302
  const current = behaviorInputs(afterOptions, check.contract, check, afterContext).revision === check.inputRevision;
201
303
  if (!current) status = "stale";
202
304
  if (signal.aborted) status = "cancelled";
203
305
  results.push({ unit: check.unit, check: check.id, file: check.file, case: check.case, profile: profileName, status, current, revision: check.inputRevision, runId, completedAt: new Date().toISOString(),
204
306
  previewRevision: profile.mode === "browser" ? host?.previewRevision() ?? readPreviewManifest(options)?.revision ?? null : null,
205
- ...(runnerFailure ? { reportedStatus, runnerExitCode: run.code } : {}),
206
- message: runnerError?.message ?? (matches.filter(item => item.status !== "passed").map(item => String(item.message || `Case reported ${item.status} without a diagnostic.`).slice(0, 2000)).join("\n") || (!matches.length ? "The selected case did not execute/report. Missing and skipped cases do not pass." : runnerFailure ? `This case reported ${reportedStatus}, but the complete run did not pass. ${runnerFailure}` : "")), report: slash(path.relative(root, report)) });
307
+ ...(matches.length ? { reportedStatus, durationMs: matches.reduce((sum, item) => sum + (Number.isFinite(item.durationMs) ? Math.max(0, item.durationMs) : 0), 0) } : {}),
308
+ ...(runnerFailure ? { runnerExitCode: run.code } : {}),
309
+ execution: progress.cases.find(item => item.unit === check.unit && item.check === check.id)?.state,
310
+ message: [matches.filter(item => item.status !== "passed").map(item => String(item.message || `Case reported ${item.status} without a diagnostic.`).slice(0, 2000)).join("\n"), runnerError ? `${runnerError.message} This check ${matches.length ? `reported ${reportedStatus}` : `was ${progress.cases.find(item => item.unit === check.unit && item.check === check.id)?.state ?? "not reported"}`}; the complete run did not pass.` : !matches.length ? "The selected case did not execute/report. Missing and skipped cases do not pass." : runnerFailure ? `This case reported ${reportedStatus}, but the complete run did not pass. ${runnerFailure}` : ""].filter(Boolean).join("\n"), report: slash(path.relative(reportRoot, report)) });
207
311
  }
208
312
  if (signal.aborted) break;
209
313
  }
@@ -217,7 +321,7 @@ export function createVerificationController(root) {
217
321
  }
218
322
  return { ok: diagnostics.length === 0 && results.length > 0 && results.every(result => result.status === "passed" && result.current), diagnostics, results };
219
323
  } catch (error) { return { ok: false, diagnostics: [{ rule: "behavior-verification", message: error.message }], results }; }
220
- finally { try { await host?.close(); } finally { if (active === controller) active = undefined; finish(); } }
324
+ finally { try { await host?.close(); } finally { if (active === controller) { active = undefined; progress = undefined; } finish(); } }
221
325
  },
222
326
  };
223
327
  }
package/docs/ADS.md CHANGED
@@ -107,6 +107,11 @@ framework/dependency gates and MCP execution contract.
107
107
  Flat owners may use `Button.scenario.tsx` and `Button.fixture.ts` instead.
108
108
  `Scenario.tsx` exports a default independently mountable view and literal metadata
109
109
  `export const scenario = { title: "Button states", covers: ["Button"] };`.
110
+ Optional static `captureTargets` maps up to 20 names to CSS selectors scoped to
111
+ that scenario's App. These support explicitly requested element screenshots; they
112
+ do not grant coverage or styling exceptions. PNGs and current/candidate metadata
113
+ are generated development evidence, outside source ownership and product bundles.
114
+ See the portable [capture workflow](../skills/ads-designer/references/captures.md).
110
115
  Token entries under the configured token directory declare
111
116
  `export const scenario = { title: fixture.title, covers: ["TokenSamples"], samples: fixture.samples };`.
112
117
  `TokenSamples` is any project-owned public review pattern, not a required name.
@@ -333,6 +338,10 @@ in each item's local README. Record source repository/file links, revision,
333
338
  authors, concrete influences, and integration differences. Preserve applicable
334
339
  license notices for copied/adapted source and assets. The project guide records
335
340
  local implementation choices and links to the local item guides.
341
+ Shared `CREDITS.md`, conventional root notices and text files in `licenses/` use
342
+ the `documentation` mutation unit; its `filePolicy` describes supported new paths.
343
+ They can be added with README links in one guarded patch, without duplicating a
344
+ shared license into every component. Colocated notices retain their owning unit.
336
345
 
337
346
  ## Build contract
338
347
 
@@ -336,6 +336,39 @@ The MCP server serves compiled bytes without evaluating project TSX. Compiler
336
336
  adapters normalize each entry to mount/unmount, retaining native React or Preact
337
337
  semantics; choosing a framework does not install one into the tooling host.
338
338
 
339
+ ## On-demand visual evidence
340
+
341
+ The capture unit is a registered, independently mountable scenario or one of its
342
+ project-declared `captureTargets`. Components, patterns, compositions and token
343
+ previews use the same real MCP App, fixtures and adapter as interactive review.
344
+ Shared controllers/hooks need a visible demonstration. Capture introduces no
345
+ tool-owned component templates, styling or fixture inference.
346
+
347
+ ADS owns isolated preview hosting, App readiness and an owned Playwright process;
348
+ the receiving project supplies Playwright and its installed browser. The capture
349
+ host is the existing minimal MCP client/bridge with navigation hidden, giving the
350
+ sandboxed App the requested viewport. Project runtime, fonts and styles remain
351
+ inside the App resource graph. The image excludes showcase chrome.
352
+
353
+ Captures exist only when requested. `ads_capture` replaces current and removes any
354
+ candidate for that scenario/target. Guarded patch/transaction dry runs can opt into
355
+ `screenshot:true` with explicit selected captures: after the normal gates, ADS
356
+ renders current and candidate under matching settings without changing live source.
357
+ A new scenario has no fabricated baseline. Final freshness/conflict checks precede
358
+ image publication. Matching accepted source promotes candidate to current.
359
+
360
+ There are at most two slots per target: current and candidate. Revisions/settings
361
+ are metadata, not new resource identities. There is no previous slot, accumulating
362
+ history or TTL. Stale source is identified explicitly; deleted targets are collected.
363
+ PNG/pixel/store limits and explicit clearing bound generated storage. These images
364
+ are visual evidence for one state, not passing behavioral guarantees.
365
+
366
+ One MCP capture index and a PNG resource template expose stable image slots.
367
+ Subscriptions receive resource updates when images change or disappear. The pair
368
+ is published as one atomic generated record under `.ds/captures/` and stays outside
369
+ source ownership and product bundles. Detailed usage lives in each portable skill's
370
+ [capture reference](../skills/ads-designer/references/captures.md).
371
+
339
372
  ## Build checks keep the architecture in place
340
373
 
341
374
  The project build runs ADS checks before producing application or showcase
@@ -369,11 +402,19 @@ context, or the shared token foundation. ADS resolves file ownership, reconstruc
369
402
  candidate files locally and validates a temporary project copy with architecture
370
403
  checks, registered lint rules and project validation scripts. Affected registered
371
404
  behavior checks, including consumers of a changed shared controller, also run in
372
- the isolated candidate project. Passing checks describe
373
- their registered assertions, not every natural-language claim. This keeps edits
405
+ the isolated candidate project. The project owns its test code and runner policy;
406
+ ADS owns affected-check selection, candidate hosting, progress, evidence freshness
407
+ and process cleanup. One profile timeout bounds its entire runner process. Parallel
408
+ workers can share that candidate host with independent App state. Partial outcomes
409
+ remain diagnostic when execution is interrupted; they cannot approve a mutation.
410
+ Passing checks describe their registered assertions, not every natural-language claim. This keeps edits
374
411
  focused without requiring entire file bodies in model context. Peer units and
375
412
  unrelated owners remain outside the selected unit's edit boundary. Shared foundation,
376
- branding and guides have their own units. Project configuration, registered guards
413
+ branding and guides have their own units. Shared credits and text license notices
414
+ belong to the documentation unit, including conventional root notices and shared
415
+ `licenses/` collections. Colocated notices remain with their component or asset
416
+ owner. Documentation paths and UTF-8 validation do not grant code/configuration
417
+ ownership. Project configuration, registered guards
377
418
  and compiler integration form an explicit configuration unit; their proposed
378
419
  settings pass the same mandatory validation. Binary assets can travel as Git patch
379
420
  artifacts without entering model context, and compiler configuration changes
package/docs/BEHAVIORS.md CHANGED
@@ -139,8 +139,25 @@ configuration must discover colocated spec files. Configure browser installation
139
139
  and any required test setup in that project. The ADS tooling host gains no
140
140
  framework or browser dependency from a profile.
141
141
 
142
- The Playwright adapter appends selected file paths, its reporter and a generated
143
- artifact directory.
142
+ The Playwright adapter appends selected file paths and case-tag filtering, its
143
+ reporter and a generated artifact directory. Unselected cases in the same spec
144
+ do not run. It preserves the project's worker configuration.
145
+
146
+ `timeoutMs` bounds the complete runner process for one profile, including runner
147
+ startup, hooks, retries and all selected cases. It is not a per-test timeout.
148
+ Playwright's own test/hook timeouts remain separate project settings. Set the
149
+ profile budget from measured suite durations with headroom; increasing it does
150
+ not repair a stalled assertion or cleanup.
151
+
152
+ Independent specs can use multiple Playwright workers against the same managed
153
+ candidate host. Each check needs its own page/context and state; avoid fixed ports,
154
+ shared mutable fixtures and test-order dependencies. Start with a small worker
155
+ count and measure on the target machine. Files normally run in parallel; tests
156
+ within one file normally remain sequential. ADS does not force parallelism or
157
+ change verification policy. See [Playwright parallelism](https://playwright.dev/docs/test-parallel).
158
+ Prefer readiness/assertion polling over arbitrary sleeps. Timer-only guarantees
159
+ may use [Playwright's clock](https://playwright.dev/docs/clock); retain real time
160
+ when browser motion, pointer gestures or layout timing are what the check verifies.
144
161
 
145
162
  Runner failures remain blocking even when some selected cases reported passed.
146
163
  Those results retain `reportedStatus` and `runnerExitCode`, and explain the suite
@@ -150,6 +167,24 @@ scenario ID and failed browser requests in startup failures; App resource errors
150
167
  identify the resource URI. Failed assertions and startup errors are not retried
151
168
  automatically. Fix or investigate the failure before explicitly verifying again.
152
169
 
170
+ The reporter atomically publishes partial snapshots at test start/end. While a
171
+ run is active, `ads_status.behaviors.progress` exposes the profile, runner budget,
172
+ elapsed time, completed/active/pending counts, worker count when available and
173
+ case identities/timings. Candidate mutation verification exposes the same data at
174
+ `ads_status.mutations.progress`. Clients requesting MCP progress receive updates
175
+ and a heartbeat during the runner phase; client-side request timeouts still apply.
176
+ `durationMs` records completed case execution, including attempts, separately from
177
+ the suite's wall time. Parallel case durations can sum to more than wall time.
178
+
179
+ After a deadline, valid completed outcomes remain visible through `reportedStatus`
180
+ and durations, alongside active and unstarted case identities. A passed case from
181
+ an interrupted run has blocking status `incomplete`; real assertion failures retain
182
+ their own messages. No incomplete run can accept a mutation or certify a guarantee.
183
+ Reports and artifacts stay under the live project's `.ds/verification/` even after
184
+ the candidate copy is removed. This is diagnostic output, not passing live-source
185
+ evidence. Old/custom runners without incremental progress use `not_reported` for
186
+ cases whose execution state cannot be established.
187
+
153
188
  Use native test tags for stable case identity:
154
189
 
155
190
  ```ts
@@ -210,7 +245,13 @@ selected `checks` and optional `scenarios` URLs. Write a bounded JSON report:
210
245
  }
211
246
  ```
212
247
 
213
- Case statuses are `passed`, `failed` or `skipped`, with optional `message`.
248
+ Case statuses are `passed`, `failed` or `skipped`, with optional `message` and
249
+ `durationMs`. Protocol runners may atomically publish intermediate reports with
250
+ `complete:false` and an `active` list of `{id,file,startedAt,workerIndex?}`, then
251
+ publish `complete:true` at completion. Older final reports without `complete`
252
+ remain supported after normal process completion. An explicitly incomplete report
253
+ cannot pass even with exit code zero. Partial reports retain the same schema/run
254
+ identity and size limits as final reports.
214
255
  `file` is project-relative. Nonzero process exit, missing cases, skipped cases,
215
256
  wrong run IDs and unavailable reports do not pass. Test commands are trusted
216
257
  project code; an adapter cannot prove the quality or honesty of their assertions.
@@ -245,10 +286,23 @@ Computed module selection conservatively includes all ADS owners. Environment,
245
286
  network and other unregistered external inputs remain the runner's responsibility;
246
287
  an evidence record is not a proof about arbitrary external state.
247
288
 
289
+ For named imports through simple re-export barrels, ADS follows selected bindings
290
+ and retains dependencies with declared side effects. This narrowing requires a
291
+ package `sideEffects:false` or a supported simple `sideEffects` glob array; preserve
292
+ CSS/global effects in that policy. Unknown policies/patterns, wildcard exports and
293
+ computed imports remain conservative. Barrel source changes still invalidate
294
+ dependents. ADS caches source parsing, resolution and digests within an inspection
295
+ only; later runs read current source again.
296
+
248
297
  Guarded mutations execute affected registered behavior contracts after core,
249
298
  Oxlint and configured project checks, before writing source. Imported shared
250
299
  controller changes also select dependent contracts conservatively. Rejected and
251
300
  dry-run patches publish no source changes. Verification output stays under `.ds/`.
301
+ To check a prepared draft, request its diff and pass the returned `patchFile` to
302
+ `ads_apply_patch` with `dryRun:true`. This checks the isolated candidate, including
303
+ affected behavior contracts and its configured browser preview. Candidate evidence
304
+ is not persisted as passing evidence for live source. `ads_verify` selects checks
305
+ for current source and has no draft argument.
252
306
 
253
307
  ## Packaging and performance
254
308
 
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.13
4
+
5
+ - Add on-demand scenario/named-element PNG captures and screenshot-enabled guarded
6
+ dry runs. Expose stable current/candidate resources, freshness and notifications;
7
+ standalone captures clear earlier candidates, matching applies promote them, and
8
+ bounded storage keeps no previous/history/TTL. Document architecture and portable
9
+ skill usage without introducing project-specific renderers or fixtures.
10
+ - Report behavior progress, worker activity and per-case durations, preserving
11
+ partial outcomes and candidate artifacts on timeout while blocking incomplete
12
+ runs. Send requested MCP progress updates and expose candidate progress in status.
13
+ - Filter selected Playwright case tags, preserve project worker configuration and
14
+ verify concurrent Apps against one candidate host. Narrow named barrel dependencies
15
+ under declared side-effect policies and reuse parsing/digests within an inspection.
16
+ - Explain draft behavior verification through diff preparation and candidate
17
+ dry runs in all five portable skills. Distinguish candidate checks from
18
+ selected live-source verification through `ads_verify`.
19
+
20
+ ## 0.3.12
21
+
22
+ - Let the documentation unit create shared credits, conventional root license
23
+ notices and text `licenses/` collections in one guarded owner patch. Expose its
24
+ new-file policy, discover readable provenance resources without enlarging
25
+ workflow guide context, preserve verbatim UTF-8 notices and keep code/peer/path
26
+ boundaries. Verify drafts, dry runs, publication, reads and rejection atomically.
27
+ - Add generic skill examples for preserving reference baselines and intended
28
+ public APIs while resolving coverage diagnostics; verify conditional controls
29
+ through browser state transitions instead of screenshots alone.
30
+
3
31
  ## 0.3.11
4
32
 
5
33
  - Bind preview success to project input content as well as artifact bytes. Source
package/docs/GUIDE.md CHANGED
@@ -5,7 +5,54 @@ contains a Node CLI, shared token contract, static checks, an Oxlint plugin, and
5
5
  empty foundation scaffolding. It includes no component or pattern catalogue,
6
6
  fonts, mock screens, or UI runtime dependency.
7
7
 
8
-
8
+ ## Use ADS through MCP
9
+
10
+ Connect your coding agent using the [MCP client setup](MCP.md#connect-codex-or-claude).
11
+ The client launches `ads mcp` in your project; add `--mutations` to enable guarded
12
+ source editing. Initialization also supplies `ads-mcp` and `ads-read-mcp` launch
13
+ scripts. The server starts its showcase and configured preview compiler, so use
14
+ the returned showcase URL rather than starting a separate preview application.
15
+
16
+ An agent uses the MCP tools to inspect, edit and review the project:
17
+
18
+ 1. Call `ads_status` to identify the connected project, available commands,
19
+ mutation mode, showcase URL and build freshness.
20
+ 2. Find public UI/behavior APIs with `ads_catalog`, inspect relevant definitions
21
+ with `ads_tokens`, and discover preview IDs with `ads_scenarios`. Read one
22
+ relevant workflow through `ads_skills` and `ads_read_skill` before editing.
23
+ 3. Open a discovered preview with `ads_show_scenario` or `ads_show_tokens`. These
24
+ render the project's actual scenario, fixtures and adapter through MCP Apps.
25
+ Use the returned showcase link when the client cannot render Apps.
26
+ 4. When editing is enabled, discover ownership with `ads_units`, read relevant
27
+ source through `ads_read_file`, and submit an owner Git patch through
28
+ `ads_apply_patch`. Use `ads_apply_transaction` for genuinely coupled owners.
29
+ Prepare/diff a local draft with `ads_prepare_patch` when useful. Dry-run first;
30
+ ADS checks an isolated candidate before any live source is written.
31
+ 5. Review diagnostics, registered behavior evidence and the rendered result.
32
+ `ads_behaviors` discovers guarantees; `ads_verify` executes selected registered
33
+ checks. Recheck `ads_status.build` after changes; notifications alone do not
34
+ establish current output.
35
+
36
+ For an on-demand screenshot, call `ads_capture` with a discovered scenario ID and
37
+ optionally a named `captureTargets` element, theme and viewport. To compare a
38
+ proposed change before applying it, add `screenshot:true` and an explicit
39
+ `captures:[{scenario:"<discovered ID>"}]` selection to the patch/transaction dry run.
40
+ Normal patches and dry runs create no screenshots. Capture uses project-installed
41
+ Playwright and its browser; ADS installs neither.
42
+
43
+ Read `ads://project/captures` and the returned PNG image URIs through MCP
44
+ `resources/read`. Each target keeps only current and candidate: a successful
45
+ standalone screenshot replaces current and clears candidate; a screenshot-enabled
46
+ dry run replaces the pair; applying matching source promotes candidate. Source
47
+ changes mark images stale. There is no previous slot, history or TTL.
48
+ `ads_capture_clear` discards candidate or clears both images. Subscribe to resource
49
+ updates when supported, and explicitly reread otherwise. Screenshots provide visual
50
+ evidence for one state; they do not replace behavior checks.
51
+
52
+ The [MCP guide](MCP.md) details tool/resource schemas, prompts, skills and
53
+ notifications. The portable [capture guide](../skills/ads-designer/references/captures.md)
54
+ explains screenshot setup, named targets, limits and comparison usage; MCP serves
55
+ the same guide at `ads://guides/captures`.
9
56
 
10
57
  ## Implementation support
11
58