@reqvire-org/ads 0.3.3 → 0.3.6

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 (68) hide show
  1. package/README.md +17 -5
  2. package/dist/behaviors.mjs +157 -0
  3. package/dist/bootstrap-ds/ADS.md +16 -3
  4. package/dist/bootstrap-ds/README.md +10 -0
  5. package/dist/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
  6. package/dist/boundaries.mjs +3 -0
  7. package/dist/check.mjs +20 -2
  8. package/dist/cli.mjs +17 -0
  9. package/dist/config.mjs +23 -3
  10. package/dist/css-values.mjs +26 -0
  11. package/dist/index.mjs +2 -0
  12. package/dist/init.mjs +1 -1
  13. package/dist/mcp/build-status.mjs +17 -7
  14. package/dist/mcp/catalog.mjs +11 -8
  15. package/dist/mcp/context.mjs +3 -3
  16. package/dist/mcp/edit-preparation.mjs +111 -0
  17. package/dist/mcp/mutations.mjs +64 -38
  18. package/dist/mcp/preview-owner-guard.mjs +29 -0
  19. package/dist/mcp/preview-watch.mjs +38 -8
  20. package/dist/mcp/preview-widget.bundle.js +2 -2
  21. package/dist/mcp/prompts.mjs +2 -2
  22. package/dist/mcp/server.mjs +88 -41
  23. package/dist/mcp/showcase-host.mjs +9 -5
  24. package/dist/mcp/text-diff.mjs +19 -0
  25. package/dist/preview-resources.mjs +45 -8
  26. package/dist/preview-scope.mjs +30 -0
  27. package/dist/public-api.mjs +39 -0
  28. package/dist/scenarios-compiler.mjs +41 -19
  29. package/dist/scenarios.mjs +72 -42
  30. package/dist/scoped-tokens.mjs +57 -0
  31. package/dist/skills/README.md +3 -3
  32. package/dist/skills/ads-break-ui/SKILL.md +26 -2
  33. package/dist/skills/ads-break-ui/references/guarded-editing.md +44 -0
  34. package/dist/skills/ads-break-ui/references/verification.md +6 -0
  35. package/dist/skills/ads-designer/SKILL.md +27 -3
  36. package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +16 -3
  37. package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +10 -0
  38. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/controllers/.gitkeep +0 -0
  39. package/dist/skills/ads-designer/references/cli.md +1 -1
  40. package/dist/skills/ads-designer/references/design-craft.md +1 -1
  41. package/dist/skills/ads-designer/references/guarded-editing.md +44 -0
  42. package/dist/skills/ads-import/SKILL.md +26 -2
  43. package/dist/skills/ads-import/references/cli.md +1 -1
  44. package/dist/skills/ads-import/references/guarded-editing.md +44 -0
  45. package/dist/skills/ads-import/references/tokens.md +4 -2
  46. package/dist/skills/{ads-assimilate → ads-migrate}/SKILL.md +32 -8
  47. package/dist/skills/ads-migrate/agents/openai.yaml +4 -0
  48. package/dist/skills/{ads-assimilate → ads-migrate}/references/cli.md +1 -1
  49. package/dist/skills/ads-migrate/references/guarded-editing.md +44 -0
  50. package/dist/skills/{ads-assimilate → ads-migrate}/references/native-implementation.md +11 -0
  51. package/dist/skills/ads-motion/SKILL.md +26 -2
  52. package/dist/skills/ads-motion/references/guarded-editing.md +44 -0
  53. package/dist/token-fixtures.mjs +30 -0
  54. package/dist/tokens.mjs +11 -4
  55. package/dist/units.mjs +35 -9
  56. package/dist/verification/browser.mjs +27 -0
  57. package/dist/verification/playwright-reporter.cjs +20 -0
  58. package/dist/verification.mjs +214 -0
  59. package/docs/ADS.md +72 -4
  60. package/docs/ARCHITECTURE.md +60 -1
  61. package/docs/BEHAVIORS.md +241 -0
  62. package/docs/CHANGELOG.md +56 -0
  63. package/docs/GUIDE.md +24 -4
  64. package/docs/MCP.md +106 -12
  65. package/package.json +4 -2
  66. package/dist/skills/ads-assimilate/agents/openai.yaml +0 -4
  67. /package/dist/skills/{ads-assimilate → ads-migrate}/references/lint-and-docs.md +0 -0
  68. /package/dist/skills/{ads-assimilate → ads-migrate}/references/testing.md +0 -0
@@ -4,13 +4,15 @@ import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { isInside, projectPath } from "../config.mjs";
6
6
  import { readPreviewManifest } from "../preview-resources.mjs";
7
+ import { previewArtifactPath, withPreviewSession } from "../preview-scope.mjs";
7
8
 
8
9
  export const BUILD_URI = "ads://project/build";
9
10
  export const buildStatusFile = (root) => path.join(root, ".ds/build-status.json");
10
11
  const maxBytes = 256 * 1024;
11
12
  const diagnostic = z.object({ message: z.string().max(4000), file: z.string().max(1000).optional(), line: z.number().int().positive().optional(), column: z.number().int().positive().optional(), plugin: z.string().max(200).optional(), code: z.string().max(100).optional() }).strict();
12
13
  const preview = z.object({ id: z.string().max(256), state: z.enum(["idle", "building", "ready", "error"]), revision: z.number().int().nonnegative(), lastSuccessfulRevision: z.number().int().positive().nullable(), artifactDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable(), diagnostics: z.array(diagnostic).max(20), diagnosticSet: z.number().int().nonnegative().optional() }).strict();
13
- const snapshot = z.object({ schemaVersion: z.literal(1), session: z.string().regex(/^[\w-]{1,100}$/), sequence: z.number().int().nonnegative(), updatedAt: z.string().datetime(), previews: z.array(preview), diagnostics: z.array(diagnostic).max(20).optional(), diagnosticSets: z.array(z.array(diagnostic).max(20)).optional() }).strict().refine(value => value.previews.every(entry => entry.diagnosticSet === undefined || value.diagnosticSets?.[entry.diagnosticSet]), "Invalid shared diagnostic reference.");
14
+ const progressSchema = z.object({ phase: z.enum(["queued", "validation", "compilation", "validation-and-compilation", "publication", "idle"]), startedAt: z.string().datetime(), updatedAt: z.string().datetime() }).strict();
15
+ const snapshot = z.object({ schemaVersion: z.literal(1), session: z.string().regex(/^[\w-]{1,100}$/), sequence: z.number().int().nonnegative(), updatedAt: z.string().datetime(), progress: progressSchema.optional(), previews: z.array(preview), diagnostics: z.array(diagnostic).max(20).optional(), diagnosticSets: z.array(z.array(diagnostic).max(20)).optional() }).strict().refine(value => value.previews.every(entry => entry.diagnosticSet === undefined || value.diagnosticSets?.[entry.diagnosticSet]), "Invalid shared diagnostic reference.");
14
16
  export const artifactDigest = (file) => {
15
17
  if (realpathSync(file) !== file) throw new Error("Preview output cannot resolve through a symlink.");
16
18
  if (statSync(file).size > 2 * 1024 * 1024) throw new Error("Preview exceeds 2 MiB.");
@@ -37,8 +39,9 @@ export function createBuildReporter(directory, { apps, session = process.env.ADS
37
39
  const entries = new Map(apps.map((app) => [app.id, { id: app.id, state: "idle", revision: 0, lastSuccessfulRevision: null, artifactDigest: null, diagnostics: [] }]));
38
40
  let sequence = 0;
39
41
  let diagnostics = [];
42
+ let progress;
40
43
  function save() {
41
- const value = { schemaVersion: 1, session, sequence: ++sequence, updatedAt: new Date().toISOString(), previews: [...entries.values()], ...(diagnostics.length ? { diagnostics } : {}) };
44
+ const value = { schemaVersion: 1, session, sequence: ++sequence, updatedAt: new Date().toISOString(), previews: [...entries.values()], ...(progress ? { progress } : {}), ...(diagnostics.length ? { diagnostics } : {}) };
42
45
  let text = JSON.stringify(snapshot.parse(value));
43
46
  // A shared graph failure applies to every scenario. Store repeated details
44
47
  // once only when necessary; ordinary reports retain inline diagnostics.
@@ -67,6 +70,11 @@ export function createBuildReporter(directory, { apps, session = process.env.ADS
67
70
  save();
68
71
  return {
69
72
  session,
73
+ progress(phase) {
74
+ const now = new Date().toISOString();
75
+ progress = { phase, startedAt: !progress || progress.phase === "idle" ? now : progress.startedAt, updatedAt: now };
76
+ save();
77
+ },
70
78
  failure(error) {
71
79
  diagnostics = (Array.isArray(error) ? error : [error]).slice(0, 20).map(error => normalizeDiagnostic(root, error));
72
80
  for (const value of entries.values()) { value.state = "error"; ++value.revision; value.diagnostics = []; }
@@ -116,7 +124,8 @@ export function createBuildReporter(directory, { apps, session = process.env.ADS
116
124
  }
117
125
 
118
126
  /** Read durable build events; a running process alone never means a fresh build. */
119
- export function inspectBuildStatus(root, config, { session, processState, file = buildStatusFile(root) } = {}) {
127
+ export function inspectBuildStatus(root, config, { session, previewSession, processState, file = buildStatusFile(root) } = {}) {
128
+ const options = withPreviewSession({ root, config: { mcp: config } }, previewSession);
120
129
  let report;
121
130
  let reportError;
122
131
  try {
@@ -130,7 +139,7 @@ export function inspectBuildStatus(root, config, { session, processState, file =
130
139
  let graphError;
131
140
  if (config.previewManifest && report?.previews.some(entry => entry.state === "ready")) {
132
141
  try {
133
- if (!readPreviewManifest({ root, config: { mcp: config } }, { verify: true })) throw new Error("Preview resource manifest is missing.");
142
+ if (!readPreviewManifest(options, { verify: true })) throw new Error("Preview resource manifest is missing.");
134
143
  } catch (error) { graphError = `Invalid preview resource graph: ${error.message}`; }
135
144
  }
136
145
  const previews = config.apps.map((app) => {
@@ -138,12 +147,13 @@ export function inspectBuildStatus(root, config, { session, processState, file =
138
147
  let state = value?.state ?? "unreported";
139
148
  let diagnostics = value?.diagnosticSet !== undefined ? report.diagnosticSets[value.diagnosticSet] : value?.diagnostics ?? [];
140
149
  if (report?.diagnostics?.length) { state = "error"; diagnostics = [...report.diagnostics, ...diagnostics].slice(0, 20); }
141
- const available = existsSync(path.resolve(root, app.file));
150
+ const artifact = path.resolve(root, previewArtifactPath(options, app.file));
151
+ const available = existsSync(artifact);
142
152
  if (reportError) { state = "error"; diagnostics = [{ message: reportError }]; }
143
153
  if (graphError && app.id.includes("/")) { state = "error"; diagnostics = [{ message: graphError }]; }
144
154
  if (state === "ready") {
145
155
  try {
146
- if (value.lastSuccessfulRevision !== value.revision || artifactDigest(path.resolve(root, app.file)) !== value.artifactDigest) throw new Error("Preview output does not match its successful build. Rebuild before reviewing it.");
156
+ if (value.lastSuccessfulRevision !== value.revision || artifactDigest(artifact) !== value.artifactDigest) throw new Error("Preview output does not match its successful build. Rebuild before reviewing it.");
147
157
  } catch (error) { state = "error"; diagnostics = [{ message: error.message }]; }
148
158
  }
149
159
  if (processState?.state === "failed") { state = "error"; diagnostics = [{ message: processState.error ?? "Preview watcher exited." }, ...diagnostics].slice(0, 20); }
@@ -153,7 +163,7 @@ export function inspectBuildStatus(root, config, { session, processState, file =
153
163
  current, stale: state === "unreported" ? null : !current, available, diagnostics };
154
164
  });
155
165
  const state = reportError || report?.diagnostics?.length || previews.some((entry) => entry.state === "error") ? "error" : previews.some((entry) => entry.state === "building") ? "building" : previews.some((entry) => entry.state === "unreported") ? "unreported" : previews.some(entry => entry.current) ? "ready" : previews.length ? "idle" : "disabled";
156
- return { state, reported: Boolean(report), session: report?.session ?? null, sequence: report?.sequence ?? null, updatedAt: report?.updatedAt ?? null, previews, ...(report?.diagnostics?.length ? { diagnostics: report.diagnostics } : {}), resourceUri: BUILD_URI };
166
+ return { state, reported: Boolean(report), session: report?.session ?? null, sequence: report?.sequence ?? null, updatedAt: report?.updatedAt ?? null, ...(report?.progress ? { progress: report.progress } : {}), previews, ...(report?.diagnostics?.length ? { diagnostics: report.diagnostics } : {}), resourceUri: BUILD_URI };
157
167
  }
158
168
 
159
169
  export class PreviewBuildError extends Error {
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import ts from "typescript";
4
4
  import { isInside, sourceFiles } from "../config.mjs";
5
5
  import { projectCompilerOptions } from "../boundaries.mjs";
6
- import { publicUi } from "../scenarios.mjs";
6
+ import { publicApi } from "../public-api.mjs";
7
7
  import { projectGuideFiles } from "../project-documents.mjs";
8
8
 
9
9
  export const PROJECT_URI = "ads://project";
@@ -24,7 +24,7 @@ export function projectDocuments(options) {
24
24
  for (const name of projectGuideFiles(root)) files.push(path.join(root, name));
25
25
  return [...new Set(files)].filter((file) => isInside(root, realpathSync(file))).sort().map((file) => {
26
26
  const relative = path.relative(root, file).replaceAll("\\", "/");
27
- return { uri: sourceUri(relative), name: relative, mimeType: file.endsWith(".md") ? "text/markdown" : "text/plain", file };
27
+ return { uri: sourceUri(relative), name: relative, mimeType: file.endsWith(".md") ? "text/markdown" : file.endsWith(".json") ? "application/json" : "text/plain", file };
28
28
  });
29
29
  }
30
30
 
@@ -32,27 +32,30 @@ export function inspectCatalog(options, scenarios = []) {
32
32
  const { root, dsRoot, config } = options;
33
33
  const entry = path.join(dsRoot, config.publicEntry);
34
34
  if (!existsSync(entry)) return [];
35
- const program = ts.createProgram([entry], projectCompilerOptions(options));
35
+ const program = ts.createProgram([entry], { ...projectCompilerOptions(options), allowJs: true });
36
36
  const checker = program.getTypeChecker();
37
37
  const source = program.getSourceFile(entry);
38
38
  const module = source && checker.getSymbolAtLocation(source);
39
39
  if (!module) return [];
40
40
  const documents = projectDocuments(options);
41
41
  const items = [];
42
- const renderable = new Set(publicUi(program, options).filter(item => item.renderable).map(item => item.name));
42
+ const api = new Map(publicApi(program, options).map(item => [item.name, item]));
43
43
  for (const exported of checker.getExportsOfModule(module)) {
44
44
  let symbol = exported;
45
45
  if (symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
46
46
  const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
47
- if (!(symbol.flags & ts.SymbolFlags.Value) || !declaration || !renderable.has(exported.name)) continue;
47
+ if (!(symbol.flags & ts.SymbolFlags.Value) || !declaration || !api.has(exported.name)) continue;
48
48
  const file = declaration.getSourceFile().fileName;
49
- const kind = isInside(path.join(dsRoot, "components"), file) ? "components" : isInside(path.join(dsRoot, "patterns"), file) ? "patterns" : null;
49
+ const kind = api.get(exported.name).kind;
50
+ const ui = api.get(exported.name).renderable;
51
+ const type = checker.getTypeOfSymbolAtLocation(symbol, declaration);
52
+ if (!ui && !type.getCallSignatures().length && !type.getConstructSignatures().length) continue;
50
53
  if (!kind) continue;
51
54
  const relative = path.relative(root, file).replaceAll("\\", "/");
52
55
  const line = declaration.getSourceFile().getLineAndCharacterOfPosition(declaration.getStart()).line + 1;
53
56
  const guides = documents.filter((doc) => doc.file.endsWith(".md") && path.dirname(doc.file) === path.dirname(file)).map((doc) => doc.uri);
54
- items.push({ name: exported.name, kind, importFrom: config.publicAlias, file: relative, line, sourceUri: sourceUri(relative), guides,
55
- previews: scenarios.filter(item => item.covers.includes(exported.name)).map(item => item.id) });
57
+ items.push({ name: exported.name, kind, apiKind: ui ? "ui" : "behavior", importFrom: config.publicAlias, file: relative, line, sourceUri: sourceUri(relative), guides,
58
+ previews: scenarios.filter(item => item.covers.includes(exported.name) || item.exercises?.includes(exported.name)).map(item => item.id) });
56
59
  }
57
60
  return items.sort((a, b) => a.name.localeCompare(b.name));
58
61
  }
@@ -5,7 +5,7 @@ import { projectGuideFiles } from "../project-documents.mjs";
5
5
 
6
6
  export const GUIDE_URI = "ads://project/guide";
7
7
  // Put the task triggers and entry path first: some hosts shorten server guidance.
8
- export const serverInstructions = "Use ADS for UI work: components, patterns, tokens, themes, branding, layout, interaction, accessibility, motion and showcase review. Start with ads_status; if tools are not exposed, read ads://project/guide then ads://project/status. Use the returned projectRoot and commands. When ads_status.mutations.enabled, use ads_units and guarded ads_apply_patch for one owner at a time. Find existing public UI with ads_catalog; discover its previews with ads_scenarios and open ads_show_scenario. Starting ADS also serves its built-in showcase: use ads_status.showcaseUrl for browser review instead of starting another runtime/compiler or project browser app. Read one relevant ADS skill before editing. Inspect the real preview, edit its owned source, check the build and review again. Scope source/guides to the project, its instruction ancestry and user-selected import/reference sources; broad searches of /mnt or home are not ADS discovery. Domain-model/requirements work is a separate project workflow. MCP source checks do not replace browser tests; direct edits still need project checks. Reading HTML is not rendering, and reading a skill is not native activation. A running watcher is not proof of current source: inspect ads_status.build or ads://project/build after edits. Notifications may not reach the agent. Only explicitly enabled mutation tools edit project-owned ADS units after validation. Checks and compiler commands come from validated project configuration, never an ad hoc command argument.";
8
+ export const serverInstructions = "Use ADS for UI work: components, patterns, controllers, hooks, tokens, themes, branding, layout, interaction, accessibility, motion and showcase review. Start with ads_status; if tools are not exposed, read ads://project/guide then ads://project/status. Use the returned projectRoot and commands. When ads_status.mutations.enabled, use ads_units and guarded ads_apply_patch for one owner; use ads_apply_transaction only for coupled owner patches. Find existing public UI with ads_catalog; discover its previews with ads_scenarios and open ads_show_scenario. Starting ADS also serves its built-in showcase: use ads_status.showcaseUrl for browser review instead of starting another runtime/compiler or project browser app. Read one relevant ADS skill before editing. Inspect the real preview, edit its owned source, check the build and review again. Scope source/guides to the project, its instruction ancestry and user-selected import/reference sources; broad searches of /mnt or home are not ADS discovery. Domain-model/requirements work is a separate project workflow. For behavior work, read ads://guides/behaviors and use ads_behaviors/ads_verify: scenario exercises are demonstrations, and registered guarantees need current passing execution evidence. Use the configured project framework and declared dependencies; MCP hosts supply no ad hoc APIs. MCP source checks do not replace browser tests; direct edits still need project checks. Reading HTML is not rendering, and reading a skill is not native activation. A running watcher is not proof of current source: inspect ads_status.build or ads://project/build after edits. Notifications may not reach the agent. Only explicitly enabled mutation tools edit project-owned ADS units after validation. Checks and compiler commands come from validated project configuration, never an ad hoc command argument.";
9
9
 
10
10
  function manager(root, pkg) {
11
11
  if (typeof pkg.packageManager === "string") return pkg.packageManager.split("@")[0];
@@ -35,7 +35,7 @@ export function workflow(previews) {
35
35
  next: "Find the requested public UI with ads_catalog (query + kind + small limit), then read its source/guide and one selected ADS skill. For a broad UI request, inspect the public barrel and targeted application entry files in sourceRoot.",
36
36
  skills: [
37
37
  { task: "Build, refine or debug UI using the project ADS", name: "ads-designer" },
38
- { task: "Adapt a component-library reference", name: "ads-assimilate" },
38
+ { task: "Adapt a component-library reference", name: "ads-migrate" },
39
39
  { task: "Import from another project's design-system", name: "ads-import" },
40
40
  { task: "Implement or review motion", name: "ads-motion" },
41
41
  { task: "Stress-test layout, states and interactions", name: "ads-break-ui" },
@@ -56,7 +56,7 @@ Use this server for UI components/patterns, tokens/themes/branding, layout, inte
56
56
  3. Select one workflow with ads_skills, then ads_read_skill. ${route.skills.map((skill) => `${skill.task}: ${skill.name}`).join("; ")}. Native skill activation is host-controlled. Relative skill references resolve from its SKILL.md URI. Read support files only when relevant.
57
57
  4. ${route.preview} Token samples: ads_scenarios(kind: tokens), then ads_show_scenario or ads_show_tokens(category: id). Token source inspection: ads_tokens with a focused query. Registered previews: ${previews.map((preview) => `${preview.id}: ${preview.tool ?? "ads_show_scenario"}`).join(", ") || "none"}. Showcase URL: ${context.showcase.url ?? "not configured; use the project's showcase command"}.
58
58
  Inspection connects a public export to its owning source, available guides, token values and discovered preview IDs. Bounded tools keep discovery focused; explicit resource reads supply detail. Source metadata does not prove layout, interaction or motion. A scenario renders the real public implementation and its fixture context in either review surface.
59
- 5. Check ads_status.mutations. When enabled, discover one owner with ads_units, read relevant source ranges with ads_read_file and submit only a Git unified diff through ads_apply_patch({unit, patch, dryRun}). Include that owner's scenarios, fixtures and required public re-exports, never peer units. tokens is the foundation unit; its manifest lists companionFiles for required canonical foundation imports when token files are created/deleted in the same patch. Preserve unrelated imports/comments and public-entry statements, including export {};. Dry-run to inspect validation; apply only a passing patch. Fix rejected patches in the owning unit; report unsupported coupled changes instead of bypassing rejection with direct writes. Intentional project setup, registered guard/compiler code and exceptions use configuration; shared globals use foundation, guides documentation and assets their discovered assets/* unit. Keep mandatory ADS gates. Binary/large Git diffs may use patchFile under .ds/patches/ to avoid model-context bytes. In read-only mode, source edits use the client editor/filesystem tools. Reuse the local public API (${context.publicEntry}). Edit presentation in its ADS owner; keep application data/services/routing in application code and development fixtures beside their scenario; larger scenarios live in composition/. Linaria extracts owned styling. Inspect relevant tokens with ads_tokens and source/owning guides before edits.
59
+ 5. Check ads_status.mutations. When enabled, discover one owner with ads_units, read relevant source ranges with ads_read_file and submit only a Git unified diff through ads_apply_patch({unit, patch, dryRun}). Include that owner's scenarios, fixtures and required public re-exports, never peer units. tokens is the foundation unit; its manifest lists companionFiles for required canonical foundation imports when token files are created/deleted in the same patch. Preserve unrelated imports/comments and public-entry statements; a real selected-owner re-export can remove its redundant export {};. Dry-run to inspect validation; apply only a passing patch. Fix rejected patches in the owning unit; use ads_apply_transaction for coupled owners instead of bypassing rejection with direct writes. ads_prepare_patch creates a local owner draft/diff; ads_prepare_token_samples reconciles explicit project bindings without guessing visual meaning. Intentional project setup, registered guard/compiler code and exceptions use configuration; shared globals use foundation, guides documentation and assets their discovered assets/* unit. Keep mandatory ADS gates. Binary/large Git diffs may use patchFile under .ds/patches/ to avoid model-context bytes. In read-only mode, source edits use the client editor/filesystem tools. Reuse the local public API (${context.publicEntry}). Edit presentation in its ADS owner; keep application data/services/routing in application code and development fixtures beside their scenario; larger scenarios live in composition/. Linaria extracts owned styling. Inspect relevant tokens with ads_tokens and source/owning guides before edits.
60
60
  6. ${route.validation} Registered commands: ${context.commands.map((command) => command.args?.join(" ") ?? `choose the project package manager for ${command.script}`).join("; ") || "none discovered; inspect the project's build setup"}. Run them from ${context.projectRoot}.
61
61
  7. After edits, inspect ads_status.build or ads://project/build explicitly. Running/unreported/old output is not a current successful preview. Fix diagnostics in owned source, wait for rebuilding and invoke the preview tool again. Notifications may not start an agent turn.
62
62
 
@@ -0,0 +1,111 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { isInside } from "../config.mjs";
6
+ import { checkUnitFiles, inspectUnits, validateUnitId } from "../units.mjs";
7
+ import { textDiff } from "./text-diff.mjs";
8
+
9
+ const hash = value => createHash("sha256").update(value).digest("hex");
10
+ const binary = bytes => bytes?.includes(0) || bytes && !Buffer.from(bytes.toString("utf8")).equals(bytes);
11
+ function generated(options, relative) {
12
+ const target = path.join(options.root, ".ds", relative);
13
+ mkdirSync(path.dirname(target), {recursive:true});
14
+ if (realpathSync(path.dirname(target)) !== path.dirname(target)) throw new Error("Patch preparation output cannot resolve through symlinks.");
15
+ return target;
16
+ }
17
+ export function storePreparedPatch(options, patch) {
18
+ if (Buffer.byteLength(patch) > 16 * 1024 * 1024) throw new Error("Prepared Git patch exceeds 16 MiB.");
19
+ if (!patch) return {patch:null,patchFile:null};
20
+ const file = `.ds/patches/${randomUUID()}.patch`;
21
+ writeFileSync(generated(options,file.slice(4)),patch);
22
+ return {patchFile:file,bytes:Buffer.byteLength(patch)};
23
+ }
24
+
25
+ /** A temporary copy of just one owner and its declared companions, not a project mirror. */
26
+ export function prepareUnitPatch(options, {unit, action = "create", draft}) {
27
+ validateUnitId(unit);
28
+ const inventory = inspectUnits(options);
29
+ if (action === "create") {
30
+ const owner = inventory.units.find(entry => entry.id === unit);
31
+ const files = [...new Set([...(owner?.files ?? []), ...(owner?.companionFiles ?? []).map(entry => entry.path),
32
+ ...(!owner ? ["ds.config.json"] : []),
33
+ ...(/^(?:components|patterns|controllers)\//.test(unit) ? [path.relative(options.root,path.join(options.dsRoot,options.config.publicEntry)).replaceAll("\\","/")] : [])])];
34
+ if (!owner && !/^(?:components|patterns|controllers|composition)\/.+/.test(unit)) throw new Error("Discover an existing unit or choose a new unoccupied component/pattern/controller/composition owner.");
35
+ if (/^(?:components|patterns|controllers|composition)\//.test(unit)) for (let directory=unit;directory!==".";directory=path.posix.dirname(directory)) {
36
+ const marker=path.join(options.dsRoot,directory,".gitkeep");
37
+ if (existsSync(marker) && realpathSync(marker)===marker && lstatSync(marker).isFile() && lstatSync(marker).size===0) {
38
+ const relative=path.relative(options.root,marker).replaceAll("\\","/");
39
+ if (!files.includes(relative)) files.push(relative);
40
+ }
41
+ }
42
+ draft = randomUUID();
43
+ const base = generated(options,`patch-drafts/${draft}/manifest.json`), folder = path.join(path.dirname(base),"work");
44
+ const entries = [];
45
+ let bytes = 0;
46
+ for (const file of files) {
47
+ const absolute = path.resolve(options.root,file);
48
+ if (!isInside(options.root,absolute) || realpathSync(absolute) !== absolute || !lstatSync(absolute).isFile()) throw new Error("Draft inputs must be ordinary project source files.");
49
+ if (lstatSync(absolute).size > 16 * 1024 * 1024) throw new Error("Draft input exceeds 16 MiB.");
50
+ const content = readFileSync(absolute);
51
+ bytes += content.length;
52
+ if (entries.length >= 256 || bytes > 32 * 1024 * 1024) throw new Error("Owner draft exceeds 256 files/32 MiB. Prepare a targeted Git patch instead.");
53
+ for (const name of ["work","base"]) {
54
+ const target = path.join(path.dirname(base),name,file);
55
+ mkdirSync(path.dirname(target),{recursive:true}); writeFileSync(target,content);
56
+ }
57
+ entries.push({file,digest:hash(content)});
58
+ }
59
+ mkdirSync(folder,{recursive:true});
60
+ writeFileSync(base,JSON.stringify({schemaVersion:1,unit,entries}));
61
+ return {draft,unit,directory:folder,files, next:"Edit only this draft, then call ads_prepare_patch with action: diff and this draft ID. Live source is unchanged; the resulting Git patch still requires ads_apply_patch and all its gates."};
62
+ }
63
+ if (!/^[a-f0-9-]{36}$/.test(draft ?? "")) throw new Error("Use the draft ID returned by ads_prepare_patch.");
64
+ const base = path.join(options.root,".ds/patch-drafts",draft);
65
+ if (realpathSync(base) !== base) throw new Error("Draft paths cannot resolve through symlinks.");
66
+ const metadata = JSON.parse(readFileSync(path.join(base,"manifest.json"),"utf8"));
67
+ if (metadata.schemaVersion !== 1 || metadata.unit !== unit) throw new Error("Draft and selected unit do not match.");
68
+ if (action === "discard") {rmSync(base,{recursive:true});return {discarded:true,draft,unit};}
69
+ if (action !== "diff") throw new Error("Choose create, diff or discard.");
70
+ const files = new Set(metadata.entries.map(entry => entry.file));
71
+ function walk(directory) {
72
+ if (realpathSync(directory) !== directory) throw new Error("Draft directories cannot resolve through symlinks.");
73
+ for (const entry of readdirSync(directory,{withFileTypes:true})) {
74
+ const file = path.join(directory,entry.name);
75
+ if (entry.isDirectory()) walk(file);
76
+ else if (!entry.isFile() || lstatSync(file).nlink !== 1) throw new Error("Drafts contain ordinary unlinked source files only.");
77
+ else files.add(path.relative(path.join(base,"work"),file).replaceAll("\\","/"));
78
+ if (files.size > 256) throw new Error("Draft exceeds 256 files.");
79
+ }
80
+ }
81
+ walk(path.join(base,"work"));
82
+ const changes = [];
83
+ let patch = "", bytes = 0;
84
+ for (const file of files) {
85
+ if (!file || path.isAbsolute(file) || file.includes("\\") || file.split("/").some(part => !part || part === "." || part === ".." || part.startsWith(".") && part !== ".gitkeep" && !/^\.(?:oxlintrc|linariarc|babelrc)(?:\.json)?$/.test(part))) throw new Error("Drafts use ordinary project-relative source paths.");
86
+ const original = path.join(base,"base",file), next = path.join(base,"work",file), live = path.join(options.root,file);
87
+ const read = file => {
88
+ if (!existsSync(file)) return null;
89
+ const stat = lstatSync(file);
90
+ if (realpathSync(file) !== file || !stat.isFile() || stat.nlink !== 1 || stat.size > 16 * 1024 * 1024) throw new Error("Draft inputs must be ordinary unlinked files no larger than 16 MiB.");
91
+ return readFileSync(file);
92
+ };
93
+ const expected = metadata.entries.find(entry => entry.file === file)?.digest ?? null;
94
+ const left = read(original), right = read(next), source = read(live);
95
+ if ((left && hash(left)) !== expected || (source && hash(source)) !== expected) throw new Error(`Source/baseline changed for ${file}; create a fresh owner draft.`);
96
+ if (left !== null && right !== null && left.equals(right) || left === null && right === null) continue;
97
+ bytes += (left?.length ?? 0) + (right?.length ?? 0);
98
+ if (bytes > 32 * 1024 * 1024) throw new Error("Draft changes exceed 32 MiB.");
99
+ const change = {path:file,originalContent:left === null ? null : binary(left) ? left : left.toString(),content:right === null ? null : binary(right) ? right : right.toString()};
100
+ changes.push(change);
101
+ if (!binary(left) && !binary(right)) patch += textDiff(file,change.originalContent,change.content);
102
+ else {
103
+ let diff;
104
+ try { diff = execFileSync("git",["diff","--no-index","--binary","--no-ext-diff","--no-textconv","--",left === null ? "/dev/null" : original,right === null ? "/dev/null" : next],{encoding:"utf8",maxBuffer:16*1024*1024}); }
105
+ catch (error) { if (error.status !== 1) throw error; diff=error.stdout; }
106
+ patch += diff.replace(/^diff --git .+$/m,`diff --git a/${file} b/${file}`);
107
+ }
108
+ }
109
+ if (changes.length) checkUnitFiles(options,unit,changes,inventory,true);
110
+ return {unit,draft,changes:changes.map(entry=>entry.path),...storePreparedPatch(options,patch),next:"Apply with ads_apply_patch using this unit and patchFile. Preparation is not validation or publication."};
111
+ }
@@ -5,6 +5,7 @@ import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, re
5
5
  import path from "node:path";
6
6
  import ts from "typescript";
7
7
  import { isInside, loadConfig } from "../config.mjs";
8
+ import { affectedBehaviorUnits, createVerificationController } from "../verification.mjs";
8
9
  import { checkProject } from "../check.mjs";
9
10
  import { generateLintConfig } from "../lint-config.mjs";
10
11
  import { generateBrandAssets } from "../brand-assets.mjs";
@@ -12,7 +13,7 @@ import { projectContext } from "./context.mjs";
12
13
  import { applyFilePatch, parseUnifiedPatch } from "./patch.mjs";
13
14
  import { checkUnitCandidate, checkUnitFiles, inspectUnits, validateUnitId } from "../units.mjs";
14
15
 
15
- export const mutationLimits = { files: 32, patchBytes: 256 * 1024, patchFileBytes: 16 * 1024 * 1024, fileBytes: 16 * 1024 * 1024, readChars: 12000, snapshotFiles: 20000, snapshotBytes: 128 * 1024 * 1024 };
16
+ export const mutationLimits = { units: 8, files: 32, patchBytes: 256 * 1024, patchFileBytes: 16 * 1024 * 1024, fileBytes: 16 * 1024 * 1024, readChars: 12000, snapshotFiles: 20000, snapshotBytes: 128 * 1024 * 1024 };
16
17
  const digest = bytes => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
17
18
  const ignored = new Set([".git", ".ds", "node_modules", ".vite", "coverage", ".playwright-cli", "test-results", "output"]);
18
19
  const textFile = file => /\.(?:[cm]?[jt]sx?|css|html|jsonc?|md|txt|svg)$/.test(file);
@@ -28,7 +29,7 @@ function address(options, file) {
28
29
  if (!isInside(options.root, target) || target === options.root) throw fail("mutation-path", "Mutation paths must stay inside the project.", file);
29
30
  for (let current = options.root; ; ) {
30
31
  const relative = path.relative(current, target).split(path.sep);
31
- if (relative.some(part => ignored.has(part) || part.startsWith(".") && !setupDotfile(part))) throw fail("mutation-path", "Hidden, dependency and generated paths are not editable.", file);
32
+ if (relative.some(part => ignored.has(part) || part.startsWith(".") && !setupDotfile(part) && !(part === ".gitkeep" && path.basename(target) === part && isInside(options.dsRoot, target)))) throw fail("mutation-path", "Hidden, dependency and generated paths are not editable.", file);
32
33
  current = path.join(current, relative[0]);
33
34
  const stat = lstatSync(current, { throwIfNoEntry: false });
34
35
  if (stat?.isSymbolicLink() || stat && current !== target && !stat.isDirectory() || stat && current === target && (!stat.isFile() || stat.nlink !== 1)) throw fail("mutation-path", "Mutation paths must contain ordinary directories and unlinked regular files.", file);
@@ -153,7 +154,7 @@ export function createMutationController(directory) {
153
154
  let commands = [], diagnostics = [];
154
155
  try { commands = checks(options).map(check => [check.command, ...check.args].join(" ")); }
155
156
  catch (error) { diagnostics = [error.diagnostic ?? { rule: "mutation-check", message: error.message }]; }
156
- return { enabled: true, scope: "one-ds-unit", format: "git-unified-diff", dsDir: options.config.dsDir, limits: mutationLimits, checks: ["ads check", "registered Oxlint rules", ...commands], diagnostics, busy, lastResult };
157
+ return { enabled: true, scope: "one-ds-unit", format: "git-unified-diff", transactions:{tool:"ads_apply_transaction",maxUnits:mutationLimits.units,finalCandidateChecks:true}, preparation:{owner:"ads_prepare_patch",tokenSamples:"ads_prepare_token_samples"}, dsDir: options.config.dsDir, limits: mutationLimits, checks: ["ads check", "registered Oxlint rules", ...commands], diagnostics, busy, lastResult };
157
158
  },
158
159
  cancel() { active?.abort(); },
159
160
  units() { return inspectUnits(loadConfig(root)).units; },
@@ -169,7 +170,7 @@ export function createMutationController(directory) {
169
170
  return { ...source, unit, content, offset, nextOffset: end < source.content.length ? end : null, totalChars: source.content.length,
170
171
  startLine: source.content.slice(0, offset).split("\n").length };
171
172
  },
172
- async apply({ unit, patch, patchFile, dryRun = false }, signal) {
173
+ async apply({ unit, patch, patchFile, patches, dryRun = false }, signal) {
173
174
  if (busy) return { ok: false, applied: false, diagnostics: [{ rule: "mutation-busy", message: "Another mutation is validating; retry after it finishes." }] };
174
175
  busy = true;
175
176
  active = new AbortController();
@@ -178,26 +179,22 @@ export function createMutationController(directory) {
178
179
  const verified = [];
179
180
  try {
180
181
  const options = loadConfig(root);
181
- validateUnitId(unit);
182
- if ((typeof patch === "string") === (typeof patchFile === "string")) throw fail("mutation-patch", "Provide exactly one of patch or patchFile.");
183
- const maxPatch = patchFile === undefined ? mutationLimits.patchBytes : mutationLimits.patchFileBytes;
184
- if (patchFile !== undefined) patch = readPatchFile(options, patchFile);
185
- if (typeof patch !== "string" || patch.includes("\0") || Buffer.from(patch).toString("utf8") !== patch || Buffer.byteLength(patch) > maxPatch) throw fail("mutation-patch", "Patch exceeds its UTF-8 Git diff size limit.");
186
- const entries = parseUnifiedPatch(patch, mutationLimits.files), changes = [];
187
- const originals = entries.map(entry => {
188
- const change = { path: entry.path };
189
- const target = address(options, change.path);
190
- const before = read(options, change.path);
191
- if (!entry.binary && before.binary) throw fail("mutation-patch", "Binary assets require a git diff --binary patch.", change.path);
192
- change.content = entry.binary ? entry.operation === "delete" ? null : before.content ?? Buffer.alloc(0) : applyFilePatch(entry, before.content);
193
- if (entry.binary) change.binaryPatch = entry.patch;
194
- change.expectedDigest = before.digest;
195
- change.originalContent = before.content;
196
- if (!entry.binary) validateContent(change, unit);
197
- changes.push(change);
198
- return { ...before, mode: before.exists ? lstatSync(target).mode : undefined };
182
+ if (patches !== undefined && (unit !== undefined || patch !== undefined || patchFile !== undefined || !Array.isArray(patches) || patches.length < 2 || patches.length > mutationLimits.units)) throw fail("mutation-batch", "A transaction requires 2–8 distinct owner patches, without a top-level unit/patch.");
183
+ const groups = patches ?? [{unit,patch,patchFile}];
184
+ if (new Set(groups.map(group => group.unit)).size !== groups.length) throw fail("mutation-batch", "Use one patch per distinct unit in a transaction.");
185
+ let patchBytes = 0;
186
+ const plans = groups.map(group => {
187
+ validateUnitId(group.unit);
188
+ if ((typeof group.patch === "string") === (typeof group.patchFile === "string")) throw fail("mutation-patch", "Provide exactly one of patch or patchFile for each owner.");
189
+ const value = group.patchFile === undefined ? group.patch : readPatchFile(options,group.patchFile);
190
+ const maxPatch = group.patchFile === undefined ? mutationLimits.patchBytes : mutationLimits.patchFileBytes;
191
+ patchBytes += Buffer.byteLength(value ?? "");
192
+ if (typeof value !== "string" || value.includes("\0") || Buffer.from(value).toString("utf8") !== value || Buffer.byteLength(value) > maxPatch || patchBytes > mutationLimits.patchFileBytes) throw fail("mutation-patch", "Patch exceeds its UTF-8 Git diff size limit.");
193
+ return {unit:group.unit,entries:parseUnifiedPatch(value,mutationLimits.files)};
199
194
  });
200
- const inventory = checkUnitFiles(options, unit, changes, inspectUnits(options), true);
195
+ const units = groups.map(group => group.unit), configuration = units.includes("configuration"), assets = units.some(unit => unit.startsWith("assets"));
196
+ for (const plan of plans) for (const entry of plan.entries) address(options,entry.path);
197
+ const changes = [], originals = [], byPath = new Map();
201
198
  const cache = path.join(root, ".ds");
202
199
  mkdirSync(cache, { recursive: true });
203
200
  if (realpathSync(cache) !== path.join(realpathSync(root), ".ds")) throw fail("mutation-path", "The .ds directory cannot resolve through a symlink.");
@@ -205,7 +202,6 @@ export function createMutationController(directory) {
205
202
  const candidate = path.join(temporary, "project");
206
203
  mkdirSync(candidate);
207
204
  const before = snapshot(options, candidate);
208
- for (const original of originals) if (read(options, original.path).digest !== original.digest) throw fail("mutation-conflict", "Source changed while preparing the validation copy. Read the affected range and regenerate the patch.", original.path);
209
205
  const dependencies = path.join(root, "node_modules");
210
206
  if (!existsSync(dependencies)) throw fail("mutation-check", "Install project dependencies before using guarded mutations.");
211
207
  symlinkSync(realpathSync(dependencies), path.join(candidate, "node_modules"), process.platform === "win32" ? "junction" : "dir");
@@ -221,24 +217,45 @@ export function createMutationController(directory) {
221
217
  return true;
222
218
  } });
223
219
  }
220
+ const entries = plans.flatMap(plan => plan.entries);
224
221
  if (entries.some(entry => entry.binary)) {
225
222
  const formats = new Set(entries.filter(entry=>entry.binary).map(entry=>entry.objectFormat));
226
223
  if (formats.size !== 1) throw fail("mutation-patch", "Binary blocks in one Git diff must use the same object format.");
227
224
  await checkCommand("git", ["init", "--quiet", `--object-format=${[...formats][0]}`, candidate], root, signal);
228
225
  }
229
- for (const change of changes) {
230
- const file = path.join(candidate, change.path);
231
- if (change.binaryPatch) {
232
- for (const match of change.binaryPatch.matchAll(/^(?:literal|delta) (\d+)$/gm)) if (Number(match[1]) > mutationLimits.fileBytes) throw fail("mutation-content", "Binary patch blocks exceed 16 MiB.", change.path);
233
- await checkCommand("git", ["apply", "--binary", "--whitespace=nowarn", "-"], candidate, signal, change.binaryPatch);
234
- change.content = existsSync(file) ? readFileSync(file) : null;
235
- validateContent(change, unit);
226
+ for (const plan of plans) {
227
+ const current = loadConfig(candidate), selected = plan.entries.map(entry => {
228
+ address(options,entry.path);
229
+ const before = read(current,entry.path);
230
+ if (!entry.binary && before.binary) throw fail("mutation-patch", "Binary assets require a git diff --binary patch.", entry.path);
231
+ const change = {path:entry.path, originalContent:before.content, content:entry.binary ? entry.operation === "delete" ? null : before.content ?? Buffer.alloc(0) : applyFilePatch(entry,before.content), ...(entry.binary ? {binaryPatch:entry.patch} : {})};
232
+ if (!entry.binary) validateContent(change,plan.unit);
233
+ if (!byPath.has(entry.path)) {
234
+ const original = read(options,entry.path);
235
+ // The candidate copy and live inputs must agree before any changes.
236
+ if (original.digest !== before.digest) throw fail("mutation-conflict", "Source changed while preparing validation; regenerate the patch.", entry.path);
237
+ originals.push({...original,mode:original.exists ? lstatSync(address(options,entry.path)).mode : undefined});
238
+ byPath.set(entry.path,changes.length);
239
+ changes.push({...change,expectedDigest:original.digest});
240
+ if (changes.length > mutationLimits.files) throw fail("mutation-batch", "A transaction may change at most 32 distinct files.");
241
+ }
242
+ return change;
243
+ });
244
+ const inventory = checkUnitFiles(current,plan.unit,selected,inspectUnits(current),true);
245
+ for (const change of selected) {
246
+ const file = path.join(candidate,change.path);
247
+ if (change.binaryPatch) {
248
+ for (const match of change.binaryPatch.matchAll(/^(?:literal|delta) (\d+)$/gm)) if (Number(match[1]) > mutationLimits.fileBytes) throw fail("mutation-content", "Binary patch blocks exceed 16 MiB.", change.path);
249
+ await checkCommand("git",["apply","--binary","--whitespace=nowarn","-"],candidate,signal,change.binaryPatch);
250
+ change.content = existsSync(file) ? readFileSync(file) : null;
251
+ validateContent(change,plan.unit);
252
+ } else if (change.content === null) rmSync(file);
253
+ else {mkdirSync(path.dirname(file),{recursive:true});writeFileSync(file,change.content);}
254
+ changes[byPath.get(change.path)].content = change.content;
236
255
  }
237
- else if (change.content === null) rmSync(file);
238
- else { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, change.content); }
256
+ checkUnitCandidate(loadConfig(candidate),plan.unit,selected,inventory);
239
257
  }
240
258
  const candidateOptions = loadConfig(candidate);
241
- checkUnitCandidate(candidateOptions, unit, changes, inventory);
242
259
  const extraChecks = checks(candidateOptions);
243
260
  const expected = snapshot(candidateOptions);
244
261
  const result = checkProject(candidate, { trackingRoot: root });
@@ -250,16 +267,25 @@ export function createMutationController(directory) {
250
267
  const bin = JSON.parse(readFileSync(oxlintPackage, "utf8")).bin;
251
268
  const executable = path.resolve(path.dirname(oxlintPackage), typeof bin === "string" ? bin : bin.oxlint);
252
269
  const lintPaths = [candidateOptions.config.sourceDir, candidateOptions.config.dsDir].filter(file => existsSync(path.join(candidate, file)));
253
- if (unit === "configuration") for (const file of inspectUnits(candidateOptions).units.find(item => item.id === "configuration").files) {
270
+ if (configuration) for (const file of inspectUnits(candidateOptions).units.find(item => item.id === "configuration").files) {
254
271
  if (/\.[cm]?[jt]sx?$/.test(file) && !lintPaths.some(directory => isInside(path.resolve(candidate, directory), path.resolve(candidate, file)))) lintPaths.push(file);
255
272
  }
256
273
  await checkCommand(process.execPath, [executable, "--deny-warnings", "--config", lintConfig, ...lintPaths], candidate, signal);
257
274
  verified.push({ name: "registered Oxlint rules", passed: true });
258
- if (unit.startsWith("assets") || unit === "configuration") {
275
+ if (assets || configuration) {
259
276
  const brand = await generateBrandAssets(candidate);
260
277
  verified.push({ name: "brand assets", passed: true, skipped: !!brand.skipped });
261
278
  }
262
279
  for (const check of extraChecks) verified.push(await checkCommand(check.command, check.args, candidate, signal));
280
+ const behaviorUnits = affectedBehaviorUnits(candidateOptions, changes.map(change => change.path));
281
+ if (behaviorUnits.length) {
282
+ const verification = createVerificationController(candidate);
283
+ let evidence;
284
+ try { evidence = await verification.run({ units: behaviorUnits, persist: false }, signal); }
285
+ finally { await verification.close(); }
286
+ if (!evidence.ok) return lastResult = { ok: false, applied: false, diagnostics: [...evidence.diagnostics, ...evidence.results.filter(item => item.status !== "passed").map(item => ({ rule: "behavior-check", message: `${item.unit}#${item.check}: ${item.status}. ${item.message}` }))].slice(0, 50), checks: verified, behavior: evidence };
287
+ verified.push({ name: "registered behavior checks", passed: true, results: evidence.results });
288
+ }
263
289
  if (signal?.aborted) throw fail("mutation-cancelled", "Mutation cancelled before application.");
264
290
  if (snapshot(candidateOptions) !== expected) throw fail("mutation-check", "A validation command changed source files. Use checks without source-writing side effects.");
265
291
  if (snapshot(loadConfig(root)) !== before) throw fail("mutation-conflict", "Project inputs changed during validation; no changes were applied. Read current source and retry.");
@@ -290,8 +316,8 @@ export function createMutationController(directory) {
290
316
  }
291
317
  }
292
318
  const warnings = [];
293
- if (!dryRun && (unit.startsWith("assets") || unit === "configuration")) { try { await generateBrandAssets(root); } catch (error) { warnings.push({ rule: "mutation-assets", message: `Source was applied; generated brand output needs recovery: ${error.message}` }); } }
294
- return lastResult = { ok: true, unit, applied: !dryRun, changes: changes.map(change => ({ path: change.path, operation: change.content === null ? "delete" : change.expectedDigest === null ? "create" : "update", digest: change.content === null ? null : digest(Buffer.from(change.content)) })), checks: verified, ...(result.advisories?.length ? { advisories: result.advisories.slice(0, 50), advisoryCount: result.advisories.length } : {}), ...(warnings.length ? { warnings } : {}),
319
+ if (!dryRun && (assets || configuration)) { try { await generateBrandAssets(root); } catch (error) { warnings.push({ rule: "mutation-assets", message: `Source was applied; generated brand output needs recovery: ${error.message}` }); } }
320
+ return lastResult = { ok: true, ...(patches ? {units,transaction:true} : {unit}), applied: !dryRun, changes: changes.map(change => ({ path: change.path, operation: change.content === null ? "delete" : change.expectedDigest === null ? "create" : "update", digest: change.content === null ? null : digest(Buffer.from(change.content)) })), checks: verified, ...(result.advisories?.length ? { advisories: result.advisories.slice(0, 50), advisoryCount: result.advisories.length } : {}), ...(warnings.length ? { warnings } : {}),
295
321
  preview: "Source validation passed. Inspect the managed preview build and rendered scenario after applying; validation is not visual verification." };
296
322
  } catch (error) {
297
323
  return lastResult = { ok: false, applied: false, diagnostics: [error.diagnostic ?? { rule: "mutation-error", message: error.message }], checks: verified };
@@ -0,0 +1,29 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ // The only stdin writer is the MCP owner. EOF also arrives after SIGKILL or a
4
+ // client launcher crash, when the owner's JavaScript cleanup cannot execute.
5
+ const pid = Number(process.argv[2]);
6
+ if (!Number.isSafeInteger(pid) || pid <= 1 || pid === process.pid) throw new Error("Invalid owned preview process group");
7
+ let released = false, stopping = false, timer, input = "";
8
+ function stop() {
9
+ if (released || stopping) return;
10
+ stopping = true;
11
+ if (process.platform === "win32") {
12
+ const terminator = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
13
+ terminator.on("error", () => { process.exitCode = 1; });
14
+ return;
15
+ }
16
+ try { process.kill(-pid, "SIGTERM"); }
17
+ catch (error) { if (error.code !== "ESRCH") process.exitCode = 1; return; }
18
+ timer = setTimeout(() => {
19
+ try { process.kill(-pid, "SIGKILL"); }
20
+ catch (error) { if (error.code !== "ESRCH") process.exitCode = 1; }
21
+ }, 1500);
22
+ }
23
+ process.stdin.on("data", bytes => {
24
+ input = (input + bytes).slice(-32);
25
+ if (input === "disarm\n") { released = true; clearTimeout(timer); }
26
+ });
27
+ for (const event of ["end", "close", "error"]) process.stdin.once(event, stop);
28
+ for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, stop);
29
+ process.stdin.resume();