@gakim-digital/dexter-bridge 0.13.3 → 0.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "description": "Local bridge for InstaWebAI and Dexter — runs Codex, Claude Code, or OpenCode on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -140,6 +140,13 @@ function hasContent(value) {
140
140
  // descendant cannot satisfy a claim about its parent. A dotted name such as
141
141
  // `link.href` or `hoverEffect.opacity` reads the field inside the property's value.
142
142
  function propertyValues(node, property) {
143
+ // Child layers are named by id, name, type, and text, not their settings.
144
+ if (property === 'children') {
145
+ const children = Array.isArray(node.children) ? node.children.filter(isRecord) : [];
146
+ return children.length
147
+ ? [children.map((child) => ({ id: child.id, name: child.name ?? null, type: child.type, ...(child.text ? { text: child.text } : {}) }))]
148
+ : [];
149
+ }
143
150
  const values = keyValues(node, property);
144
151
  const [head, ...path] = property.split('.');
145
152
  if (values.length > 0 || path.length === 0) return values;
@@ -305,27 +312,31 @@ export function summarizeRequirements(requirements, checkResults) {
305
312
  }
306
313
 
307
314
  // Compares two snapshots of layers that had to stay as they were. A snapshot
308
- // maps a layer id to { name, rect, props } where props is the serialized
309
- // layer without children and rect is null when Dexter could not measure the
310
- // layer, so only its settings are compared. Boxes are compared only when both
311
- // readings came from the same source (the user's editor or Framer MCP).
312
- export function unchangedViolations(before, after, tolerance = 1) {
315
+ // maps a layer id to { name, origin, rect, props } where props is the
316
+ // serialized layer without children, origin is the layer a breakpoint or
317
+ // variant copy follows, and rect is null when Dexter could not measure the
318
+ // layer, so only its settings are compared. Sizes are compared only when both
319
+ // readings came from the same source (the user's editor or Framer MCP), and
320
+ // only size counts, not position: a section that grows moves every section
321
+ // below it.
322
+ // A change is edited when the run edited the layer, or the layer it follows;
323
+ // any other change happened without the model touching the layer.
324
+ export function unchangedViolations(before, after, edited = new Set(), tolerance = 1) {
313
325
  const changes = [];
314
326
  for (const [id, earlier] of Object.entries(before || {})) {
315
327
  const later = after?.[id];
316
328
  const name = earlier.name || id;
317
- if (!later) {
318
- changes.push(`${name} was removed`);
319
- continue;
320
- }
329
+ const source = edited.has(id) ? 'self' : earlier.origin && edited.has(earlier.origin) ? 'origin' : null;
330
+ const note = source === 'self' ? ' (you edited it)' : source === 'origin' ? ` (it follows your edit of ${earlier.origin})` : '';
321
331
  const a = earlier.rect;
322
- const b = later.rect;
332
+ const b = later?.rect;
323
333
  const comparable = a && b && (a.source || null) === (b.source || null);
324
- if (comparable && ['x', 'y', 'width', 'height'].some((key) => Math.abs(a[key] - b[key]) > tolerance)) {
325
- changes.push(`${name} moved or resized (${a.width}×${a.height} at ${a.x},${a.y} → ${b.width}×${b.height} at ${b.x},${b.y})`);
326
- } else if (earlier.props !== later.props) {
327
- changes.push(`${name} has different settings`);
328
- }
334
+ let text = null;
335
+ if (!later) text = `${name} was removed`;
336
+ else if (comparable && ['width', 'height'].some((key) => Math.abs(a[key] - b[key]) > tolerance)) {
337
+ text = `${name} changed size (${a.width}×${a.height} → ${b.width}×${b.height})`;
338
+ } else if (earlier.props !== later.props) text = `${name} has different settings`;
339
+ if (text) changes.push({ text: `${text}${note}`, edited: Boolean(source) });
329
340
  if (changes.length >= 20) break;
330
341
  }
331
342
  return changes;
@@ -18,6 +18,7 @@ export const PROPERTY_ERROR_CHECK = 'Unapplied properties';
18
18
  export const CLAIMED_TARGET_CHECK = 'Claimed targets';
19
19
  export const VERIFICATION_CHECK = 'Harness-owned final verification';
20
20
  export const REQUIREMENTS_CHECK = 'Requirements';
21
+ export const UNCHECKED_REQUIREMENTS_CHECK = 'Requirements not re-checked';
21
22
  export const UNCHANGED_CHECK = 'Unchanged layers';
22
23
 
23
24
  const LITERAL_TOKEN_TYPES = new Set(['StringLiteral', 'NoSubstitutionTemplate', 'TemplateHead', 'TemplateMiddle', 'TemplateTail']);
@@ -54,6 +55,9 @@ const UNCHANGED_LAYER_LIMIT = 600;
54
55
  const FONT_KEYS = new Set(['font', 'fontFamily', 'family']);
55
56
  const PROJECT_MISMATCH_PATTERN = /permission|forbidden|not found|does not have access|no access|can't open|cannot open/i;
56
57
 
58
+ // Inside Codex's exec, a tool's images arrive as text after the JSON and
59
+ // stay unseen unless passed to image(); models were reading them as noise.
60
+ const IMAGE_RESULT_NOTE = 'Screenshots come back as images after the JSON result, and screenshotsAttached says how many. When you call this tool from exec, the result is a string: the JSON on the first line, then one data:image line per screenshot. Pass each data:image line to image() to see it; text() shows it only as unreadable characters.';
57
61
  const MEASURE_TOOL_NOTE = 'For exact spacing or sizes, await dexter.measure(ids) returns each layer\'s x, y, width, and height inside its breakpoint frame, and await dexter.gap(a, b) returns the vertical and horizontal space between two layers (negative when they share that range) and whether they overlap. Both measure the rendered layout, including edits made earlier in the same program. Framer lays out only some pages for Dexter; elsewhere a layer comes back with measured: false, and then there are no numbers to act on, so judge it from a screenshot.';
58
62
 
59
63
  // Item shapes the requirement contract (framerRequirementContract.js) accepts.
@@ -72,7 +76,7 @@ const CHECK_SCHEMA = {
72
76
  properties: {
73
77
  requirementId: { type: 'string' },
74
78
  nodeId: { type: 'string' },
75
- property: { type: 'string', description: 'A Framer property to read, such as fill, link.href, or text. Use either property or measure.' },
79
+ property: { type: 'string', description: 'A Framer property to read, such as fill, link.href, or text; children lists the child layers of the layer by name. Use either property or measure.' },
76
80
  contains: { type: 'string', description: 'Text the property value must contain, such as #151515 or /contact.' },
77
81
  absent: { type: 'boolean', description: 'True when the requirement is that the property is not set on the layer, such as no appearEffect on a background image.' },
78
82
  measure: { type: 'string', enum: MEASURES, description: 'width, height, gap (to otherNodeId), or insideParent (the layer stays inside its parent).' },
@@ -103,7 +107,7 @@ const FRAMER_MEASURE_DEFINITION = {
103
107
  const TOOL_DEFINITIONS = [
104
108
  {
105
109
  name: 'framer_read',
106
- description: `Read the open Framer project by running a program. Use api.tree.getNode, getNodes, serialize, and serializeNodes. Stock image search is api.images.search({ query }), one call per query. Fonts are api.fonts.get and api.fonts.list({ query }). There is no framer global. ${MEASURE_TOOL_NOTE} This tool cannot change the project.`,
110
+ description: `Read the open Framer project by running a program. Use api.tree.getNode, getNodes, serialize, and serializeNodes. Stock image search is api.images.search({ query }), one call per query. Fonts are api.fonts.get and api.fonts.list({ query }). There is no framer global. ${MEASURE_TOOL_NOTE} ${IMAGE_RESULT_NOTE} This tool cannot change the project.`,
107
111
  inputSchema: {
108
112
  type: 'object',
109
113
  properties: {
@@ -115,7 +119,7 @@ const TOOL_DEFINITIONS = [
115
119
  },
116
120
  {
117
121
  name: 'framer_edit',
118
- description: `Change the open Framer project by running a program. Use api.tree.insert, patch, remove, move, and replaceText. Place an SVG with tree.insert("SVGNode", id, { svg, intrinsicWidth, intrinsicHeight }). Write code files with api.files.create, write, or applyPatch. Search stock images with api.images.search({ query }). Place an image by setting fill to await api.tree.assetReference(url) with a public image URL. Load fonts with api.fonts. Pass screenshotNodeIds for the nodes you want to see after the edit. Include plan on your first framer_edit. ${MEASURE_TOOL_NOTE}`,
122
+ description: `Change the open Framer project by running a program. Use api.tree.insert, patch, remove, move, and replaceText. Place an SVG with tree.insert("SVGNode", id, { svg, intrinsicWidth, intrinsicHeight }). Write code files with api.files.create, write, or applyPatch. Search stock images with api.images.search({ query }). Place an image by setting fill to await api.tree.assetReference(url) with a public image URL. Load fonts with api.fonts. Pass screenshotNodeIds for the nodes you want to see after the edit. Include plan on your first framer_edit. ${MEASURE_TOOL_NOTE} ${IMAGE_RESULT_NOTE}`,
119
123
  inputSchema: {
120
124
  type: 'object',
121
125
  properties: {
@@ -153,7 +157,7 @@ const TOOL_DEFINITIONS = [
153
157
  },
154
158
  {
155
159
  name: 'framer_view_image',
156
- description: 'Show one or more image URLs to yourself so you can judge them. Use public attachment URLs and image URLs already in the project.',
160
+ description: `Show one or more image URLs to yourself so you can judge them. Use public attachment URLs and image URLs already in the project. ${IMAGE_RESULT_NOTE}`,
157
161
  inputSchema: {
158
162
  type: 'object',
159
163
  properties: {
@@ -178,7 +182,7 @@ const TOOL_DEFINITIONS = [
178
182
  },
179
183
  {
180
184
  name: 'framer_read_website',
181
- description: 'Read one page of a website the user named: exact copy per section, fonts and text styles, colours, image URLs (including CSS backgrounds), and desktop and phone screenshots. Use it before recreating or migrating that page. Only sites the user named can be read.',
185
+ description: `Read one page of a website the user named: exact copy per section, fonts and text styles, colours, image URLs (including CSS backgrounds), and desktop and phone screenshots. Use it before recreating or migrating that page. Only sites the user named can be read. ${IMAGE_RESULT_NOTE}`,
182
186
  inputSchema: {
183
187
  type: 'object',
184
188
  properties: {
@@ -785,10 +789,15 @@ function helpersUsed(program) {
785
789
 
786
790
  // Reads what framer_verify needs in one program: the layers whose properties
787
791
  // are checked, and each measure in order.
788
- export function verifyProgram(propertyNodeIds, measures) {
792
+ // Layers whose checks name children are read one level deep so their
793
+ // child layers can be checked.
794
+ export function verifyProgram(propertyNodeIds, measures, childNodeIds = []) {
789
795
  return `${MEASURE_PRELUDE}
790
796
  const tree = api.tree;
791
- const nodes = await tree.serializeNodes(${JSON.stringify(propertyNodeIds)}, { depth: 0 });
797
+ const withChildren = new Set(${JSON.stringify(childNodeIds)});
798
+ const flat = await tree.serializeNodes(${JSON.stringify(propertyNodeIds)}.filter((id) => !withChildren.has(id)), { depth: 0 });
799
+ const nested = withChildren.size ? await tree.serializeNodes([...withChildren], { depth: 1 }) : [];
800
+ const nodes = [...(flat || []), ...(nested || [])];
792
801
  const measured = [];
793
802
  for (const check of ${JSON.stringify(measures)}) {
794
803
  try {
@@ -820,16 +829,44 @@ return { nodes, measured };`;
820
829
  }
821
830
 
822
831
  // Records layers the user wants left as they are: each layer's measured box,
823
- // when Framer has laid it out, and a fingerprint of its settings, for every
824
- // layer inside them.
825
- export function snapshotProgram(rootIds) {
832
+ // when Framer has laid it out, a fingerprint of its settings, and the layer a
833
+ // breakpoint or variant copy follows, for every layer inside them. The read
834
+ // after the run passes walk: false to read back exactly the recorded layers.
835
+ // Layers name colour styles only as var(--token-id). Reads carry the style's
836
+ // name and values next to them, so a model can tell a dark page colour from
837
+ // a light surface without guessing from the id.
838
+ const COLOR_TOKEN_PATTERN = /var\(--token-([A-Za-z0-9_-]+)\)/g;
839
+ const COLOR_STYLE_LIMIT = 40;
840
+
841
+ export function colorTokensIn(value) {
842
+ const text = JSON.stringify(value) || '';
843
+ return [...new Set([...text.matchAll(COLOR_TOKEN_PATTERN)].map((match) => match[1]))].slice(0, COLOR_STYLE_LIMIT);
844
+ }
845
+
846
+ export function colorStylesProgram(ids) {
847
+ return `const out = {};
848
+ for (const id of ${JSON.stringify(ids)}) {
849
+ const node = api.tree.get(id);
850
+ if (node && node.type === 'ColorStyleTokenNode') out[id] = { name: node.name || null, light: node.light || null, dark: node.dark || null };
851
+ }
852
+ return out;`;
853
+ }
854
+
855
+ export function describeColorStyle(style) {
856
+ const values = [style?.light && `light ${style.light}`, style?.dark && `dark ${style.dark}`].filter(Boolean).join(', ');
857
+ return `${style?.name || 'Unnamed colour style'}${values ? ` (${values})` : ''}`;
858
+ }
859
+
860
+ export function snapshotProgram(rootIds, { walk = true } = {}) {
826
861
  return `${LAYOUT_CHECK}
827
862
  const tree = api.tree;
828
863
  const fingerprint = (text) => { let hash = 2166136261; for (let i = 0; i < text.length; i += 1) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619); } return (hash >>> 0).toString(36); };
829
864
  const ids = new Set();
830
865
  for (const root of ${JSON.stringify(rootIds)}) {
866
+ if (${walk ? 'true' : 'false'} && ids.size >= ${UNCHANGED_LAYER_LIMIT}) break;
831
867
  if (!tree.get(root)) continue;
832
868
  ids.add(root);
869
+ if (!${walk ? 'true' : 'false'}) continue;
833
870
  for (const node of tree.walk(root)) {
834
871
  if (ids.size >= ${UNCHANGED_LAYER_LIMIT}) break;
835
872
  if (node && node.id) ids.add(node.id);
@@ -842,7 +879,7 @@ for (const id of ids) {
842
879
  const rect = (await __dexterLaidOut(id)) ? await __dexterRect(id) : null;
843
880
  let props = '';
844
881
  try { const { children, ...rest } = JSON.parse(JSON.stringify(node)); props = fingerprint(JSON.stringify(rest)); } catch {}
845
- out[id] = { name: node.name || null, rect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null, props };
882
+ out[id] = { name: node.name || null, origin: node.$originalId || null, rect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null, props };
846
883
  }
847
884
  return out;`;
848
885
  }
@@ -894,6 +931,8 @@ export function createFramerAgentToolRuntime({
894
931
  const unchangedBefore = {};
895
932
  let unchangedPending = [];
896
933
  let unchangedChanges = [];
934
+ const weighedPropertyErrors = new Set();
935
+ const colorStyles = new Map();
897
936
  let promptContext = {};
898
937
  // Set once the panel fails to answer, so a closed panel does not cost a
899
938
  // wait on every later measure.
@@ -944,6 +983,10 @@ export function createFramerAgentToolRuntime({
944
983
 
945
984
  const client = mcpClient || createFramerMcpClient({ getAccessToken: tokenGetter });
946
985
 
986
+ function propertyErrorKey(error) {
987
+ return `${error.nodeId}.${error.property}`;
988
+ }
989
+
947
990
  function lingeringPropertyErrors() {
948
991
  let open = [];
949
992
  for (const receipt of mutationReceipts) {
@@ -1151,8 +1194,8 @@ export function createFramerAgentToolRuntime({
1151
1194
  // Records the layers the plan said to leave as they are. Boxes come from the
1152
1195
  // editor where it measured them, otherwise from Framer MCP where that page is
1153
1196
  // laid out; each records its source so before and after compare like with like.
1154
- async function snapshotLayers(roots) {
1155
- const snapshot = outputOf(await client.exec(snapshotProgram(roots))).result;
1197
+ async function snapshotLayers(roots, options) {
1198
+ const snapshot = outputOf(await client.exec(snapshotProgram(roots, options))).result;
1156
1199
  if (!snapshot || typeof snapshot !== 'object') return null;
1157
1200
  for (const entry of Object.values(snapshot)) if (entry?.rect) entry.rect.source = 'mcp';
1158
1201
  const layers = await measureInEditor(Object.keys(snapshot));
@@ -1387,7 +1430,8 @@ export function createFramerAgentToolRuntime({
1387
1430
  const measures = measureChecks(checks);
1388
1431
  const layers = await measureInEditor(measures.flatMap((check) => [check.nodeId, check.otherNodeId]));
1389
1432
  const fromEditor = measures.map((check) => editorMeasurement(check, layers));
1390
- const result = await client.exec(verifyProgram(checkNodeIds(checks), measures.filter((_, index) => !fromEditor[index])));
1433
+ const childNodeIds = [...new Set(checks.filter((check) => check.property === 'children').map((check) => check.nodeId))];
1434
+ const result = await client.exec(verifyProgram(checkNodeIds(checks), measures.filter((_, index) => !fromEditor[index]), childNodeIds));
1391
1435
  const output = outputOf(result);
1392
1436
  if (output.error) throw toolError('FRAMER_PROGRAM_FAILED', String(output.error).split('\n')[0].slice(0, 300));
1393
1437
  const { nodes, measured: fromMcp } = output.result && typeof output.result === 'object' ? output.result : {};
@@ -1419,6 +1463,10 @@ export function createFramerAgentToolRuntime({
1419
1463
  } else {
1420
1464
  throw toolError('FRAMER_AGENT_TOOL_UNKNOWN', `Unknown Framer tool ${name}.`);
1421
1465
  }
1466
+ if (name === 'framer_edit') colorStyles.clear();
1467
+ if (name === 'framer_read' || name === 'framer_edit' || name === 'framer_verify') {
1468
+ payload = await withColorStyles(payload);
1469
+ }
1422
1470
  recordCall(name, category, 'succeeded', { ...callExtra, durationMs: Date.now() - startedAt });
1423
1471
  noteFramerAnswer(null);
1424
1472
  const nudge = category === 'read' ? firstEditNudge() : null;
@@ -1426,7 +1474,7 @@ export function createFramerAgentToolRuntime({
1426
1474
  if (Array.isArray(payload?.modelImages)) {
1427
1475
  const { modelImages, ...rest } = payload;
1428
1476
  const ready = await modelReadyImages(modelImages);
1429
- payload = ready.length ? { ...rest, modelImages: ready } : rest;
1477
+ payload = ready.length ? { ...rest, screenshotsAttached: ready.length, modelImages: ready } : rest;
1430
1478
  }
1431
1479
  reportActivity({
1432
1480
  kind: 'tool_active',
@@ -1455,6 +1503,25 @@ export function createFramerAgentToolRuntime({
1455
1503
  }
1456
1504
  }
1457
1505
 
1506
+ async function withColorStyles(payload) {
1507
+ const { modelImages, ...rest } = payload || {};
1508
+ const ids = colorTokensIn(rest);
1509
+ if (!ids.length) return payload;
1510
+ const unknown = ids.filter((id) => !colorStyles.has(id));
1511
+ if (unknown.length) {
1512
+ try {
1513
+ const found = outputOf(await client.exec(colorStylesProgram(unknown))).result || {};
1514
+ for (const id of unknown) colorStyles.set(id, found[id] || null);
1515
+ } catch {
1516
+ return payload;
1517
+ }
1518
+ }
1519
+ const named = Object.fromEntries(ids
1520
+ .filter((id) => colorStyles.get(id))
1521
+ .map((id) => [`var(--token-${id})`, describeColorStyle(colorStyles.get(id))]));
1522
+ return Object.keys(named).length ? { ...payload, colorStyles: named } : payload;
1523
+ }
1524
+
1458
1525
  function invoke(name, args = {}) {
1459
1526
  const operation = serial.then(() => invokeUnsafe(name, args));
1460
1527
  serial = operation.catch(() => undefined);
@@ -1529,16 +1596,28 @@ export function createFramerAgentToolRuntime({
1529
1596
  return { content: guidance.content, inventory: guidance.inventory };
1530
1597
  }
1531
1598
 
1599
+ // Requirements a read after the last change found not met.
1532
1600
  function unmetRequirements() {
1533
1601
  if (!requirements.length || mutationCalls === 0) return [];
1534
1602
  return requirementStatus().requirements
1535
- .filter((item) => !item.checked || (item.verdict !== 'verified' && item.verdict !== 'unmeasured'))
1603
+ .filter((item) => item.checked && item.verdict !== 'verified' && item.verdict !== 'unmeasured')
1536
1604
  .map((item) => ({
1537
1605
  id: item.requirementId,
1538
1606
  text: item.text,
1539
- reason: item.contradictions[0]
1540
- || item.unknowns[0]
1541
- || (item.uncheckedNodeIds.length ? `not checked on ${item.uncheckedNodeIds.join(', ')}` : 'not checked after the last change'),
1607
+ reason: item.contradictions[0] || item.unknowns[0] || 'not met',
1608
+ }));
1609
+ }
1610
+
1611
+ // Requirements nothing has read since the last change: not known to be
1612
+ // wrong, only not confirmed.
1613
+ function uncheckedRequirements() {
1614
+ if (!requirements.length || mutationCalls === 0) return [];
1615
+ return requirementStatus().requirements
1616
+ .filter((item) => !item.checked)
1617
+ .map((item) => ({
1618
+ id: item.requirementId,
1619
+ text: item.text,
1620
+ nodeIds: item.uncheckedNodeIds,
1542
1621
  }));
1543
1622
  }
1544
1623
 
@@ -1550,6 +1629,7 @@ export function createFramerAgentToolRuntime({
1550
1629
  const missing = verification().missingChecks;
1551
1630
  const propertyErrors = lingeringPropertyErrors();
1552
1631
  const facts = [];
1632
+ for (const error of propertyErrors) weighedPropertyErrors.add(propertyErrorKey(error));
1553
1633
  if (propertyErrors.length) {
1554
1634
  facts.push(`Framer rejected these properties, so they are not set: ${propertyErrors.map((error) => `${error.nodeId}.${error.property}: ${error.message}`).join('; ')}.`);
1555
1635
  }
@@ -1566,8 +1646,12 @@ export function createFramerAgentToolRuntime({
1566
1646
  if (unmet.length) {
1567
1647
  facts.push(`Framer shows these requirements from your plan as not met: ${unmet.map((item) => `${item.text} (${item.reason})`).join('; ')}.`);
1568
1648
  }
1649
+ const unchecked = uncheckedRequirements();
1650
+ if (unchecked.length) {
1651
+ facts.push(`These requirements from your plan have not been checked since your last change, so they are not confirmed either way: ${unchecked.map((item) => item.text).join('; ')}.`);
1652
+ }
1569
1653
  if (unchangedChanges.length) {
1570
- facts.push(`These layers you planned to leave as they were now read differently: ${unchangedChanges.join('; ')}.`);
1654
+ facts.push(`These layers you planned to leave as they were now read differently: ${unchangedChanges.map((change) => change.text).join('; ')}.`);
1571
1655
  }
1572
1656
  if (!facts.length) return null;
1573
1657
  return [
@@ -1631,8 +1715,8 @@ export function createFramerAgentToolRuntime({
1631
1715
  if (mutationCalls > 0 && Object.keys(unchangedBefore).length) {
1632
1716
  try {
1633
1717
  await ensureProject();
1634
- const after = await snapshotLayers(Object.keys(unchangedBefore).slice(0, UNCHANGED_LAYER_LIMIT));
1635
- unchangedChanges = after ? unchangedViolations(unchangedBefore, after) : [];
1718
+ const after = await snapshotLayers(Object.keys(unchangedBefore), { walk: false });
1719
+ unchangedChanges = after ? unchangedViolations(unchangedBefore, after, known) : [];
1636
1720
  readCalls += 1;
1637
1721
  } catch {
1638
1722
  unchangedChanges = [];
@@ -1670,7 +1754,9 @@ export function createFramerAgentToolRuntime({
1670
1754
  output: 'No layer changed for a summary that claimed a change.',
1671
1755
  });
1672
1756
  }
1673
- const propertyErrors = lingeringPropertyErrors();
1757
+ // A rejected property the model was shown and then finished without is
1758
+ // its judgment, such as reaching the goal another way.
1759
+ const propertyErrors = lingeringPropertyErrors().filter((error) => !weighedPropertyErrors.has(propertyErrorKey(error)));
1674
1760
  if (propertyErrors.length) {
1675
1761
  checks.push({
1676
1762
  command: PROPERTY_ERROR_CHECK,
@@ -1694,11 +1780,25 @@ export function createFramerAgentToolRuntime({
1694
1780
  output: unmet.map((item) => `${item.id}: ${item.reason}`).join('; ').slice(0, 2000),
1695
1781
  });
1696
1782
  }
1697
- if (unchangedChanges.length) {
1783
+ const unchecked = uncheckedRequirements();
1784
+ if (unchecked.length) {
1785
+ checks.push({
1786
+ command: UNCHECKED_REQUIREMENTS_CHECK,
1787
+ status: 'failed',
1788
+ output: unchecked
1789
+ .map((item) => (item.nodeIds.length ? `${item.id}: not checked on ${item.nodeIds.join(', ')}` : `${item.id}: not checked after the last change`))
1790
+ .join('; ')
1791
+ .slice(0, 2000),
1792
+ });
1793
+ }
1794
+ // A layer the model edited, or one that follows its edit, is a change it
1795
+ // made and weighed; only changes nothing in the run explains fail it.
1796
+ const unexplained = unchangedChanges.filter((change) => !change.edited);
1797
+ if (unexplained.length) {
1698
1798
  checks.push({
1699
1799
  command: UNCHANGED_CHECK,
1700
1800
  status: 'failed',
1701
- output: unchangedChanges.join('; ').slice(0, 2000),
1801
+ output: unexplained.map((change) => change.text).join('; ').slice(0, 2000),
1702
1802
  });
1703
1803
  }
1704
1804
  // The model's own plan sets the evidence it owes; a run that changed the
package/src/protocol.js CHANGED
@@ -510,6 +510,7 @@ export function buildFramerOutcomePrompt(outcome = {}, product, promptContext =
510
510
  'Set projectChanged to true only when this run applied a change to the Framer project, and list the ids of the layers you changed in changedNodeIds. The summary must describe only changes that were applied. If a requirement is still not met, or you could not find or change something, name it plainly in the summary; never describe it as done.',
511
511
  'Some requests need something beyond the layers you may change, such as photos on other pages, a component shared across the site, or site-wide styles. If the user has not decided about it yet, ask one plain question that names it and what changing it affects, and return blocked; when they agree, Dexter gives you that reach in the next run. If they already said no, or told you what to do in that case, do not ask again: do what you can within what you may change, and explain plainly what is in the way, such as which shared setting causes it. Never mention access, scope, permissions, grants, or authorization.',
512
512
  'If you changed layers before asking a question, say in your summary what you changed.',
513
+ 'When you return blocked, write the question itself in summary, since summary is what the user reads; blockedReason is a short note for Dexter.',
513
514
  'Write in the language the user wrote their request in: the summary, any question, and the sentences Dexter shows while you work. Keep layer names and quoted text as they are.',
514
515
  REPAIR_SUMMARY_RULE,
515
516
  'When finished, return exactly one JSON object and no markdown.',
@@ -524,7 +525,7 @@ export function buildFramerOutcomePrompt(outcome = {}, product, promptContext =
524
525
  const task = [
525
526
  ...(selectionFirst
526
527
  ? [
527
- 'This is a selection-first run. Start from the selected nodes and keep every mutation inside the authorized write scope.',
528
+ 'This is a selection-first run. Start from the selected nodes: they show what the user is pointing at. When the user\'s words name other layers on this page, or the setting they asked about sits on a parent of the selection, change that too, within the reach in the assignment.',
528
529
  'Use a focused live Framer read of the selected nodes and their descendants before editing when you need current details.',
529
530
  'Prefer focused inspection. Expand beyond the selected subtree only when a concrete dependency requires it.',
530
531
  'Batch related canvas edits when practical, but make as many repair batches as the verified result genuinely requires.',
@@ -132,7 +132,7 @@ function codexOutcomeInstructions(product, { platform = null } = {}) {
132
132
  return [
133
133
  `You are an autonomous Framer harness embedded inside ${name}.`,
134
134
  'Work directly in the connected Framer project through Framer MCP. Programs use api.tree. There is no framer global.',
135
- 'When selected nodes are supplied, start with them, reuse their supplied snapshot, and keep mutations inside the authorized write scope.',
135
+ 'When selected nodes are supplied, start with them and reuse their supplied snapshot. They show what the user is pointing at; the user\'s words decide what to change, within the reach in the assignment.',
136
136
  'Prefer focused inspection and batched edits, but use as many reads, repairs, and verification passes as the work genuinely requires.',
137
137
  'You own the full Framer loop: inspect, edit, read diagnostics, verify the actual result, repair concrete failures, and decide when the request is complete.',
138
138
  'Avoid repeated inspection of unchanged evidence and avoid broad project inspection unless a concrete dependency requires it.',