@calibrate-ds/cli 0.1.24 → 0.1.26

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/dist/commands/apply.d.ts.map +1 -1
  2. package/dist/commands/apply.js +4 -2
  3. package/dist/commands/apply.js.map +1 -1
  4. package/dist/commands/diff.d.ts.map +1 -1
  5. package/dist/commands/diff.js +26 -26
  6. package/dist/commands/diff.js.map +1 -1
  7. package/dist/commands/document.d.ts.map +1 -1
  8. package/dist/commands/document.js +2 -1
  9. package/dist/commands/document.js.map +1 -1
  10. package/dist/commands/generate-components.d.ts.map +1 -1
  11. package/dist/commands/generate-components.js +11 -5
  12. package/dist/commands/generate-components.js.map +1 -1
  13. package/dist/commands/implement.d.ts.map +1 -1
  14. package/dist/commands/implement.js +2 -1
  15. package/dist/commands/implement.js.map +1 -1
  16. package/dist/commands/prompt.js +2 -1
  17. package/dist/commands/prompt.js.map +1 -1
  18. package/dist/commands/scan.d.ts.map +1 -1
  19. package/dist/commands/scan.js +3 -13
  20. package/dist/commands/scan.js.map +1 -1
  21. package/dist/commands/verify.d.ts.map +1 -1
  22. package/dist/commands/verify.js +3 -2
  23. package/dist/commands/verify.js.map +1 -1
  24. package/dist/mcp/tools/action-tools.d.ts.map +1 -1
  25. package/dist/mcp/tools/action-tools.js +182 -217
  26. package/dist/mcp/tools/action-tools.js.map +1 -1
  27. package/dist/mcp/tools/component-tools.d.ts.map +1 -1
  28. package/dist/mcp/tools/component-tools.js +84 -29
  29. package/dist/mcp/tools/component-tools.js.map +1 -1
  30. package/dist/mcp/tools/governance-tools.d.ts.map +1 -1
  31. package/dist/mcp/tools/governance-tools.js +70 -184
  32. package/dist/mcp/tools/governance-tools.js.map +1 -1
  33. package/dist/mcp/tools/info-tools.d.ts.map +1 -1
  34. package/dist/mcp/tools/info-tools.js +36 -166
  35. package/dist/mcp/tools/info-tools.js.map +1 -1
  36. package/dist/mcp/tools/token-tools.d.ts.map +1 -1
  37. package/dist/mcp/tools/token-tools.js +34 -55
  38. package/dist/mcp/tools/token-tools.js.map +1 -1
  39. package/dist/mcp/tools/verify-tool.d.ts.map +1 -1
  40. package/dist/mcp/tools/verify-tool.js +0 -37
  41. package/dist/mcp/tools/verify-tool.js.map +1 -1
  42. package/dist/utils/managed-components.d.ts +14 -0
  43. package/dist/utils/managed-components.d.ts.map +1 -0
  44. package/dist/utils/managed-components.js +23 -0
  45. package/dist/utils/managed-components.js.map +1 -0
  46. package/package.json +5 -5
@@ -42,136 +42,143 @@ async function saveLock(workspaceRoot, lock) {
42
42
  export const actionTools = {
43
43
  name: "action",
44
44
  register(server, getContext) {
45
- server.tool("run_scan", "Scan the Figma design file and update the local snapshot. Equivalent to running 'ptb scan' in the terminal. Requires FIGMA_ACCESS_TOKEN to be set in the environment.", async () => {
46
- // Load config independently — no snapshot required (this tool creates the snapshot).
47
- let config;
48
- try {
49
- const cwd = await resolveCwd();
50
- config = await loadConfig(cwd);
51
- }
52
- catch (e) {
53
- return { content: [{ type: "text", text: e.message }] };
54
- }
55
- // Check token first so the error points at the real problem.
56
- const tokenEnv = config.designTool.accessTokenEnv;
57
- const token = process.env[tokenEnv];
58
- if (!token) {
59
- return {
60
- content: [{
61
- type: "text",
62
- text: `${tokenEnv} is not set in the MCP server environment.\n\nThe MCP server is a separate process from your terminal — it does not inherit your shell exports.\n\nFix options:\n 1. Add to ~/.zshrc or ~/.bashrc: export ${tokenEnv}=your_token\n Then launch your IDE from the terminal (not the dock): e.g. 'cursor .' or 'code .'\n 2. Mac only (dock-launched IDEs): run in terminal: launchctl setenv ${tokenEnv} your_token\n Then fully quit and relaunch your IDE.\n NOTE: If you already ran launchctl setenv, the MCP server may have started before the token was set. Fully quitting and relaunching your IDE is required — the running server cannot see tokens set after it started.\n 3. Alternatively, run 'ptb scan' directly in your terminal where the token is already exported.`,
63
- }],
64
- };
65
- }
66
- try {
67
- const fileKey = config.designTool.fileKey;
68
- const adapter = getDesignToolAdapter(config.designTool.provider);
69
- const model = await adapter.scanDesignFile({ fileKey, accessToken: token });
70
- // Mirror the full enrichment pipeline from scan.ts so MCP scans
71
- // produce the same snapshot as CLI scans (tokens, icons, bitmaps, thumbnails).
72
- const tokenResolution = await resolveTokenSource(config, token);
73
- if (tokenResolution.tokens.length > 0) {
74
- model.tokens = tokenResolution.tokens;
45
+ server.tool("run", `Execute a PTB pipeline stage. Use this to scan Figma, generate code, or maintain the local design system state.
46
+
47
+ PIPELINE ORDER — run stages in this sequence after first setup:
48
+ 1. scan → fetch Figma snapshot, enrich tokens/icons/thumbnails, auto-exports context
49
+ 2. tokens → regenerate CSS design token files from snapshot
50
+ 3. generate scaffold typed React component shells (accepts optional name param)
51
+ 4. context → re-export context files + update ptb.lock without re-scanning Figma
52
+ 5. prune → remove generated folders for components deleted from Figma
53
+
54
+ WHEN TO USE EACH:
55
+ scan — Figma file has changed or this is the first run. Requires FIGMA_ACCESS_TOKEN (or configured env var) in env.
56
+ tokens — After scan, or whenever token values changed in Figma.
57
+ generate After scan, or to scaffold a specific new component (use name param).
58
+ context — After implementing components to refresh ptb.lock hashes; or after scan if you skipped it.
59
+ prune — Components were deleted in Figma and their folders should be removed from the repo.`, {
60
+ stage: z.enum(["scan", "generate", "tokens", "context", "prune"])
61
+ .describe("Pipeline stage to execute"),
62
+ name: z.string().optional().default(".")
63
+ .describe("For stage:'generate' only — component name or '.' for all. Ignored by other stages."),
64
+ }, async ({ stage, name = "." }) => {
65
+ if (stage === "scan") {
66
+ let config;
67
+ try {
68
+ const cwd = await resolveCwd();
69
+ config = await loadConfig(cwd);
75
70
  }
76
- await enrichIconsWithSvg(model, fileKey, token, adapter);
77
- await enrichBitmapAssets(model, fileKey, token, config);
78
- await enrichComponentThumbnails(model, fileKey, token, config, adapter);
79
- await saveSnapshot(model, config);
80
- invalidateCache();
81
- const manifest = await loadManifest(config).catch(() => ({ components: {} }));
82
- await exportAllComponentContexts(model, config, manifest);
83
- return { content: [{ type: "text", text: `Scan complete. ${model.components.length} components, ${model.tokens?.length ?? 0} token collections.` }] };
84
- }
85
- catch (e) {
86
- return { content: [{ type: "text", text: `Scan failed: ${e.message}` }] };
87
- }
88
- });
89
- server.tool("generate_components", "Regenerate typed React component shells from the latest snapshot. Equivalent to 'ptb generate-components'. Pass a component name or '.' for all.", { name: z.string().default(".").describe("Component name or '.' for all") }, async ({ name }) => {
90
- let ctx;
91
- try {
92
- ctx = await getContext();
93
- }
94
- catch (e) {
95
- return { content: [{ type: "text", text: e.message }] };
96
- }
97
- try {
98
- const { snapshot, config } = ctx;
99
- const targets = name === "." || name === "*"
100
- ? snapshot.components
101
- : snapshot.components.filter(c => c.name.toLowerCase() === name.toLowerCase() || toSlug(c.name) === toSlug(name));
102
- if (targets.length === 0) {
103
- return { content: [{ type: "text", text: `No component found matching '${name}'.` }] };
71
+ catch (e) {
72
+ return { content: [{ type: "text", text: e.message }] };
104
73
  }
105
- const generator = getGenerator(config.framework.name || "react");
106
- const report = await generator.writeComponents(snapshot, config);
107
- await syncManifestWithReport(config, report);
108
- // Update Symbol Bridge
109
- await updateBridge(targets, config);
110
- const totalFiles = report.createdFiles.length + report.skippedFiles.length;
111
- const lines = [
112
- `Generated ${report.createdFiles.length} new files, skipped ${report.skippedFiles.length} (already exist). Total: ${totalFiles} files across ${targets.length} components.`,
113
- ``,
114
- `These are scaffold shells — prop signatures and CSS stubs, not full implementations.`,
115
- `After you implement each component, run stamp_component to record it:`,
116
- ` • One component: stamp_component { name: "ComponentName" }`,
117
- ` • All at once: stamp_component { name: "." }`,
118
- ``,
119
- `Until stamped, coverage_summary will show them as unstamped and ptb status --fail-on-stale will flag them.`,
120
- ];
121
- return { content: [{ type: "text", text: lines.join("\n") }] };
122
- }
123
- catch (e) {
124
- return { content: [{ type: "text", text: `Generation failed: ${e.message}` }] };
125
- }
126
- });
127
- server.tool("generate_tokens", "Regenerate CSS design token files from the latest snapshot. Equivalent to 'ptb generate-tokens'.", async () => {
128
- let ctx;
129
- try {
130
- ctx = await getContext();
131
- }
132
- catch (e) {
133
- return { content: [{ type: "text", text: e.message }] };
134
- }
135
- try {
136
- const { snapshot, config } = ctx;
137
- if (!snapshot.tokens?.length) {
138
- return { content: [{ type: "text", text: "No token collections in snapshot. Run run_scan first." }] };
74
+ const tokenEnv = config.designTool.accessTokenEnv;
75
+ const token = process.env[tokenEnv];
76
+ if (!token) {
77
+ return {
78
+ content: [{
79
+ type: "text",
80
+ text: `${tokenEnv} is not set in the MCP server environment.\n\nThe MCP server is a separate process from your terminal — it does not inherit your shell exports.\n\nFix options:\n 1. Add to ~/.zshrc or ~/.bashrc: export ${tokenEnv}=your_token\n Then launch your IDE from the terminal (not the dock): e.g. 'cursor .' or 'code .'\n 2. Mac only (dock-launched IDEs): run in terminal: launchctl setenv ${tokenEnv} your_token\n Then fully quit and relaunch your IDE.\n NOTE: If you already ran launchctl setenv, the MCP server may have started before the token was set. Fully quitting and relaunching your IDE is required — the running server cannot see tokens set after it started.\n 3. Alternatively, run 'ptb scan' directly in your terminal where the token is already exported.`,
81
+ }],
82
+ };
83
+ }
84
+ try {
85
+ const fileKey = config.designTool.fileKey;
86
+ const adapter = getDesignToolAdapter(config.designTool.provider);
87
+ const model = await adapter.scanDesignFile({ fileKey, accessToken: token });
88
+ const tokenResolution = await resolveTokenSource(config, token);
89
+ if (tokenResolution.tokens.length > 0)
90
+ model.tokens = tokenResolution.tokens;
91
+ await enrichIconsWithSvg(model, fileKey, token, adapter);
92
+ await enrichBitmapAssets(model, fileKey, token, config);
93
+ await enrichComponentThumbnails(model, fileKey, token, config, adapter);
94
+ await saveSnapshot(model, config);
95
+ invalidateCache();
96
+ const manifest = await loadManifest(config).catch(() => ({ components: {} }));
97
+ await exportAllComponentContexts(model, config, manifest);
98
+ return { content: [{ type: "text", text: `Scan complete. ${model.components.length} components, ${model.tokens?.length ?? 0} token collections.` }] };
99
+ }
100
+ catch (e) {
101
+ return { content: [{ type: "text", text: `Scan failed: ${e.message}` }] };
139
102
  }
140
- const generator = getGenerator(config.framework.name || "react");
141
- const report = await generator.writeTokens(snapshot, config);
142
- const totalTokenFiles = report.createdFiles.length + report.skippedFiles.length;
143
- return { content: [{ type: "text", text: `Generated ${report.createdFiles.length} new token files, skipped ${report.skippedFiles.length} (already exist). Total: ${totalTokenFiles} token files on disk.` }] };
144
- }
145
- catch (e) {
146
- return { content: [{ type: "text", text: `Token generation failed: ${e.message}` }] };
147
- }
148
- });
149
- server.tool("export_context", "Export component context files to .ptb/context/ and update ptb.lock. Equivalent to 'ptb export context'. Run after a scan to make context available to AI tools.", async () => {
150
- let ctx;
151
- try {
152
- ctx = await getContext();
153
103
  }
154
- catch (e) {
155
- return { content: [{ type: "text", text: e.message }] };
104
+ if (stage === "generate") {
105
+ let ctx;
106
+ try {
107
+ ctx = await getContext();
108
+ }
109
+ catch (e) {
110
+ return { content: [{ type: "text", text: e.message }] };
111
+ }
112
+ try {
113
+ const { snapshot, config } = ctx;
114
+ const targets = name === "." || name === "*"
115
+ ? snapshot.components
116
+ : snapshot.components.filter(c => c.name.toLowerCase() === name.toLowerCase() || toSlug(c.name) === toSlug(name));
117
+ if (targets.length === 0) {
118
+ return { content: [{ type: "text", text: `No component found matching '${name}'.` }] };
119
+ }
120
+ const generator = getGenerator(config.framework.name || "react");
121
+ const report = await generator.writeComponents(snapshot, config);
122
+ await syncManifestWithReport(config, report);
123
+ await updateBridge(targets, config);
124
+ const totalFiles = report.createdFiles.length + report.skippedFiles.length;
125
+ const lines = [
126
+ `Generated ${report.createdFiles.length} new files, skipped ${report.skippedFiles.length} (already exist). Total: ${totalFiles} files across ${targets.length} components.`,
127
+ ``,
128
+ `These are scaffold shells — prop signatures and CSS stubs, not full implementations.`,
129
+ `After you implement each component, call submit_work to record it as done:`,
130
+ ` • submit_work({ name: "ComponentName", folder: "src/components/..." })`,
131
+ ` • Bulk stamp (CLI only): ptb stamp component .`,
132
+ ``,
133
+ `Until stamped, get_status will show them as not-stamped and ptb status --fail-on-stale will flag them.`,
134
+ ];
135
+ return { content: [{ type: "text", text: lines.join("\n") }] };
136
+ }
137
+ catch (e) {
138
+ return { content: [{ type: "text", text: `Generation failed: ${e.message}` }] };
139
+ }
156
140
  }
157
- try {
158
- const { snapshot, config } = ctx;
159
- const manifest = await loadManifest(config).catch(() => ({ components: {} }));
160
- const paths = await exportAllComponentContexts(snapshot, config, manifest);
161
- return { content: [{ type: "text", text: `Exported ${paths.length} context files to .ptb/context/.` }] };
141
+ if (stage === "tokens") {
142
+ let ctx;
143
+ try {
144
+ ctx = await getContext();
145
+ }
146
+ catch (e) {
147
+ return { content: [{ type: "text", text: e.message }] };
148
+ }
149
+ try {
150
+ const { snapshot, config } = ctx;
151
+ if (!snapshot.tokens?.length) {
152
+ return { content: [{ type: "text", text: "No token collections in snapshot. Run run({ stage:'scan' }) first." }] };
153
+ }
154
+ const generator = getGenerator(config.framework.name || "react");
155
+ const report = await generator.writeTokens(snapshot, config);
156
+ const totalTokenFiles = report.createdFiles.length + report.skippedFiles.length;
157
+ return { content: [{ type: "text", text: `Generated ${report.createdFiles.length} new token files, skipped ${report.skippedFiles.length} (already exist). Total: ${totalTokenFiles} token files on disk.` }] };
158
+ }
159
+ catch (e) {
160
+ return { content: [{ type: "text", text: `Token generation failed: ${e.message}` }] };
161
+ }
162
162
  }
163
- catch (e) {
164
- return { content: [{ type: "text", text: `Export failed: ${e.message}` }] };
163
+ if (stage === "context") {
164
+ let ctx;
165
+ try {
166
+ ctx = await getContext();
167
+ }
168
+ catch (e) {
169
+ return { content: [{ type: "text", text: e.message }] };
170
+ }
171
+ try {
172
+ const { snapshot, config } = ctx;
173
+ const manifest = await loadManifest(config).catch(() => ({ components: {} }));
174
+ const paths = await exportAllComponentContexts(snapshot, config, manifest);
175
+ return { content: [{ type: "text", text: `Exported ${paths.length} context files to .ptb/context/.` }] };
176
+ }
177
+ catch (e) {
178
+ return { content: [{ type: "text", text: `Export failed: ${e.message}` }] };
179
+ }
165
180
  }
166
- });
167
- server.tool("stamp_component", `Mark a component as implemented against the current design. Equivalent to 'ptb stamp component <name>'. Use '.' to stamp all components.
168
-
169
- Optionally pass folder + artifacts to register the component's files in manifest.json — this is required for the ptb dev playground to pick up the component automatically. If ptb dev is running, the playground will hot-reload as soon as the manifest is updated.`, {
170
- name: z.string().describe("Component name or '.' for all"),
171
- message: z.string().optional().describe("Optional note about what was implemented"),
172
- folder: z.string().optional().describe("Relative path from packageRoot to the component folder, e.g. 'src/components/web/btn-web'. Required for playground auto-registration."),
173
- artifacts: z.array(z.string()).optional().describe("List of artifact paths relative to packageRoot, e.g. ['src/components/web/btn-web/BtnWeb.tsx', ...]. Defaults to [folder] if omitted."),
174
- }, async ({ name, message, folder, artifacts }) => {
181
+ // stage === "prune"
175
182
  let ctx;
176
183
  try {
177
184
  ctx = await getContext();
@@ -180,61 +187,19 @@ Optionally pass folder + artifacts to register the component's files in manifest
180
187
  return { content: [{ type: "text", text: e.message }] };
181
188
  }
182
189
  try {
183
- const lock = await loadLock(ctx.workspaceRoot);
184
- if (!lock) {
185
- return { content: [{ type: "text", text: "No ptb.lock found. Run export_context first." }] };
186
- }
187
- const identity = getGitIdentity();
188
- const now = new Date().toISOString();
189
- const stamped = [];
190
- const entries = name === "." || name === "*"
191
- ? Object.entries(lock.components)
192
- : Object.entries(lock.components).filter(([slug, e]) => slug === toSlug(name) || e.displayName.toLowerCase() === name.toLowerCase());
193
- if (entries.length === 0) {
194
- return { content: [{ type: "text", text: `No component found in ptb.lock matching '${name}'.` }] };
195
- }
196
- for (const [slug, entry] of entries) {
197
- // Build stampedContract from the live snapshot so diff is
198
- // scan-timing-independent (same logic as CLI stampInLock).
199
- const modelComponent = ctx.snapshot.components.find(c => c.name.toLowerCase() === entry.displayName.toLowerCase() ||
200
- c.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") === slug);
201
- const stampedContract = modelComponent ? {
202
- ...(modelComponent.variantAxes ? { variantAxes: modelComponent.variantAxes } : {}),
203
- ...(modelComponent.properties ? { properties: modelComponent.properties } : {}),
204
- ...(modelComponent.tokenBindings ? { tokenBindings: modelComponent.tokenBindings } : {}),
205
- ...(modelComponent.styleMetadata ? { styleMetadata: modelComponent.styleMetadata } : {}),
206
- ...(modelComponent.layout ? { layout: modelComponent.layout } : {}),
207
- } : undefined;
208
- lock.components[slug] = {
209
- ...entry,
210
- stampedHash: entry.designHash,
211
- stampedAt: now,
212
- stampedBy: identity,
213
- workStatus: "stamped",
214
- ...(message ? { stampedMessage: message } : {}),
215
- ...(stampedContract ? { stampedContract } : {}),
216
- };
217
- stamped.push(entry.displayName);
218
- }
219
- await saveLock(ctx.workspaceRoot, lock);
220
- // If the caller provided file paths, register them in manifest.json.
221
- // This triggers the ptb dev playground watcher so the component appears
222
- // in the playground without a manual `ptb dev` restart.
223
- const manifestNotes = [];
224
- if (folder && entries.length === 1) {
225
- const [, entry] = entries[0];
226
- const comp = ctx.snapshot.components.find((c) => c.name === entry.displayName);
227
- if (comp) {
228
- const resolvedArtifacts = artifacts ?? [folder];
229
- await updateComponentArtifacts(ctx.config, comp.id, comp.name, folder, resolvedArtifacts, ctx.config.framework?.name ?? "react");
230
- manifestNotes.push(`Registered in manifest.json → folder: ${folder}`);
231
- }
190
+ const result = await pruneStaleComponents(ctx.config, ctx.snapshot);
191
+ if (result.found === 0) {
192
+ return { content: [{ type: "text", text: "No stale artifacts to prune." }] };
232
193
  }
233
- const detail = manifestNotes.length > 0 ? `\n${manifestNotes.join("\n")}` : "";
234
- return { content: [{ type: "text", text: `Stamped ${stamped.length} component(s): ${stamped.join(", ")}${detail}` }] };
194
+ return {
195
+ content: [{
196
+ type: "text",
197
+ text: JSON.stringify({ found: result.found, deleted: result.deleted, skipped: result.skipped, errors: result.errors }, null, 2),
198
+ }],
199
+ };
235
200
  }
236
201
  catch (e) {
237
- return { content: [{ type: "text", text: `Stamp failed: ${e.message}` }] };
202
+ return { content: [{ type: "text", text: `Prune failed: ${e.message}` }] };
238
203
  }
239
204
  });
240
205
  server.tool("assign_component", "Assign a component to a developer. Stores assignment metadata (priority, due date, notes, acceptance criteria) in ptb.lock.", {
@@ -301,7 +266,7 @@ TOKEN AUTHORITY — what to trust, what to verify:
301
266
  DIVISION OF LABOR WITH FIGMA MCP (composite workflow — most accurate):
302
267
  PTB → token names, dependency graph, variant axes, state/content contracts (read contextFile)
303
268
  Figma → exact hex values, spacing, visual arrangement (get_variable_defs, get_screenshot)
304
- Map: Figma hex → get_token_by_value(hex) → --ptb-* cssVar name → emit both together
269
+ Map: Figma hex → get_token(hex) → --ptb-* cssVar name → emit both together
305
270
 
306
271
  TYPICAL WORKFLOW:
307
272
  1. Call start_work("<name>") — read blockedBy and tokenConflicts first
@@ -433,18 +398,24 @@ TYPICAL WORKFLOW:
433
398
  return { content: [{ type: "text", text: `start_work failed: ${e.message}` }] };
434
399
  }
435
400
  });
436
- server.tool("submit_work", `Submit completed implementation work for a component.
401
+ server.tool("submit_work", `Submit completed implementation work for a component. Use after writing all component files.
437
402
 
438
- Stamps the component (records the current design hash as implemented) and transitions
439
- its work lifecycle. Use after writing all component files.
403
+ Stamps the component (records the current design hash + design contract snapshot) and
404
+ transitions its work lifecycle. This is the canonical "I'm done" signal — always call
405
+ this after implement_component and writing the code.
440
406
 
441
407
  transitionTo options:
442
408
  - "stamped" (default): stamps immediately — for solo work or when you're the reviewer
443
- - "in_review": marks as ready for review without stamping — for teams where a reviewer stamps later`, {
409
+ - "in_review": marks as ready for review without stamping — for teams where a reviewer stamps later
410
+
411
+ folder + artifacts: register the component's files in manifest.json so the ptb dev
412
+ playground picks it up automatically (hot-reloads on save if ptb dev is running).`, {
444
413
  name: z.string().describe("Component name to submit"),
445
- transitionTo: z.enum(["stamped", "in_review"]).optional().describe("Target status after submitting. Default: 'stamped'"),
414
+ transitionTo: z.enum(["stamped", "in_review"]).optional().describe("Target status. Default: 'stamped'"),
446
415
  message: z.string().optional().describe("Optional implementation note"),
447
- }, async ({ name, transitionTo = "stamped", message }) => {
416
+ folder: z.string().optional().describe("Relative path from packageRoot to the component folder, e.g. 'src/components/web/btn-web'. Required for playground auto-registration."),
417
+ artifacts: z.array(z.string()).optional().describe("Artifact paths relative to packageRoot, e.g. ['src/components/web/btn-web/BtnWeb.tsx']. Defaults to [folder] if omitted."),
418
+ }, async ({ name, transitionTo = "stamped", message, folder, artifacts }) => {
448
419
  let ctx;
449
420
  try {
450
421
  ctx = await getContext();
@@ -455,7 +426,7 @@ transitionTo options:
455
426
  try {
456
427
  const lock = await loadLock(ctx.workspaceRoot);
457
428
  if (!lock) {
458
- return { content: [{ type: "text", text: "No ptb.lock found. Run export_context first." }] };
429
+ return { content: [{ type: "text", text: "No ptb.lock found. Run run({ stage:'context' }) first." }] };
459
430
  }
460
431
  const entries = Object.entries(lock.components).filter(([slug, e]) => slug === toSlug(name) || e.displayName.toLowerCase() === name.toLowerCase());
461
432
  if (entries.length === 0) {
@@ -465,6 +436,15 @@ transitionTo options:
465
436
  const now = new Date().toISOString();
466
437
  for (const [slug, entry] of entries) {
467
438
  if (transitionTo === "stamped") {
439
+ const modelComponent = ctx.snapshot.components.find(c => c.name.toLowerCase() === entry.displayName.toLowerCase() ||
440
+ c.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") === slug);
441
+ const stampedContract = modelComponent ? {
442
+ ...(modelComponent.variantAxes ? { variantAxes: modelComponent.variantAxes } : {}),
443
+ ...(modelComponent.properties ? { properties: modelComponent.properties } : {}),
444
+ ...(modelComponent.tokenBindings ? { tokenBindings: modelComponent.tokenBindings } : {}),
445
+ ...(modelComponent.styleMetadata ? { styleMetadata: modelComponent.styleMetadata } : {}),
446
+ ...(modelComponent.layout ? { layout: modelComponent.layout } : {}),
447
+ } : undefined;
468
448
  lock.components[slug] = {
469
449
  ...entry,
470
450
  stampedHash: entry.designHash,
@@ -472,6 +452,7 @@ transitionTo options:
472
452
  stampedBy: identity,
473
453
  workStatus: "stamped",
474
454
  ...(message ? { stampedMessage: message } : {}),
455
+ ...(stampedContract ? { stampedContract } : {}),
475
456
  };
476
457
  }
477
458
  else {
@@ -483,11 +464,20 @@ transitionTo options:
483
464
  }
484
465
  }
485
466
  await saveLock(ctx.workspaceRoot, lock);
467
+ // Register artifact paths in manifest.json for playground hot-reload
468
+ const manifestNotes = [];
469
+ if (folder && transitionTo === "stamped" && entries.length === 1) {
470
+ const [, entry] = entries[0];
471
+ const comp = ctx.snapshot.components.find(c => c.name === entry.displayName);
472
+ if (comp) {
473
+ await updateComponentArtifacts(ctx.config, comp.id, comp.name, folder, artifacts ?? [folder], ctx.config.framework?.name ?? "react");
474
+ manifestNotes.push(`Registered in manifest.json → folder: ${folder}`);
475
+ }
476
+ }
486
477
  const names = entries.map(([, e]) => e.displayName).join(", ");
487
- const action = transitionTo === "stamped"
488
- ? `Stamped and marked as done`
489
- : `Submitted for review (not yet stamped)`;
490
- return { content: [{ type: "text", text: `${action}: ${names}` }] };
478
+ const action = transitionTo === "stamped" ? `Stamped and marked as done` : `Submitted for review (not yet stamped)`;
479
+ const detail = manifestNotes.length > 0 ? `\n${manifestNotes.join("\n")}` : "";
480
+ return { content: [{ type: "text", text: `${action}: ${names}${detail}` }] };
491
481
  }
492
482
  catch (e) {
493
483
  return { content: [{ type: "text", text: `submit_work failed: ${e.message}` }] };
@@ -509,8 +499,7 @@ TOKEN AUTHORITY — what to trust, what to verify:
509
499
 
510
500
  WORKFLOW — follow this exactly:
511
501
  1. Check unbuiltDependencies. If any exist, call implement_component for each one in buildOrder first.
512
- - autoGeneratedIcons are handled by ptb automatically — do NOT call stamp_component for them,
513
- they are not tracked in ptb.lock.
502
+ - autoGeneratedIcons are handled by ptb automatically — they are not tracked in ptb.lock.
514
503
  2. For each component in buildOrder (dependencies first, then the requested component):
515
504
  a. Read contextFile for the full implementation brief (promptText, renderTree, variantDiffs).
516
505
  b. Write the required files: <Name>.tsx, <Name>.types.ts, <Name>.module.css, index.ts
@@ -518,8 +507,8 @@ WORKFLOW — follow this exactly:
518
507
  - Populate story args from contentSourceMap (username, title, etc.) using the base values
519
508
  shown in the Content Contracts section of promptText.
520
509
  - Include at least one populated story so the playground/Storybook preview matches the design.
521
- d. After writing ALL files, call stamp_component with the folder path so the playground updates.
522
- 3. Never skip stamp_component — it is what marks the component as done and keeps ptb.lock in sync.
510
+ d. After writing ALL files, call submit_work with the folder path so the playground updates.
511
+ 3. Never skip submit_work — it records stampedContract and keeps ptb.lock in sync.
523
512
 
524
513
  If a dependency also has its own unbuilt dependencies, implement_component will surface those
525
514
  recursively when you call it for that dependency.`, { name: z.string().describe("Component name to implement, e.g. 'SearchBar' or 'BtnWeb'") }, async ({ name }) => {
@@ -552,7 +541,7 @@ recursively when you call it for that dependency.`, { name: z.string().describe(
552
541
  depIds.delete(comp.id);
553
542
  const depComponents = ctx.snapshot.components.filter((c) => depIds.has(c.id));
554
543
  // Icons are auto-generated deterministically by ptb — they are NOT tracked
555
- // in ptb.lock (writeLockFile skips them), so calling stamp_component on them
544
+ // in ptb.lock (writeLockFile skips them), so calling submit_work on them
556
545
  // will always fail with "not found". Split them out so the agent never tries.
557
546
  const iconDeps = depComponents.filter((c) => isIconComponent(c.name));
558
547
  const nonIconDeps = depComponents.filter((c) => !isIconComponent(c.name));
@@ -639,30 +628,6 @@ recursively when you call it for that dependency.`, { name: z.string().describe(
639
628
  return { content: [{ type: "text", text: `implement_component failed: ${e.message}` }] };
640
629
  }
641
630
  });
642
- server.tool("run_prune", "Remove stale generated component folders that are no longer in the design system (present in manifest but deleted from Figma). Also cleans up ptb.lock entries. Equivalent to 'ptb prune'.", async () => {
643
- let ctx;
644
- try {
645
- ctx = await getContext();
646
- }
647
- catch (e) {
648
- return { content: [{ type: "text", text: e.message }] };
649
- }
650
- try {
651
- const result = await pruneStaleComponents(ctx.config, ctx.snapshot);
652
- if (result.found === 0) {
653
- return { content: [{ type: "text", text: "No stale artifacts to prune." }] };
654
- }
655
- return {
656
- content: [{
657
- type: "text",
658
- text: JSON.stringify({ found: result.found, deleted: result.deleted, skipped: result.skipped, errors: result.errors }, null, 2),
659
- }],
660
- };
661
- }
662
- catch (e) {
663
- return { content: [{ type: "text", text: `run_prune failed: ${e.message}` }] };
664
- }
665
- });
666
631
  server.tool("document_component", "Generate MDX docs and/or Storybook stories for a component. Pass '.' to document all. The docs tool is read from ptb.config.json (docs.tool). Equivalent to 'ptb document component <name>'.", {
667
632
  name: z.string().describe("Component name or '.' for all"),
668
633
  format: z.enum(["mdx", "stories", "both"]).optional().describe("Output format. Default: mdx"),
@@ -726,7 +691,7 @@ recursively when you call it for that dependency.`, { name: z.string().describe(
726
691
  const componentFile = path.join(baseOutputDir, `${codeName}.${ext}`);
727
692
  const componentExists = await fs.access(componentFile).then(() => true).catch(() => false);
728
693
  if (!componentExists) {
729
- skipped.push(`${codeName}.stories.tsx (${codeName}.${ext} not found — run generate_components first)`);
694
+ skipped.push(`${codeName}.stories.tsx (${codeName}.${ext} not found — run run({ stage:'generate' }) first)`);
730
695
  continue;
731
696
  }
732
697
  const storiesContent = generateComponentStories(context, codeName, fileKey);