shotops-mcp 0.9.6 → 0.9.7

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/dist/local.js CHANGED
@@ -24,7 +24,7 @@
24
24
  }
25
25
  await visit('')
26
26
  return hash.digest('hex')
27
- })(sourceDirectory) !== "5df467e22a054acf0779df03013843f6f55d0a3e8cb9f87ecca7dca8eef02f46") {
27
+ })(sourceDirectory) !== "1b56a11440edcc23083bfa4a283fe2d972acfb3a17820247bc951bcd5cc3810d") {
28
28
  console.error('shotops-mcp: dist/local.js is stale; run npm run build in shotops-mcp.');
29
29
  }
30
30
  } catch { /* An unreadable checkout must not break an otherwise working CLI. */ }
@@ -2208,7 +2208,7 @@ var init_screenVariants = __esm({
2208
2208
  });
2209
2209
 
2210
2210
  // ../mockup-engine/appstore/catalog/layouts.ts
2211
- var LAYOUT_TEMPLATE_CATALOG, LAYOUT_TEMPLATES, TEMPLATE_IDS, LAYOUT_TEMPLATE_IDS, LAYOUT_POSE_FIELDS, LAYOUT_CAMERA_FIELDS, LAYOUT_CAPTION_FIELDS;
2211
+ var LAYOUT_TEMPLATE_CATALOG, LAYOUT_TEMPLATES, TEMPLATE_IDS, LAYOUT_TEMPLATE_IDS, LAYOUT_CAPTION_ABSENT_DEFAULTS, LAYOUT_POSE_FIELDS, LAYOUT_CAMERA_FIELDS, LAYOUT_CAPTION_FIELDS;
2212
2212
  var init_layouts = __esm({
2213
2213
  "../mockup-engine/appstore/catalog/layouts.ts"() {
2214
2214
  "use strict";
@@ -2218,28 +2218,78 @@ var init_layouts = __esm({
2218
2218
  label: "Standard",
2219
2219
  blurb: "Headline on top, whole device below it, nothing cropped. The safe default.",
2220
2220
  character: "safe",
2221
+ bleedEdge: "none",
2221
2222
  shotLook: { phoneHeight: "82", hOffset: "0", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2222
- caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 2 }
2223
+ caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 2, align: "center", bandCenter: 0.5 }
2223
2224
  },
2224
2225
  {
2225
2226
  id: "bleed",
2226
2227
  label: "Bleed",
2227
2228
  blurb: "Room for a longer headline, device running off the bottom edge. What the market looks like.",
2228
2229
  character: "conventional",
2230
+ bleedEdge: "bottom",
2229
2231
  shotLook: { phoneHeight: "82", hOffset: "0", vOffset: "38", angle: "front", cameraPos: null, roll: "0" },
2230
- caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 4 }
2232
+ caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 4, align: "center", bandCenter: 0.5 }
2231
2233
  },
2232
2234
  {
2233
2235
  id: "top-bleed",
2234
2236
  label: "Top bleed",
2235
2237
  blurb: "Device running off the TOP edge, headline underneath it. Inverts the usual reading order.",
2236
2238
  character: "expressive",
2239
+ bleedEdge: "top",
2237
2240
  shotLook: { phoneHeight: "82", hOffset: "0", vOffset: "-38", angle: "front", cameraPos: null, roll: "0" },
2238
2241
  // The only entry that moves the band. Its caption sits BELOW the device, which the engine
2239
2242
  // could not express at all until #218 gave placeCaptions a bottom-anchored band — before that
2240
2243
  // the only way down was pinning an anchor, which silently costs reflow, stacking and the
2241
2244
  // legibility advisory. `band: 'bottom'` keeps the anchor ABSENT, so it stays genuinely auto.
2242
- caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 4, band: "bottom" }
2245
+ caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 4, band: "bottom", align: "center", bandCenter: 0.5 }
2246
+ },
2247
+ // ── The side bleeds. Facts 5–7 in the essay above own every number here. ────────────────────
2248
+ //
2249
+ // What they buy that the vertical three cannot: the headline gets a COLUMN instead of a band the
2250
+ // device sits under. Narrowing `maxWidth` to 0.61 and moving `bandCenter` off centre is what
2251
+ // makes that column, and it is why the catalog owns `align`/`bandCenter` at all — see the note
2252
+ // on LayoutCaptionField below for what that costs every other template.
2253
+ {
2254
+ id: "lean-right",
2255
+ label: "Lean right",
2256
+ blurb: "Device pushed off the RIGHT edge, headline in a column down the left. Reads as one continuous swipe.",
2257
+ character: "expressive",
2258
+ bleedEdge: "right",
2259
+ shotLook: { phoneHeight: "82", hOffset: "46", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2260
+ caption: { sizePt: 32, maxWidth: 0.61, reserveLines: 2, align: "left", bandCenter: 0.375 }
2261
+ },
2262
+ {
2263
+ id: "lean-left",
2264
+ label: "Lean left",
2265
+ blurb: "Device pushed off the LEFT edge, headline in a column down the right. The mirror of Lean right.",
2266
+ character: "expressive",
2267
+ bleedEdge: "left",
2268
+ shotLook: { phoneHeight: "82", hOffset: "-46", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2269
+ caption: { sizePt: 32, maxWidth: 0.61, reserveLines: 2, align: "right", bandCenter: 0.625 }
2270
+ },
2271
+ {
2272
+ id: "swipe",
2273
+ label: "Swipe",
2274
+ blurb: "A third of the device carried into the next panel, headline full width above it. Only honest applied to every frame.",
2275
+ character: "expressive",
2276
+ bleedEdge: "right",
2277
+ // Standard's vertical, and that is not a free choice: a 4-line reservation over vOffset 18 puts
2278
+ // the device's top edge through the fourth line, and Bleed's vOffset 38 would crop the bottom
2279
+ // too — which makes this template claim `bottom-right` and collide with Corner bleed, the only
2280
+ // other entry on that edge. The horizontal push is the whole idea here; the vertical stays out
2281
+ // of its way. Both halves are guarded (clearance, and the declared-edge check).
2282
+ shotLook: { phoneHeight: "82", hOffset: "78", vOffset: "18", angle: "front", cameraPos: null, roll: "0" },
2283
+ caption: { sizePt: 32, maxWidth: 0.86, reserveLines: 2, align: "center", bandCenter: 0.5 }
2284
+ },
2285
+ {
2286
+ id: "corner-bleed",
2287
+ label: "Corner bleed",
2288
+ blurb: "Off the bottom AND the right at once, headline in a column down the left. The most opinionated panel here.",
2289
+ character: "expressive",
2290
+ bleedEdge: "bottom-right",
2291
+ shotLook: { phoneHeight: "82", hOffset: "46", vOffset: "38", angle: "front", cameraPos: null, roll: "0" },
2292
+ caption: { sizePt: 32, maxWidth: 0.61, reserveLines: 4, align: "left", bandCenter: 0.375 }
2243
2293
  }
2244
2294
  ];
2245
2295
  LAYOUT_TEMPLATES = LAYOUT_TEMPLATE_CATALOG;
@@ -2248,6 +2298,10 @@ var init_layouts = __esm({
2248
2298
  TEMPLATE_IDS[0],
2249
2299
  ...TEMPLATE_IDS.slice(1)
2250
2300
  ];
2301
+ LAYOUT_CAPTION_ABSENT_DEFAULTS = {
2302
+ align: "center",
2303
+ bandCenter: 0.5
2304
+ };
2251
2305
  LAYOUT_POSE_FIELDS = ["phoneHeight", "hOffset", "vOffset"];
2252
2306
  LAYOUT_CAMERA_FIELDS = ["angle", "cameraPos", "roll"];
2253
2307
  LAYOUT_CAPTION_FIELDS = Array.from(
@@ -2259,8 +2313,8 @@ var init_layouts = __esm({
2259
2313
  // ../mockup-engine/appstore/layoutTemplates.ts
2260
2314
  function layoutTemplateAgentSummary(template) {
2261
2315
  const reserveLines = template.caption.reserveLines;
2262
- const capacity = typeof reserveLines === "number" ? ` Reserves ${reserveLines} headline lines (~${reserveLines * 18} characters).` : "";
2263
- return `${template.blurb}${capacity} Character: [${template.character}].`;
2316
+ const capacity = typeof reserveLines === "number" ? ` Reserves ${reserveLines} headline lines (~${Math.round(reserveLines * 18 * (template.caption.maxWidth ?? 0.86) / 0.86)} characters).` : "";
2317
+ return `${template.blurb}${capacity} Character: [${template.character}]. Bleeds: [${template.bleedEdge}].`;
2264
2318
  }
2265
2319
  function layoutTemplateById(id) {
2266
2320
  return LAYOUT_TEMPLATES.find((t) => t.id === id);
@@ -2280,8 +2334,9 @@ function differencesFrom(template, shotLook2, captionStyle) {
2280
2334
  }
2281
2335
  if (captionStyle) {
2282
2336
  for (const field of LAYOUT_CAPTION_FIELDS) {
2283
- const actual = captionStyle[field] ?? void 0;
2284
- const wanted = template.caption[field] ?? void 0;
2337
+ const fallback = LAYOUT_CAPTION_ABSENT_DEFAULTS[field];
2338
+ const actual = captionStyle[field] ?? fallback;
2339
+ const wanted = template.caption[field] ?? fallback;
2285
2340
  if (actual !== wanted) differs.push({ field, actual, template: wanted });
2286
2341
  }
2287
2342
  }
@@ -2326,7 +2381,7 @@ function layoutTemplateToLook(template) {
2326
2381
  }
2327
2382
  return { shotLook: { ...template.shotLook }, caption };
2328
2383
  }
2329
- var LAYOUT_CHARACTER_ANSWERS, LAYOUT_CHARACTER_IDS, LAYOUT_SELECTION_CAVEATS, valuesEqual;
2384
+ var LAYOUT_CHARACTER_ANSWERS, LAYOUT_CHARACTER_IDS, LAYOUT_BLEED_EDGE_WORDS, LAYOUT_BLEED_EDGE_IDS, LAYOUT_SELECTION_CAVEATS, valuesEqual;
2330
2385
  var init_layoutTemplates = __esm({
2331
2386
  "../mockup-engine/appstore/layoutTemplates.ts"() {
2332
2387
  "use strict";
@@ -2338,9 +2393,20 @@ var init_layoutTemplates = __esm({
2338
2393
  expressive: "the request explicitly asks to stand out \u2014 unique, creative, bold, striking, different, surprising"
2339
2394
  };
2340
2395
  LAYOUT_CHARACTER_IDS = Object.keys(LAYOUT_CHARACTER_ANSWERS);
2396
+ LAYOUT_BLEED_EDGE_WORDS = {
2397
+ none: "nothing about cropping or edges was asked for",
2398
+ top: "the phone should run off the TOP, or the headline should sit underneath it",
2399
+ bottom: "the phone should run off the BOTTOM \u2014 the ordinary store crop",
2400
+ left: "the phone should sit to the LEFT, or run off the left edge",
2401
+ right: "the phone should sit to the RIGHT, run off the right edge, or carry into the next screenshot",
2402
+ "bottom-right": "the phone should run off TWO edges at once, or the request asks for the boldest possible crop"
2403
+ };
2404
+ LAYOUT_BLEED_EDGE_IDS = Object.keys(LAYOUT_BLEED_EDGE_WORDS);
2341
2405
  LAYOUT_SELECTION_CAVEATS = [
2342
2406
  'A request that names a composition outright ("device running off the bottom edge", "headline under the phone") beats the character.',
2343
- "When several templates share the right character, take the one whose reserved headline lines fit the LONGEST caption in the set. Under-reserving truncates; over-reserving leaves a visible empty band."
2407
+ "The EDGE beats the character too, and is checked first: a request that says which way the phone should go has already chosen, whatever quality words sit around it.",
2408
+ "When several templates share the right character AND the right edge, take the one whose reserved headline lines fit the LONGEST caption in the set. Under-reserving truncates; over-reserving leaves a visible empty band.",
2409
+ "A side bleed is chosen for the WHOLE set or not at all. Every panel leaning the same way reads as one swipe; mirrored neighbours (lean-right beside lean-left) overlap and read as a mistake."
2344
2410
  ];
2345
2411
  valuesEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
2346
2412
  }
@@ -6162,6 +6228,17 @@ function matchAuthoredLocale(locales, requested) {
6162
6228
  });
6163
6229
  return matches.length === 1 ? matches[0] : null;
6164
6230
  }
6231
+ function localesWithoutCaptions(captionText, locales, baseLocale = DEFAULT_LOCALE) {
6232
+ return locales.filter((locale2) => {
6233
+ if (locale2 === baseLocale) return false;
6234
+ const inner = captionText[locale2];
6235
+ if (!inner) return true;
6236
+ for (const layers of Object.values(inner)) {
6237
+ if (layers && layers.some((l) => l.text.trim().length > 0)) return false;
6238
+ }
6239
+ return true;
6240
+ });
6241
+ }
6165
6242
  function normalizeLegacyLocales(captionText, locales) {
6166
6243
  const hasLegacy = Object.keys(captionText).some((c) => c in LEGACY_LOCALE_ALIASES) || (locales?.some((c) => c in LEGACY_LOCALE_ALIASES) ?? false);
6167
6244
  if (!hasLegacy) return { captionText, locales };
@@ -7139,7 +7216,7 @@ var init_agentCapabilities = __esm({
7139
7216
  targets: ["frame"],
7140
7217
  requiredInputs: ["frameId"],
7141
7218
  limits: ["The last remaining frame cannot be removed."],
7142
- evaluationCases: ["structure-remove-empty-frame"]
7219
+ evaluationCases: ["structure-remove-empty-frame", "structure-remove-populated-frame"]
7143
7220
  },
7144
7221
  {
7145
7222
  id: "frame.reorder",
@@ -7286,7 +7363,7 @@ var init_agentCapabilities = __esm({
7286
7363
  domain: "caption",
7287
7364
  status: "supported",
7288
7365
  summary: "Restyle captions: font, size, colour, alignment, width and band placement.",
7289
- studioCommands: [],
7366
+ studioCommands: ["patchAllCaptionStyles"],
7290
7367
  wire: "patch.captionStyle (strip-wide) or patch.frames[i].captionStyle (one frame)",
7291
7368
  targets: ["project", "frame"],
7292
7369
  requiredInputs: ["the style fields to change"],
@@ -7330,7 +7407,7 @@ var init_agentCapabilities = __esm({
7330
7407
  domain: "caption",
7331
7408
  status: "supported",
7332
7409
  summary: "Move a caption layer to another frame.",
7333
- studioCommands: ["moveCaptionLayerToFrame"],
7410
+ studioCommands: ["moveCaptionLayer", "moveCaptionLayerToFrame"],
7334
7411
  wire: "structuralOperations: caption.move across frames",
7335
7412
  targets: ["caption", "frame"],
7336
7413
  requiredInputs: ["frameId", "the caption position", "toFrameId"],
@@ -7345,19 +7422,19 @@ var init_agentCapabilities = __esm({
7345
7422
  wire: null,
7346
7423
  targets: ["project"],
7347
7424
  requiredInputs: [],
7348
- reason: "Translation is its own metered Studio pass over the whole strip, not a refinement edit.",
7425
+ reason: "Agent refinement cannot translate captions. Open Translate in Studio to create German or other language versions while retaining the base words.",
7349
7426
  evaluationCases: ["unsupported-translate-captions"]
7350
7427
  },
7351
7428
  {
7352
7429
  id: "caption.output-override",
7353
7430
  domain: "caption",
7354
7431
  status: "intentionally_excluded",
7355
- summary: "Give one output size its own caption wording.",
7432
+ summary: "Give an output family its own caption wording.",
7356
7433
  studioCommands: ["patchOutputCaption", "clearOutputCaption"],
7357
7434
  wire: null,
7358
7435
  targets: ["caption"],
7359
7436
  requiredInputs: [],
7360
- reason: "Per-output wording is a manual override of the shared caption; the Agent edits the shared one.",
7437
+ reason: "Agent refinement edits shared captions. Studio has manual output-family caption wording; both iPhone sizes share one family and cannot have separate words.",
7361
7438
  evaluationCases: ["unsupported-per-output-caption"]
7362
7439
  },
7363
7440
  // ── Look and backdrop ─────────────────────────────────────────────────────────────────────────
@@ -7385,18 +7462,18 @@ var init_agentCapabilities = __esm({
7385
7462
  wire: "patch.frames[i].background",
7386
7463
  targets: ["frame"],
7387
7464
  requiredInputs: ["the frame", "the backdrop fields"],
7388
- evaluationCases: ["background-frame-one-amber", "inspection-correct-frame-two-background"]
7465
+ evaluationCases: ["background-frame-one-amber", "background-frame-three-flat-teal", "inspection-correct-frame-two-background"]
7389
7466
  },
7390
7467
  {
7391
7468
  id: "frame.background-reset",
7392
7469
  domain: "look",
7393
7470
  status: "intentionally_excluded",
7394
- summary: "Drop a frame\u2019s own backdrop so it inherits the strip again.",
7395
- studioCommands: ["clearPanelBackground"],
7471
+ summary: "Drop one or all frame backdrops so they inherit the strip again.",
7472
+ studioCommands: ["clearPanelBackground", "clearAllPanelBackgrounds"],
7396
7473
  wire: null,
7397
7474
  targets: ["frame"],
7398
7475
  requiredInputs: [],
7399
- reason: "The Agent sets backdrops; returning one frame to the strip default is a Studio control.",
7476
+ reason: "Agent refinement cannot reset backdrop inheritance. Use the frame background reset control in Studio to return it to the strip default.",
7400
7477
  evaluationCases: ["unsupported-frame-background-reset"]
7401
7478
  },
7402
7479
  {
@@ -7415,13 +7492,13 @@ var init_agentCapabilities = __esm({
7415
7492
  id: "frame.color",
7416
7493
  domain: "look",
7417
7494
  status: "intentionally_excluded",
7418
- summary: "Set a frame\u2019s flat panel colour.",
7495
+ summary: "Set the legacy per-panel colour that sits under a frame\u2019s backdrop.",
7419
7496
  studioCommands: ["setPanelColor"],
7420
7497
  wire: null,
7421
7498
  targets: ["frame"],
7422
7499
  requiredInputs: [],
7423
- reason: "No Studio surface dispatches it; the frame backdrop is the live control for the same result.",
7424
- evaluationCases: ["unsupported-frame-flat-color"]
7500
+ reason: "No Studio surface dispatches it. A flat colour is a one-stop frame backdrop (`frame.background`), so a request for one is carried out there, never refused.",
7501
+ evaluationCases: ["background-frame-three-flat-teal"]
7425
7502
  },
7426
7503
  {
7427
7504
  id: "look.design-preset",
@@ -7455,7 +7532,7 @@ var init_agentCapabilities = __esm({
7455
7532
  wire: null,
7456
7533
  targets: ["project"],
7457
7534
  requiredInputs: [],
7458
- reason: "Saved Looks are versioned styling the user owns; restoring one is a Studio control, not an edit.",
7535
+ reason: "Agent refinement cannot restore a saved Look. Restore the saved version through Look version history in Studio.",
7459
7536
  evaluationCases: ["unsupported-restore-saved-look"]
7460
7537
  },
7461
7538
  // ── Output and locale axes ────────────────────────────────────────────────────────────────────
@@ -7484,29 +7561,42 @@ var init_agentCapabilities = __esm({
7484
7561
  evaluationCases: ["project-add-german-locale", "geometry-clear-while-adding-a-language"]
7485
7562
  },
7486
7563
  // ── Assets ────────────────────────────────────────────────────────────────────────────────────
7564
+ // The whole-strip versions of the two asset capabilities (#880). No wire of their own: a whole-strip
7565
+ // request is carried out as the per-device operations above, one per supplied screenshot, so the
7566
+ // styling, captions and Output membership that hang off each device survive exactly as they do
7567
+ // for a single swap. Asset-gated the same way — with no screenshots on the turn they ask for them.
7487
7568
  {
7488
7569
  id: "strip.add-screens",
7489
7570
  domain: "asset",
7490
- status: "requires_asset",
7571
+ status: "supported",
7491
7572
  summary: "Add new screenshots to the project, each in its own frame.",
7492
7573
  studioCommands: ["addShots"],
7493
- wire: null,
7574
+ wire: "structuralOperations: frame.add with a frameRef, then device.add into it, once per screenshot from AVAILABLE SCREENSHOTS",
7494
7575
  targets: ["project"],
7495
7576
  requiredInputs: ["one or more authorized screenshot references"],
7496
- reason: "New frames need new pixels. Supply the screenshots and the Agent will place them.",
7497
- evaluationCases: ["asset-add-screens-needs-pixels"]
7577
+ withoutAssetReason: "New frames need new pixels. Send the screenshots with the instruction and the Agent will place them.",
7578
+ limits: [
7579
+ "Only screenshots listed in AVAILABLE SCREENSHOTS; the Agent never invents or generates pixels.",
7580
+ "Each new frame starts without a caption and its device on the default look, exactly as adding screenshots in Studio does."
7581
+ ],
7582
+ evaluationCases: ["asset-add-screens-supplied", "asset-add-screens-needs-pixels"]
7498
7583
  },
7499
7584
  {
7500
7585
  id: "strip.replace-screens",
7501
7586
  domain: "asset",
7502
- status: "requires_asset",
7587
+ status: "supported",
7503
7588
  summary: "Replace every screenshot in the project, keeping the styling.",
7504
7589
  studioCommands: ["loadScreens"],
7505
- wire: null,
7590
+ wire: "structuralOperations: device.replace on every device in strip order, each naming its own assetId from AVAILABLE SCREENSHOTS",
7506
7591
  targets: ["project"],
7507
7592
  requiredInputs: ["a complete authorized screenshot set"],
7508
- reason: "Replacing the whole strip needs the whole new set of pixels.",
7509
- evaluationCases: ["asset-replace-all-screens-needs-pixels"]
7593
+ withoutAssetReason: "Replacing the whole strip needs the whole new set of pixels. This instruction carried none.",
7594
+ limits: [
7595
+ "Only screenshots listed in AVAILABLE SCREENSHOTS; the Agent never invents or generates pixels.",
7596
+ "Every device keeps its look, placement, captions, translated screens and Output membership \u2014 only the pixels change.",
7597
+ "When the supplied screenshots do not match the devices one to one, the Agent asks which goes where instead of swapping some of them."
7598
+ ],
7599
+ evaluationCases: ["asset-replace-all-screens-supplied", "asset-replace-all-screens-needs-pixels", "asset-replace-all-screens-count-mismatch"]
7510
7600
  },
7511
7601
  {
7512
7602
  id: "locale.load-screens",
@@ -7709,7 +7799,13 @@ function applicationOutcome(facts) {
7709
7799
  }
7710
7800
  const gap = (facts.gaps ?? []).find((entry) => entry.kind === "asset") ?? (facts.gaps ?? []).find((entry) => entry.kind === "excluded") ?? (facts.gaps ?? []).find((entry) => entry.kind === "clarification");
7711
7801
  if (!gap) {
7712
- return { status: "applied", headline: headlineForChanges(changed), changed };
7802
+ const layout = facts.unresolvedLayout?.trim();
7803
+ return layout ? {
7804
+ status: "applied_with_remaining",
7805
+ headline: headlineForChanges(changed),
7806
+ changed,
7807
+ remaining: boundOutcomeText(layout, AGENT_OUTCOME_BOUNDS.remaining)
7808
+ } : { status: "applied", headline: headlineForChanges(changed), changed };
7713
7809
  }
7714
7810
  return {
7715
7811
  status: "applied_with_remaining",
@@ -8629,11 +8725,30 @@ function applyStructuralOperation(state, operation, assets = [], bindings) {
8629
8725
  }
8630
8726
  }
8631
8727
  }
8728
+ function applyMergedCaptionMove(state, merge, index, toIndex) {
8729
+ const slot2 = merge.pending[index];
8730
+ if (slot2 === void 0 || panelIndex(state, merge.into) < 0) return null;
8731
+ const ids = captionSlotIds(state)[merge.into] ?? [];
8732
+ const settled = ids.length - merge.pending.length;
8733
+ if (settled < 0 || ids.slice(settled).some((id, offset) => id !== merge.pending[offset])) return null;
8734
+ const to = toIndex ?? settled;
8735
+ if (to < 0 || to > settled) return null;
8736
+ const moved = applyCaptionOperation(state, {
8737
+ operation: "move",
8738
+ frameId: merge.into,
8739
+ index: ids.indexOf(slot2),
8740
+ toFrameId: merge.into,
8741
+ toIndex: to
8742
+ });
8743
+ if (moved) merge.pending.splice(index, 1);
8744
+ return moved;
8745
+ }
8632
8746
  function applyStructuralOperations(state, patch, assets = []) {
8633
8747
  let next = state;
8634
8748
  const assetBindings = [];
8635
8749
  const frameRefs = /* @__PURE__ */ new Map();
8636
8750
  const retiredRefs = /* @__PURE__ */ new Set();
8751
+ const merged = /* @__PURE__ */ new Map();
8637
8752
  for (const raw of normalizeStructuralOperations(patch)) {
8638
8753
  const ref = raw.op === "frame.add" ? raw.frameRef : void 0;
8639
8754
  if (ref !== void 0 && (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(ref) || frameRefs.has(ref) || state.panels.some((panel) => panel.id === ref) || state.shots.some((shot) => shot.id === ref))) return null;
@@ -8646,8 +8761,14 @@ function applyStructuralOperations(state, patch, assets = []) {
8646
8761
  ..."fromFrameId" in raw ? { fromFrameId: resolve14(raw.fromFrameId) } : {},
8647
8762
  ..."afterFrameId" in raw && raw.afterFrameId !== void 0 ? { afterFrameId: resolve14(raw.afterFrameId) } : {}
8648
8763
  };
8649
- const applied = applyStructuralOperation(next, operation, assets, assetBindings);
8764
+ const merge = operation.op === "caption.move" && panelIndex(next, operation.frameId) < 0 ? merged.get(operation.frameId) : void 0;
8765
+ const applied = operation.op === "caption.move" && merge?.into === operation.toFrameId ? applyMergedCaptionMove(next, merge, operation.index, operation.toIndex) : applyStructuralOperation(next, operation, assets, assetBindings);
8650
8766
  if (!applied) return null;
8767
+ if (operation.op === "frame.combine") {
8768
+ const carried = captionSlotIds(next)[operation.fromFrameId]?.length ?? 0;
8769
+ const survivor = captionSlotIds(applied)[operation.frameId] ?? [];
8770
+ merged.set(operation.fromFrameId, { into: operation.frameId, pending: carried > 0 ? survivor.slice(-carried) : [] });
8771
+ }
8651
8772
  if (ref !== void 0) {
8652
8773
  const created = applied.panels.find((panel) => !next.panels.some((previous) => previous.id === panel.id));
8653
8774
  if (!created) return null;
@@ -8673,6 +8794,57 @@ var init_refinementStructure = __esm({
8673
8794
  }
8674
8795
  });
8675
8796
 
8797
+ // ../mockup-engine/appstore/templatePoseGuard.ts
8798
+ function withoutPose(entry) {
8799
+ if (!TEMPLATE_OWNED_SHOT_FIELDS.some((field) => entry[field] !== void 0)) return null;
8800
+ const kept = { ...entry };
8801
+ for (const field of TEMPLATE_OWNED_SHOT_FIELDS) delete kept[field];
8802
+ return kept;
8803
+ }
8804
+ function dropTemplatePoses(patch, templated, panelIds, panelOfDevice) {
8805
+ if (templated.size === 0 || !patch.shots && !patch.devices) return { patch, dropped: false };
8806
+ let dropped = false;
8807
+ let next = patch;
8808
+ if (patch.shots) {
8809
+ const shots = patch.shots.map((entry, index) => {
8810
+ const panelId = panelIds[index];
8811
+ const kept = panelId !== void 0 && templated.has(panelId) ? withoutPose(entry) : null;
8812
+ if (!kept) return entry;
8813
+ dropped = true;
8814
+ return kept;
8815
+ });
8816
+ if (dropped) next = { ...next, shots };
8817
+ }
8818
+ if (patch.devices) {
8819
+ let devicesDropped = false;
8820
+ const devices = [];
8821
+ for (const entry of patch.devices) {
8822
+ const panelId = panelOfDevice(entry.deviceId);
8823
+ const kept = panelId !== void 0 && templated.has(panelId) ? withoutPose(entry.patch) : null;
8824
+ if (!kept) {
8825
+ devices.push(entry);
8826
+ continue;
8827
+ }
8828
+ devicesDropped = true;
8829
+ if (Object.keys(kept).length > 0) devices.push({ ...entry, patch: kept });
8830
+ }
8831
+ if (devicesDropped) {
8832
+ dropped = true;
8833
+ const { devices: _replaced, ...rest } = next;
8834
+ void _replaced;
8835
+ next = devices.length > 0 ? { ...rest, devices } : rest;
8836
+ }
8837
+ }
8838
+ return { patch: next, dropped };
8839
+ }
8840
+ var TEMPLATE_OWNED_SHOT_FIELDS;
8841
+ var init_templatePoseGuard = __esm({
8842
+ "../mockup-engine/appstore/templatePoseGuard.ts"() {
8843
+ "use strict";
8844
+ TEMPLATE_OWNED_SHOT_FIELDS = ["orbit", "roll", "angle", "phoneHeight", "hOffset", "vOffset"];
8845
+ }
8846
+ });
8847
+
8676
8848
  // ../mockup-engine/appstore/refinementEffect.ts
8677
8849
  function describeRefinementEffect(before, after) {
8678
8850
  const changes = [];
@@ -8684,6 +8856,17 @@ function describeRefinementEffect(before, after) {
8684
8856
  if (framesRemoved > 0) changes.push(`Removed ${framesRemoved} frame${framesRemoved === 1 ? "" : "s"}`);
8685
8857
  const beforeCaptions = before.captionText[before.baseLocale] ?? {};
8686
8858
  const afterCaptions = after.captionText[after.baseLocale] ?? {};
8859
+ const beforeSlots = normalizeCaptionSlotIds(before.captionSlotIds, before.captionText, before.panels.map((panel) => panel.id));
8860
+ const afterSlots = normalizeCaptionSlotIds(after.captionSlotIds, after.captionText, after.panels.map((panel) => panel.id));
8861
+ const reorderOf = (panelId) => {
8862
+ const was = beforeSlots[panelId] ?? [];
8863
+ const now = afterSlots[panelId] ?? [];
8864
+ if (was.length !== now.length || was.join("\0") === now.join("\0")) return null;
8865
+ const source = now.map((id) => was.indexOf(id));
8866
+ return source.every((index, position) => index >= 0 && source.indexOf(index) === position) ? source : null;
8867
+ };
8868
+ const sourceIndex = (panelId, index) => reorderOf(panelId)?.[index] ?? index;
8869
+ let captionFramesReordered = 0;
8687
8870
  let rewritten = 0;
8688
8871
  let captionSlotsAdded = 0;
8689
8872
  let captionSlotsRemoved = 0;
@@ -8694,8 +8877,9 @@ function describeRefinementEffect(before, after) {
8694
8877
  captionSlotsAdded += Math.max(0, newLayers.length - oldLayers.length);
8695
8878
  captionSlotsRemoved += Math.max(0, oldLayers.length - newLayers.length);
8696
8879
  if (oldLayers.length !== newLayers.length) continue;
8880
+ if (reorderOf(panelId)) captionFramesReordered += 1;
8697
8881
  for (let index = 0; index < oldLayers.length; index++) {
8698
- if (oldLayers[index].text !== newLayers[index].text) rewritten += 1;
8882
+ if (oldLayers[sourceIndex(panelId, index)]?.text !== newLayers[index].text) rewritten += 1;
8699
8883
  }
8700
8884
  }
8701
8885
  const captionWordsIn = (source) => Object.entries(source).flatMap(([panelId, layers]) => layers.map((layer) => `${panelId}\0${layer.text}`));
@@ -8712,6 +8896,9 @@ function describeRefinementEffect(before, after) {
8712
8896
  captionSlotsAdded -= movedCaptions;
8713
8897
  captionSlotsRemoved -= movedCaptions;
8714
8898
  if (rewritten > 0) changes.push(`Rewrote ${rewritten} caption${rewritten === 1 ? "" : "s"}`);
8899
+ if (captionFramesReordered > 0) {
8900
+ changes.push(`Reordered the captions in ${captionFramesReordered} frame${captionFramesReordered === 1 ? "" : "s"}`);
8901
+ }
8715
8902
  if (movedCaptions > 0) {
8716
8903
  changes.push(`Moved ${movedCaptions} caption${movedCaptions === 1 ? "" : "s"} to another frame`);
8717
8904
  }
@@ -8744,7 +8931,10 @@ function describeRefinementEffect(before, after) {
8744
8931
  const oldLayers = oldByPanel[panelId] ?? [];
8745
8932
  const newLayers = newByPanel[panelId] ?? [];
8746
8933
  if (oldLayers.length !== newLayers.length) return false;
8747
- return oldLayers.some((layer, index) => styleOf(layer) !== styleOf(newLayers[index]));
8934
+ return newLayers.some((layer, index) => {
8935
+ const prior = oldLayers[sourceIndex(panelId, index)];
8936
+ return !prior || styleOf(prior) !== styleOf(layer);
8937
+ });
8748
8938
  });
8749
8939
  });
8750
8940
  const styleByText = (source) => {
@@ -8811,6 +9001,7 @@ var init_refinementEffect = __esm({
8811
9001
  "../mockup-engine/appstore/refinementEffect.ts"() {
8812
9002
  "use strict";
8813
9003
  init_gradient();
9004
+ init_frameTemplates();
8814
9005
  init_refinementValues();
8815
9006
  }
8816
9007
  });
@@ -8914,7 +9105,18 @@ function applyProjectRefinementWithAssets(state, patch, panelIds, assets = []) {
8914
9105
  next = { ...next, shots, captionText, ...overlay.background };
8915
9106
  }
8916
9107
  }
8917
- if (patch.layout) next = applyLayout(next, patch.layout, sourcePanelIds);
9108
+ if (patch.layout) {
9109
+ next = applyLayout(next, patch.layout, sourcePanelIds);
9110
+ if (layoutTemplateById(patch.layout)) {
9111
+ const shots = next.shots;
9112
+ patch = dropTemplatePoses(
9113
+ patch,
9114
+ new Set(sourcePanelIds),
9115
+ requestedPanelIds,
9116
+ (deviceId) => shots.find((shot) => shot.id === deviceId)?.panelId
9117
+ ).patch;
9118
+ }
9119
+ }
8918
9120
  if (patch.shots?.length === positionalCount) {
8919
9121
  const entries = new Map(requestedPanelIds.map((panelId, index) => [panelId, patch.shots[index]]));
8920
9122
  const shared = new Set(
@@ -9051,6 +9253,7 @@ var init_refinement = __esm({
9051
9253
  init_refinementStructure();
9052
9254
  init_refinementValues();
9053
9255
  init_gradient();
9256
+ init_templatePoseGuard();
9054
9257
  init_refinementEffect();
9055
9258
  init_refinementStructure();
9056
9259
  }
@@ -9124,45 +9327,55 @@ var init_appStoreLocales = __esm({
9124
9327
  });
9125
9328
 
9126
9329
  // ../mockup-engine/appstore/agentTurnRepetition.ts
9127
- function creationOf(operation) {
9128
- if (!CREATING_OPS.has(operation.op)) return null;
9330
+ function appliedOperationOf(operation) {
9331
+ if (!NON_REPEATABLE_OPS.has(operation.op)) return null;
9332
+ if (operation.op === "caption.remove") return { op: operation.op, target: `${operation.frameId}#${operation.index}` };
9333
+ if (operation.op === "device.remove") return { op: operation.op, target: operation.deviceId };
9129
9334
  const target = "frameId" in operation ? operation.frameId : "toFrameId" in operation ? operation.toFrameId : "afterFrameId" in operation && operation.afterFrameId ? `after:${operation.afterFrameId}` : "strip";
9130
9335
  return { op: operation.op, target };
9131
9336
  }
9132
- function contextualCreations(operations, resolved = {}) {
9337
+ function normalizeTarget(target, refs) {
9338
+ if (target.startsWith("after:")) {
9339
+ const id = target.slice(6);
9340
+ return `after:${refs.get(id) ?? id}`;
9341
+ }
9342
+ const ordinal = target.indexOf("#");
9343
+ if (ordinal >= 0) {
9344
+ const id = target.slice(0, ordinal);
9345
+ return `${refs.get(id) ?? id}${target.slice(ordinal)}`;
9346
+ }
9347
+ return refs.get(target) ?? target;
9348
+ }
9349
+ function contextualOperations(operations, resolved = {}) {
9133
9350
  const refs = /* @__PURE__ */ new Map();
9134
9351
  return operations.map((operation) => {
9135
- const creation = creationOf(operation);
9136
- const target = creation?.target;
9137
- const normalized = creation && target !== void 0 ? {
9138
- ...creation,
9139
- target: target.startsWith("after:") ? `after:${refs.get(target.slice(6)) ?? target.slice(6)}` : refs.get(target) ?? target
9140
- } : null;
9352
+ const applied = appliedOperationOf(operation);
9353
+ const normalized = applied ? { ...applied, target: normalizeTarget(applied.target, refs) } : null;
9141
9354
  if (operation.op === "frame.add" && operation.frameRef && normalized) {
9142
9355
  refs.set(operation.frameRef, Object.prototype.hasOwnProperty.call(resolved, operation.frameRef) ? resolved[operation.frameRef] : `created-frame:${normalized.target}`);
9143
9356
  }
9144
9357
  return normalized;
9145
9358
  });
9146
9359
  }
9147
- function creationsIn(patch, frameRefs) {
9360
+ function appliedOperationsIn(patch, frameRefs) {
9148
9361
  const operations = patch.structuralOperations ?? [];
9149
- const symbolic = contextualCreations(operations).flatMap((creation) => creation ? [creation] : []);
9150
- const durable = frameRefs ? contextualCreations(operations, frameRefs).flatMap((creation) => creation ? [creation] : []) : [];
9151
- return symbolic.concat(durable.filter((creation) => !symbolic.some((entry) => entry.op === creation.op && entry.target === creation.target)));
9362
+ const symbolic = contextualOperations(operations).flatMap((applied) => applied ? [applied] : []);
9363
+ const durable = frameRefs ? contextualOperations(operations, frameRefs).flatMap((applied) => applied ? [applied] : []) : [];
9364
+ return symbolic.concat(durable.filter((applied) => !symbolic.some((entry) => entry.op === applied.op && entry.target === applied.target)));
9152
9365
  }
9153
- function dropRepeatedCreations(patch, already) {
9366
+ function dropRepeatedOperations(patch, already) {
9154
9367
  const operations = patch.structuralOperations;
9155
9368
  if (!operations || operations.length === 0 || already.length === 0) return { patch, dropped: [] };
9156
- const seen = new Set(already.map((creation) => `${creation.op} ${creation.target}`));
9369
+ const seen = new Set(already.map((applied2) => `${applied2.op} ${applied2.target}`));
9157
9370
  const dropped = [];
9158
- const creations = contextualCreations(operations);
9371
+ const applied = contextualOperations(operations);
9159
9372
  const unavailableRefs = /* @__PURE__ */ new Set();
9160
9373
  const kept = operations.filter((operation, index) => {
9161
- const creation = creations[index];
9374
+ const entry = applied[index];
9162
9375
  const dependent = structuralOperationTargets(operation).frameIds.some((id) => unavailableRefs.has(id));
9163
- if (!dependent && (!creation || !seen.has(`${creation.op} ${creation.target}`))) return true;
9376
+ if (!dependent && (!entry || !seen.has(`${entry.op} ${entry.target}`))) return true;
9164
9377
  if (operation.op === "frame.add" && operation.frameRef) unavailableRefs.add(operation.frameRef);
9165
- if (creation) dropped.push(creation);
9378
+ if (entry) dropped.push(entry);
9166
9379
  return false;
9167
9380
  });
9168
9381
  if (dropped.length === 0) return { patch, dropped };
@@ -9170,16 +9383,19 @@ function dropRepeatedCreations(patch, already) {
9170
9383
  void _dropped;
9171
9384
  return { patch: kept.length > 0 ? { ...rest, structuralOperations: kept } : rest, dropped };
9172
9385
  }
9173
- var CREATING_OPS;
9386
+ var NON_REPEATABLE_OPS;
9174
9387
  var init_agentTurnRepetition = __esm({
9175
9388
  "../mockup-engine/appstore/agentTurnRepetition.ts"() {
9176
9389
  "use strict";
9177
9390
  init_refineIntent();
9178
- CREATING_OPS = /* @__PURE__ */ new Set([
9391
+ NON_REPEATABLE_OPS = /* @__PURE__ */ new Set([
9179
9392
  "frame.add",
9180
9393
  "frame.split",
9181
9394
  "caption.add",
9182
- "device.add"
9395
+ "device.add",
9396
+ "frame.remove",
9397
+ "caption.remove",
9398
+ "device.remove"
9183
9399
  ]);
9184
9400
  }
9185
9401
  });
@@ -9231,8 +9447,14 @@ async function runAgentTurn(input, ports) {
9231
9447
  const layoutRepairable = canRepairLayoutForFocus(input.focus, instruction);
9232
9448
  const loaded = await ports.loadProject();
9233
9449
  const before = loaded.state;
9450
+ const sourceCaptions = before.panels.map((panel) => ({
9451
+ frameId: panel.id,
9452
+ captions: (before.captionText[before.baseLocale]?.[panel.id] ?? []).map((layer) => layer.text)
9453
+ }));
9234
9454
  let working = before;
9235
9455
  let rendered;
9456
+ let renderedState = null;
9457
+ let measuredLayout = null;
9236
9458
  const passes = [];
9237
9459
  const broaderChanges = /* @__PURE__ */ new Set();
9238
9460
  let checkpointId = 0;
@@ -9244,14 +9466,16 @@ async function runAgentTurn(input, ports) {
9244
9466
  let interpretation = "";
9245
9467
  let inspection = "";
9246
9468
  let remainingMismatch = null;
9247
- const appliedCreations = [];
9469
+ const appliedOperations = [];
9248
9470
  const touchedCapabilities = /* @__PURE__ */ new Set();
9471
+ const templatedPanels = /* @__PURE__ */ new Set();
9249
9472
  let terminalOutcome = null;
9250
9473
  let invalid2 = false;
9251
9474
  let cancelled = false;
9252
9475
  await emit({ type: "turn_started" });
9253
9476
  try {
9254
9477
  rendered = await ports.renderComposed(working, 0);
9478
+ renderedState = working;
9255
9479
  } catch {
9256
9480
  terminalOutcome = failureOutcome(
9257
9481
  "service_unavailable",
@@ -9279,12 +9503,13 @@ async function runAgentTurn(input, ports) {
9279
9503
  actualDiff: selected?.changed ?? [],
9280
9504
  inspection,
9281
9505
  cycle: cycle - 1,
9282
- appliedOperations: [...appliedCreations]
9506
+ appliedOperations: [...appliedOperations],
9507
+ sourceCaptions: sourceCaptions.map((frame) => ({ ...frame, captions: [...frame.captions] }))
9283
9508
  }
9284
9509
  } : {}
9285
9510
  });
9286
- } catch {
9287
- terminalOutcome = failureOutcome(
9511
+ } catch (error) {
9512
+ terminalOutcome = error instanceof AgentTurnAuthorizationError ? error.outcome : failureOutcome(
9288
9513
  "service_unavailable",
9289
9514
  "The Agent could not complete this turn. Nothing changed."
9290
9515
  );
@@ -9322,9 +9547,19 @@ async function runAgentTurn(input, ports) {
9322
9547
  remainingMismatch = authorization.remainingMismatch?.trim() || authorization.followUp?.trim() || null;
9323
9548
  for (const change of authorization.broaderChanges ?? []) broaderChanges.add(change);
9324
9549
  if (authorization.patch && cycle > 1) {
9325
- const guarded = dropRepeatedCreations(authorization.patch, appliedCreations);
9550
+ const guarded = dropRepeatedOperations(authorization.patch, appliedOperations);
9326
9551
  if (guarded.dropped.length > 0) authorization = { ...authorization, patch: guarded.patch };
9327
9552
  }
9553
+ if (authorization.patch && templatedPanels.size > 0) {
9554
+ const shots = working.shots;
9555
+ const guarded = dropTemplatePoses(
9556
+ authorization.patch,
9557
+ templatedPanels,
9558
+ authorization.panelIds,
9559
+ (deviceId) => shots.find((shot) => shot.id === deviceId)?.panelId
9560
+ );
9561
+ if (guarded.dropped) authorization = { ...authorization, patch: guarded.patch };
9562
+ }
9328
9563
  if (authorization.patch) {
9329
9564
  for (const id of capabilitiesForPatch(authorization.patch)) touchedCapabilities.add(id);
9330
9565
  const application = applyProjectRefinementWithAssets(
@@ -9338,7 +9573,12 @@ async function runAgentTurn(input, ports) {
9338
9573
  break;
9339
9574
  }
9340
9575
  working = application.state;
9341
- appliedCreations.push(...creationsIn(authorization.patch, application.frameRefs));
9576
+ if (authorization.patch.layout && layoutTemplateById(authorization.patch.layout)) {
9577
+ for (const panelId of authorization.panelIds) {
9578
+ if (working.panels.some((panel) => panel.id === panelId)) templatedPanels.add(panelId);
9579
+ }
9580
+ }
9581
+ appliedOperations.push(...appliedOperationsIn(authorization.patch, application.frameRefs));
9342
9582
  workingAssetBindings = [...workingAssetBindings, ...application.assetBindings];
9343
9583
  checkpointId += 1;
9344
9584
  const checkpoint = {
@@ -9365,7 +9605,24 @@ async function runAgentTurn(input, ports) {
9365
9605
  });
9366
9606
  }
9367
9607
  const requiresRenderedInspection = cycle === 1 && authorization.patch != null;
9368
- const unresolvedLayout = layoutRepairable && authorization.complete === true && !requiresRenderedInspection && authorization.patch == null ? ports.unresolvedLayout?.(rendered)?.trim() || null : null;
9608
+ let renderedThisCycle = false;
9609
+ let unresolvedLayout = null;
9610
+ if (layoutRepairable && authorization.complete === true && !requiresRenderedInspection && ports.unresolvedLayout) {
9611
+ let measurable = true;
9612
+ if (authorization.patch != null) {
9613
+ try {
9614
+ rendered = await ports.renderComposed(working, cycle);
9615
+ renderedState = working;
9616
+ renderedThisCycle = true;
9617
+ } catch {
9618
+ measurable = false;
9619
+ }
9620
+ }
9621
+ if (measurable) {
9622
+ unresolvedLayout = ports.unresolvedLayout(rendered)?.trim() || null;
9623
+ if (renderedState) measuredLayout = { state: renderedState, collision: unresolvedLayout };
9624
+ }
9625
+ }
9369
9626
  if (authorization.complete === true && !requiresRenderedInspection && !(unresolvedLayout && cycle < AGENT_TURN_PASS_CAP)) {
9370
9627
  if (selected?.changed.length) best = selected;
9371
9628
  remainingMismatch = unresolvedLayout;
@@ -9373,9 +9630,10 @@ async function runAgentTurn(input, ports) {
9373
9630
  }
9374
9631
  if (unresolvedLayout) remainingMismatch = unresolvedLayout;
9375
9632
  else if (authorization.followUp && !authorization.patch) break;
9376
- if (cycle < AGENT_TURN_PASS_CAP) {
9633
+ if (cycle < AGENT_TURN_PASS_CAP && !renderedThisCycle) {
9377
9634
  try {
9378
9635
  rendered = await ports.renderComposed(working, cycle);
9636
+ renderedState = working;
9379
9637
  } catch {
9380
9638
  terminalOutcome = failureOutcome(
9381
9639
  "service_unavailable",
@@ -9393,7 +9651,10 @@ async function runAgentTurn(input, ports) {
9393
9651
  terminalOutcome ?? applicationOutcome({
9394
9652
  changed: selected?.changed ?? [],
9395
9653
  invalid: invalid2,
9396
- gaps: selected?.gaps ?? []
9654
+ gaps: selected?.gaps ?? [],
9655
+ // Only a measurement of the very state being committed. A collision seen on an earlier draft,
9656
+ // or on a strip the last pass then changed without it being rendered, says nothing about this one.
9657
+ unresolvedLayout: selected && measuredLayout?.state === selected.state ? measuredLayout.collision : null
9397
9658
  }),
9398
9659
  Array.from(touchedCapabilities)
9399
9660
  );
@@ -9458,7 +9719,7 @@ async function runAgentTurn(input, ports) {
9458
9719
  void input.requestedCycle;
9459
9720
  return result;
9460
9721
  }
9461
- var AGENT_TURN_PASS_CAP, EMPTY_USAGE, NO_CHANGE_SUMMARY, NEEDS_INPUT_SUMMARY, FAILED_PASS_SUMMARY;
9722
+ var AGENT_TURN_PASS_CAP, AgentTurnAuthorizationError, EMPTY_USAGE, NO_CHANGE_SUMMARY, NEEDS_INPUT_SUMMARY, FAILED_PASS_SUMMARY;
9462
9723
  var init_agentTurnCoordinator = __esm({
9463
9724
  "../mockup-engine/appstore/agentTurnCoordinator.ts"() {
9464
9725
  "use strict";
@@ -9470,7 +9731,17 @@ var init_agentTurnCoordinator = __esm({
9470
9731
  init_appStoreLocales();
9471
9732
  init_agentAssetCustody();
9472
9733
  init_agentTurnRepetition();
9734
+ init_templatePoseGuard();
9735
+ init_layoutTemplates();
9473
9736
  AGENT_TURN_PASS_CAP = 3;
9737
+ AgentTurnAuthorizationError = class extends Error {
9738
+ constructor(outcome) {
9739
+ super(outcome.headline);
9740
+ this.outcome = outcome;
9741
+ this.name = "AgentTurnAuthorizationError";
9742
+ }
9743
+ outcome;
9744
+ };
9474
9745
  EMPTY_USAGE = {
9475
9746
  inputTokens: 0,
9476
9747
  cacheReadTokens: 0,
@@ -11250,6 +11521,84 @@ var init_renderCredits = __esm({
11250
11521
  }
11251
11522
  });
11252
11523
 
11524
+ // src/render/browserInstall.ts
11525
+ import { spawn as spawnProcess } from "node:child_process";
11526
+ import { createRequire } from "node:module";
11527
+ import { dirname, join as join2 } from "node:path";
11528
+ function isMissingBrowserError(message) {
11529
+ return /Executable doesn't exist at /.test(message);
11530
+ }
11531
+ function classifyInstallFailure(output2) {
11532
+ const lines = output2.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !/^at /.test(line));
11533
+ const detail = (lines[lines.length - 1] ?? "the installer exited without output").slice(0, 300);
11534
+ return { offline: NETWORK_FAILURE.test(output2), detail };
11535
+ }
11536
+ function rendererSetupRefusal(err) {
11537
+ if (!(err instanceof RendererSetupError)) return null;
11538
+ if (err.offline) return errorResult(RENDERER_DOWNLOAD_OFFLINE_MESSAGE, "upstream_unavailable");
11539
+ return errorResult(`The one-time download of the renderer (Chromium) failed: ${err.detail}`, "internal_error");
11540
+ }
11541
+ async function refuseOnRendererSetupFailure(run) {
11542
+ try {
11543
+ return await run();
11544
+ } catch (err) {
11545
+ const refusal = rendererSetupRefusal(err);
11546
+ if (refusal) return refusal;
11547
+ throw err;
11548
+ }
11549
+ }
11550
+ function playwrightCliPath() {
11551
+ const require2 = createRequire(import.meta.url);
11552
+ return join2(dirname(require2.resolve("playwright/package.json")), "cli.js");
11553
+ }
11554
+ function installChromium(options = {}) {
11555
+ const spawn2 = options.spawn ?? spawnProcess;
11556
+ const log = options.log ?? ((chunk) => void process.stderr.write(chunk));
11557
+ const execPath = options.execPath ?? process.execPath;
11558
+ const cliPath = options.cliPath ?? playwrightCliPath();
11559
+ return new Promise((resolve14, reject) => {
11560
+ let output2 = "";
11561
+ const collect = (data) => {
11562
+ const text3 = String(data);
11563
+ log(text3);
11564
+ output2 = (output2 + text3).slice(-OUTPUT_TAIL_BYTES);
11565
+ };
11566
+ const child = spawn2(execPath, [cliPath, "install", "chromium"], {
11567
+ env: options.env ?? process.env,
11568
+ stdio: ["ignore", "pipe", "pipe"],
11569
+ windowsHide: true
11570
+ });
11571
+ child.stdout?.on("data", collect);
11572
+ child.stderr?.on("data", collect);
11573
+ child.on("error", (err) => reject(new RendererSetupError(classifyInstallFailure(err.message))));
11574
+ child.on("close", (code) => {
11575
+ if (code === 0) resolve14();
11576
+ else reject(new RendererSetupError(classifyInstallFailure(output2)));
11577
+ });
11578
+ });
11579
+ }
11580
+ var RENDERER_DOWNLOADED_NOTE, RENDERER_DOWNLOAD_OFFLINE_MESSAGE, NETWORK_FAILURE, RendererSetupError, OUTPUT_TAIL_BYTES;
11581
+ var init_browserInstall = __esm({
11582
+ "src/render/browserInstall.ts"() {
11583
+ "use strict";
11584
+ init_toolResults();
11585
+ RENDERER_DOWNLOADED_NOTE = "This was the first render on this machine, so ShotOps downloaded its renderer (Chromium) once before rendering; later renders skip that step.";
11586
+ RENDERER_DOWNLOAD_OFFLINE_MESSAGE = "The first render on this machine needs a one-time download of the renderer (Chromium), and the download server could not be reached, so connect to the internet and try again.";
11587
+ NETWORK_FAILURE = /\b(?:ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH|ENETDOWN)\b|getaddrinfo|socket hang up|server closed connection|timed out after \d+ms/;
11588
+ RendererSetupError = class extends Error {
11589
+ offline;
11590
+ detail;
11591
+ constructor(failure2) {
11592
+ super(failure2.offline ? RENDERER_DOWNLOAD_OFFLINE_MESSAGE : `renderer download failed: ${failure2.detail}`);
11593
+ this.name = "RendererSetupError";
11594
+ this.offline = failure2.offline;
11595
+ this.detail = failure2.detail;
11596
+ }
11597
+ };
11598
+ OUTPUT_TAIL_BYTES = 16e3;
11599
+ }
11600
+ });
11601
+
11253
11602
  // ../api/_lib/billingStore.ts
11254
11603
  var SUBSCRIPTION_COLUMNS;
11255
11604
  var init_billingStore = __esm({
@@ -11542,7 +11891,7 @@ var init_providerSpendStore = __esm({
11542
11891
  evidenceSchema = z8.object({
11543
11892
  state: z8.enum(["estimated", "partial", "unknown", "not_incurred"]),
11544
11893
  amountNanoUsd: decimalMoney.nullable(),
11545
- missing: z8.array(z8.enum(["rate", "usage", "input", "input_breakdown", "inconsistent_input", "cache_read", "cache_write", "cache_write_ttl", "output", "multiple_provider_attempts", "provider_attempts_unknown", "amount_overflow"])),
11894
+ missing: z8.array(z8.enum(["rate", "usage", "input", "input_breakdown", "inconsistent_input", "cache_read", "cache_write", "cache_write_ttl", "output", "multiple_provider_attempts", "provider_attempts_unknown", "gateway_bill", "amount_overflow"])),
11546
11895
  usage: z8.object({ inputTokens: nullableCount, cacheReadTokens: nullableCount, cacheWriteTokens: nullableCount, outputTokens: nullableCount, cacheWriteTtl: z8.enum(["5m", "1h"]).nullable() }).strict().nullable(),
11547
11896
  rate: z8.object({
11548
11897
  version: z8.string().regex(/^[a-zA-Z0-9._-]{1,100}$/),
@@ -12058,7 +12407,7 @@ var init_package = __esm({
12058
12407
  "package.json"() {
12059
12408
  package_default = {
12060
12409
  name: "shotops-mcp",
12061
- version: "0.9.6",
12410
+ version: "0.9.7",
12062
12411
  private: false,
12063
12412
  type: "module",
12064
12413
  description: "The bundle-emitting MCP server over the @engine/@sync spine. Exposes ShotOps tools to ChatGPT, Claude Code, Cursor, and CI over Streamable HTTP with OAuth or personal API tokens. Hosted and MCP tool paths never accept, store, or forward a store-signing credential; the explicit local release CLI validates the user's existing key directly with Apple and records only its path. Renders via headless Playwright Chromium, the engine's native non-browser habitat (same path as mockup-mcp). Also ships as a free LOCAL stdio server (`npx shotops-mcp`) \u2014 same render, on your own machine, no account needed.",
@@ -12095,7 +12444,8 @@ var init_package = __esm({
12095
12444
  prepack: "node ../scripts/third-party-notices.mjs --check",
12096
12445
  prepublishOnly: "npm run build",
12097
12446
  typecheck: "tsc --noEmit",
12098
- "build:widget": "node build.widget.mjs"
12447
+ "build:widget": "node build.widget.mjs",
12448
+ "build:mcpb": "tsx build.mcpb.ts"
12099
12449
  },
12100
12450
  dependencies: {
12101
12451
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -12109,7 +12459,9 @@ var init_package = __esm({
12109
12459
  zod: "^3.25.76"
12110
12460
  },
12111
12461
  devDependencies: {
12462
+ "@anthropic-ai/mcpb": "2.1.2",
12112
12463
  "@types/express": "^5.0.6",
12464
+ "@vercel/blob": "2.8.0",
12113
12465
  ai: "^7.0.48",
12114
12466
  esbuild: "^0.28.1",
12115
12467
  nitro: "3.0.260610-beta",
@@ -12259,6 +12611,15 @@ function projectCopyLocales(projectFile, locales, family = BASE_SCREEN_FAMILY) {
12259
12611
  }
12260
12612
  return out;
12261
12613
  }
12614
+ function projectCopyGaps(projectFile, locales, family = BASE_SCREEN_FAMILY) {
12615
+ const file = projectFile;
12616
+ const baseLocale = typeof file?.baseLocale === "string" && file.baseLocale ? file.baseLocale : "en-US";
12617
+ const captionText = normalizeCaptionText(file?.captionText, file?.captionStyles, baseLocale);
12618
+ const outputCaptions = normalizeOutputCaptionText(file?.outputCaptions);
12619
+ const base = resolveOutputCaptionText(captionText, outputCaptions, family, baseLocale, baseLocale);
12620
+ if (!Object.values(base).some((layers) => layers.some((layer) => layer.text.trim().length > 0))) return [];
12621
+ return localesWithoutCaptions(captionText, [...locales], baseLocale).filter((locale2) => !Object.values(outputCaptions[family]?.[locale2] ?? {}).some((words) => words.some((word) => word.trim().length > 0))).map((locale2) => ({ code: "copy_locale_missing", subject: locale2 }));
12622
+ }
12262
12623
  function directCopyLocales(style2, locales) {
12263
12624
  const captions = style2?.captions;
12264
12625
  const words = [];
@@ -12474,7 +12835,7 @@ var init_productionReadiness2 = __esm({
12474
12835
  // src/tools/toolsRender.ts
12475
12836
  function handleRenderStrip(deps, args, signal) {
12476
12837
  const scope = openChargeScope(deps);
12477
- return runCharged(scope, () => renderStrip(deps, args, scope, signal));
12838
+ return refuseOnRendererSetupFailure(() => runCharged(scope, () => renderStrip(deps, args, scope, signal)));
12478
12839
  }
12479
12840
  async function renderStrip(deps, args, scope, signal) {
12480
12841
  const operationId = newOperationId("render_strip");
@@ -12592,7 +12953,7 @@ async function renderStrip(deps, args, scope, signal) {
12592
12953
  }
12593
12954
  const totalBytes = result.panels.reduce((sum, p) => sum + estimateDecodedBytes(p), 0);
12594
12955
  const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, totalBytes, deps.forceOutput);
12595
- const noteWithOutput = [note, outputNote, result.layoutNote, resolutionGapNote(snapshot)].filter(Boolean).join(" ") || void 0;
12956
+ const noteWithOutput = [note, outputNote, result.layoutNote, result.setupNote, resolutionGapNote(snapshot)].filter(Boolean).join(" ") || void 0;
12596
12957
  const measuredReady = requireReadiness({
12597
12958
  snapshot,
12598
12959
  surface: "render",
@@ -13089,7 +13450,7 @@ async function handleRenderProject(deps, args, signal) {
13089
13450
  const scope = openChargeScope(deps);
13090
13451
  const attempt = {};
13091
13452
  try {
13092
- return await runCharged(scope, () => renderProject(deps, args, scope, attempt, signal));
13453
+ return await refuseOnRendererSetupFailure(() => runCharged(scope, () => renderProject(deps, args, scope, attempt, signal)));
13093
13454
  } finally {
13094
13455
  finishProductionRender(attempt.access);
13095
13456
  }
@@ -13317,6 +13678,7 @@ async function renderProject(deps, args, scope, attempt, signal) {
13317
13678
  if (outputNote) notes.push(outputNote);
13318
13679
  if (droppedNote) notes.push(droppedNote);
13319
13680
  if (result.layoutNote) notes.push(result.layoutNote);
13681
+ if (result.setupNote) notes.push(result.setupNote);
13320
13682
  if (multi) {
13321
13683
  notes.push(
13322
13684
  `Rendered ${outputBlocks.length} output devices (${outputBlocks.map((o) => o.label).join(", ")}) \u2014 see \`outputs\`. The top-level panels are the active device (${active?.label}).`
@@ -13412,6 +13774,7 @@ var init_toolsRender = __esm({
13412
13774
  init_studioUrl();
13413
13775
  init_renderCredits();
13414
13776
  init_panelDelivery();
13777
+ init_browserInstall();
13415
13778
  init_toolContract();
13416
13779
  init_schemas();
13417
13780
  init_productionOperationStart();
@@ -13730,12 +14093,12 @@ var init_recordFromStrip = __esm({
13730
14093
  // src/render/staticServer.ts
13731
14094
  import { createServer } from "node:http";
13732
14095
  import { readFile as readFile2 } from "node:fs/promises";
13733
- import { extname, join as join3, relative, isAbsolute } from "node:path";
14096
+ import { extname, join as join4, relative, isAbsolute } from "node:path";
13734
14097
  async function startStaticServer(rootDir) {
13735
14098
  const server = createServer((req, res) => {
13736
14099
  const reqPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
13737
14100
  const rel = reqPath === "/" ? "index.html" : reqPath.replace(/^\/+/, "");
13738
- const filePath = join3(rootDir, rel);
14101
+ const filePath = join4(rootDir, rel);
13739
14102
  const within = relative(rootDir, filePath);
13740
14103
  if (within.startsWith("..") || isAbsolute(within)) {
13741
14104
  res.statusCode = 403;
@@ -13799,6 +14162,7 @@ var init_renderer = __esm({
13799
14162
  init_recordFromStrip();
13800
14163
  init_log();
13801
14164
  init_staticServer();
14165
+ init_browserInstall();
13802
14166
  StripRenderer = class {
13803
14167
  serveMode;
13804
14168
  vite = null;
@@ -13814,9 +14178,14 @@ var init_renderer = __esm({
13814
14178
  // The harness server's URL, cached so a Chromium re-boot (#336) re-launches the browser
13815
14179
  // against the SAME server instead of starting a second Vite/static server per boot.
13816
14180
  harnessUrl = null;
14181
+ installBrowser;
14182
+ // #889 — the note owed to the first SUCCESSFUL render after a browser install. Kept until one
14183
+ // succeeds, so a render that fails for an unrelated reason does not swallow it.
14184
+ pendingSetupNote;
13817
14185
  // Default 'vite' so `new StripRenderer()` (server.ts) keeps the hosted path byte-for-byte.
13818
14186
  constructor(options = {}) {
13819
14187
  this.serveMode = options.serve ?? "vite";
14188
+ this.installBrowser = options.installBrowser;
13820
14189
  }
13821
14190
  boot() {
13822
14191
  if (!this.booting) {
@@ -13829,8 +14198,7 @@ var init_renderer = __esm({
13829
14198
  }
13830
14199
  async doBoot() {
13831
14200
  if (!this.harnessUrl) this.harnessUrl = await this.startHarnessServer();
13832
- const { chromium } = await import("playwright");
13833
- this.browser = await chromium.launch({ headless: true });
14201
+ this.browser = await this.launchBrowser();
13834
14202
  const page2 = await this.browser.newPage();
13835
14203
  page2.on("console", (m) => {
13836
14204
  if (/CONTEXT_LOST|Context Lost/i.test(m.text())) this.sawContextLost = true;
@@ -13839,6 +14207,24 @@ var init_renderer = __esm({
13839
14207
  await page2.waitForFunction(() => window.__RENDER_READY === true, { timeout: 9e4 });
13840
14208
  this.page = page2;
13841
14209
  }
14210
+ // #889 — the install decision. Only a launch that failed because the browser binary is missing,
14211
+ // and only on a renderer that was given an installer, downloads anything; the install's own
14212
+ // failure (offline, most often) propagates as a RendererSetupError, which the render tools'
14213
+ // `refuseOnRendererSetupFailure` turns into a coded refusal. `boot()` is memoized, so concurrent
14214
+ // first renders share one download.
14215
+ async launchBrowser() {
14216
+ const { chromium } = await import("playwright");
14217
+ try {
14218
+ return await chromium.launch({ headless: true });
14219
+ } catch (err) {
14220
+ const message = err instanceof Error ? err.message : String(err);
14221
+ if (!this.installBrowser || !isMissingBrowserError(message)) throw err;
14222
+ console.error("[shotops-mcp-local] Chromium is not installed \u2014 downloading the renderer once (playwright install chromium)\u2026");
14223
+ await this.installBrowser();
14224
+ this.pendingSetupNote = RENDERER_DOWNLOADED_NOTE;
14225
+ return await chromium.launch({ headless: true });
14226
+ }
14227
+ }
13842
14228
  // The one line that forks between hosted and local. Everything downstream (browser launch,
13843
14229
  // the __RENDER_READY handshake, window.renderStrip, boot-retry, CONTEXT_LOST) is identical
13844
14230
  // — the page is the same harness, only its bytes come from a different server.
@@ -13915,6 +14301,11 @@ var init_renderer = __esm({
13915
14301
  panels: result.count ?? void 0
13916
14302
  });
13917
14303
  }
14304
+ if (result.ok && this.pendingSetupNote) {
14305
+ const setupNote = this.pendingSetupNote;
14306
+ this.pendingSetupNote = void 0;
14307
+ return { ...result, setupNote };
14308
+ }
13918
14309
  return result;
13919
14310
  } catch (err) {
13920
14311
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
@@ -14028,6 +14419,9 @@ function parseControlPlaneResponse(op, value) {
14028
14419
  case "read_project_screens":
14029
14420
  if (!Array.isArray(body.screens)) throw new Error("control plane response is missing screens");
14030
14421
  break;
14422
+ case "read_attachment_screens":
14423
+ if (!Array.isArray(body.screens)) throw new Error("control plane response is missing screens");
14424
+ break;
14031
14425
  case "refine_project":
14032
14426
  if (typeof body.ok !== "boolean") throw new Error("control plane response is missing refine_project result");
14033
14427
  if (body.ok) {
@@ -14265,6 +14659,10 @@ function connectHostedBridge(token2) {
14265
14659
  ...repairFindings && repairFindings.length > 0 ? { repairFindings } : {}
14266
14660
  };
14267
14661
  },
14662
+ async readAttachmentScreens(projectId, refs) {
14663
+ const res = await callOp(token2, "read_attachment_screens", { project: projectId, refs: [...refs] });
14664
+ return res.screens;
14665
+ },
14268
14666
  async refineProject(input) {
14269
14667
  return callOp(token2, "refine_project", input);
14270
14668
  },
@@ -14529,7 +14927,9 @@ var init_visionGeneration = __esm({
14529
14927
  LAYOUT_SELECTION_RULE = [
14530
14928
  "LAYOUT SELECTION",
14531
14929
  "- Choose the layout from the user's WORDS, not from the screenshots. A composition is a styling decision; pixels do not vote on it.",
14532
- "- Every layout above ends with a [character]. Pick the one whose character matches what was asked:",
14930
+ "- Every layout above ends with a [character] and a [bleeds] edge. Read the EDGE first: if the request says which way the device should go, that settles it.",
14931
+ ...LAYOUT_BLEED_EDGE_IDS.map((edge) => ` - bleeds [${edge}] when ${LAYOUT_BLEED_EDGE_WORDS[edge]}.`),
14932
+ "- Otherwise pick the one whose character matches what was asked:",
14533
14933
  ...LAYOUT_CHARACTER_IDS.map((character) => ` - [${character}] when ${LAYOUT_CHARACTER_ANSWERS[character]}.`),
14534
14934
  ...LAYOUT_SELECTION_CAVEATS.map((line) => `- ${line}`)
14535
14935
  ].join("\n");
@@ -14538,7 +14938,7 @@ var init_visionGeneration = __esm({
14538
14938
 
14539
14939
  // ../api/_lib/refineProjectContracts.ts
14540
14940
  import { z as z10 } from "zod";
14541
- var localeIds, MAX_DURABLE_MODEL_PROSE, hexSchema, currentColorSchema, shotPatchSchema, aiNumberSchema, captionStylePatchSchema, MAX_WIRE_GRADIENT_STOPS, gradientStopSchema, backgroundPatchSchema, stripBackgroundPatchSchema, framePatchSchema, currentCaptionStyleSchema, currentGradientStopSchema, currentStops, currentBackgroundSchema, currentStripBackgroundSchema, currentShotSchema, currentFramePatchSchema, localeCodeSchema, focusSchema, currentFrameSchema, captionOperationSchema, frameOperationSchema, frameIdSchema, assetIdSchema, structuralOperationSchema, attachmentSchema, refinementAttachmentsSchema, visionScreenshotSchema, requestSchema, legacyFinalizeSchema, durableUsageSchema, durablePassSchema, durableStateDiffSchema, agentOutcomeSchema, durableFinalizeSchema, revertSchema, finalizeSchema, createSessionSchema, historySchema, REFINE_FIELD_LABELS;
14941
+ var MAX_STRIP_FRAMES, localeIds, MAX_DURABLE_MODEL_PROSE, hexSchema, currentColorSchema, shotPatchSchema, aiNumberSchema, captionStylePatchSchema, MAX_WIRE_GRADIENT_STOPS, gradientStopSchema, backgroundPatchSchema, stripBackgroundPatchSchema, framePatchSchema, currentCaptionStyleSchema, currentGradientStopSchema, currentStops, currentBackgroundSchema, currentStripBackgroundSchema, currentShotSchema, currentFramePatchSchema, localeCodeSchema, focusSchema, currentFrameSchema, captionOperationSchema, frameOperationSchema, frameIdSchema, assetIdSchema, structuralOperationSchema, attachmentSchema, refinementAttachmentsSchema, visionScreenshotSchema, requestSchema, legacyFinalizeSchema, durableUsageSchema, durablePassSchema, durableStateDiffSchema, agentOutcomeSchema, durableFinalizeSchema, revertSchema, finalizeSchema, createSessionSchema, historySchema, REFINE_FIELD_LABELS;
14542
14942
  var init_refineProjectContracts = __esm({
14543
14943
  "../api/_lib/refineProjectContracts.ts"() {
14544
14944
  "use strict";
@@ -14548,6 +14948,7 @@ var init_refineProjectContracts = __esm({
14548
14948
  init_startIntent();
14549
14949
  init_visionGeneration();
14550
14950
  init_aiShotProjection();
14951
+ MAX_STRIP_FRAMES = 20;
14551
14952
  localeIds = APP_STORE_LOCALES;
14552
14953
  MAX_DURABLE_MODEL_PROSE = 32e3;
14553
14954
  hexSchema = z10.string().trim().regex(/^#?([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/);
@@ -14749,7 +15150,7 @@ var init_refineProjectContracts = __esm({
14749
15150
  focus: focusSchema.optional(),
14750
15151
  baseLocale: localeCodeSchema,
14751
15152
  current: z10.object({
14752
- captions: z10.array(z10.string().max(240)).min(1).max(MAX_START_SCREENSHOTS),
15153
+ captions: z10.array(z10.string().max(240)).min(1).max(MAX_STRIP_FRAMES),
14753
15154
  outputs: z10.array(z10.enum(START_OUTPUT_IDS)).min(1).max(START_OUTPUT_IDS.length),
14754
15155
  // Bounded by the CATALOG, not by six. Studio's Add-language dialog offers every catalog entry
14755
15156
  // with no ceiling and the reducer just appends, so a seventh language used to 400 every Agent
@@ -14758,15 +15159,16 @@ var init_refineProjectContracts = __esm({
14758
15159
  locales: z10.array(localeCodeSchema).min(1).max(localeIds.length),
14759
15160
  designPreset: z10.enum(START_DESIGN_PRESET_IDS).nullable(),
14760
15161
  layout: z10.enum(START_LAYOUT_IDS).nullable(),
14761
- frames: z10.array(currentShotSchema).min(1).max(MAX_START_SCREENSHOTS),
15162
+ frames: z10.array(currentShotSchema).min(1).max(MAX_STRIP_FRAMES),
14762
15163
  background: currentStripBackgroundSchema.optional(),
14763
- frameStyles: z10.array(currentFramePatchSchema).min(1).max(MAX_START_SCREENSHOTS).optional(),
14764
- // Structural editing can add blank frames without adding source screenshots. Keep the vision
14765
- // attachment ceiling at ten while allowing those real project frames back into correction
14766
- // cycles instead of rejecting the first successful `add` operation on a full strip.
14767
- structure: z10.array(currentFrameSchema).min(1).max(20).optional()
15164
+ frameStyles: z10.array(currentFramePatchSchema).min(1).max(MAX_STRIP_FRAMES).optional(),
15165
+ // Structural editing can add blank frames without adding source screenshots, so this and every
15166
+ // other per-frame array on `current` share `MAX_STRIP_FRAMES` rather than the vision-upload
15167
+ // ceiling — a real project frame belongs back in correction cycles instead of rejecting the
15168
+ // first successful `add`/`split` operation on a full strip (#838).
15169
+ structure: z10.array(currentFrameSchema).min(1).max(MAX_STRIP_FRAMES).optional()
14768
15170
  }),
14769
- screenshots: z10.array(visionScreenshotSchema).min(1).max(MAX_START_SCREENSHOTS),
15171
+ screenshots: z10.array(visionScreenshotSchema).min(1).max(MAX_STRIP_FRAMES),
14770
15172
  // Private refs from Studio. Opaque asset handles are derived server-side, so a client cannot
14771
15173
  // plant its own handle table or smuggle a ref into model-visible data.
14772
15174
  attachments: refinementAttachmentsSchema.optional(),
@@ -14793,19 +15195,24 @@ var init_refineProjectContracts = __esm({
14793
15195
  frames: z10.array(z10.number().int().min(0).max(31)).min(1).max(2),
14794
15196
  depthPct: z10.number().int().min(0).max(100),
14795
15197
  anchored: z10.boolean(),
14796
- clearAtScale: z10.number().min(0.05).max(1).optional()
15198
+ clearAtScale: z10.number().min(0.05).max(1).optional(),
15199
+ clearAtMaxWidth: z10.number().min(0.1).max(1).optional()
14797
15200
  })).max(24).optional(),
14798
15201
  previous: z10.object({
14799
15202
  actualDiff: z10.array(z10.string().trim().min(1).max(300)).max(24),
14800
15203
  inspection: z10.string().trim().min(1).max(MAX_DURABLE_MODEL_PROSE),
14801
15204
  cycle: z10.number().int().min(1).max(2),
14802
- // The creating structural operations earlier cycles of this turn already performed, so the
14803
- // prompt can list them and the model stops re-adding what it added. Optional: an older client
14804
- // sends none, and the coordinator drops a repeat regardless.
15205
+ // The creating and removing structural operations earlier cycles of this turn already
15206
+ // performed, so the prompt can list them and the model stops re-doing what it did. Optional: an
15207
+ // older client sends none, and the coordinator drops a repeat regardless.
14805
15208
  appliedOperations: z10.array(z10.object({
14806
15209
  op: z10.string().trim().min(1).max(40),
14807
15210
  target: z10.string().trim().min(1).max(180)
14808
- })).max(32).optional()
15211
+ })).max(32).optional(),
15212
+ sourceCaptions: z10.array(z10.object({
15213
+ frameId: frameIdSchema,
15214
+ captions: z10.array(z10.string().max(240)).max(32)
15215
+ })).max(MAX_STRIP_FRAMES).optional()
14809
15216
  }).optional()
14810
15217
  }).superRefine((value, context) => {
14811
15218
  if (value.sessionId == null !== (value.turnId == null)) {
@@ -14964,21 +15371,21 @@ var init_refineProjectContracts = __esm({
14964
15371
  baseLocale: "the base language",
14965
15372
  // The frame ceiling rides on all four fields it can surface through: Zod reports in declaration
14966
15373
  // order, so any one of them can be the fault the user actually reads.
14967
- "current.captions": `the caption list, one per frame and at most ${MAX_START_SCREENSHOTS}`,
15374
+ "current.captions": `the caption list, one per frame and at most ${MAX_STRIP_FRAMES}`,
14968
15375
  "current.outputs": "the output size list",
14969
15376
  "current.locales": "the language list on this project",
14970
15377
  "current.designPreset": "the design preset",
14971
15378
  "current.layout": "the layout template",
14972
- "current.frames": `the per-frame device styling, at most ${MAX_START_SCREENSHOTS} frames`,
15379
+ "current.frames": `the per-frame device styling, at most ${MAX_STRIP_FRAMES} frames`,
14973
15380
  "current.frames.customColor": "a custom device colour",
14974
15381
  "current.frames.clayCustom": "a custom clay colour",
14975
15382
  "current.background": "the strip background",
14976
- "current.frameStyles": `the per-frame caption styling, at most ${MAX_START_SCREENSHOTS} frames`,
15383
+ "current.frameStyles": `the per-frame caption styling, at most ${MAX_STRIP_FRAMES} frames`,
14977
15384
  "current.frameStyles.captionStyle.color": "a caption colour",
14978
15385
  "current.frameStyles.background": "a frame's own background",
14979
- "current.structure": "the frame structure of this project",
15386
+ "current.structure": `the frame structure of this project, at most ${MAX_STRIP_FRAMES} frames`,
14980
15387
  current: "the current state of this project",
14981
- screenshots: `the screenshot list, at most ${MAX_START_SCREENSHOTS} per Agent turn`,
15388
+ screenshots: `the screenshot list, at most ${MAX_STRIP_FRAMES} per Agent turn`,
14982
15389
  attachments: `the screenshots offered to this turn, at most ${MAX_START_SCREENSHOTS}`,
14983
15390
  assets: `the supplied-screenshot handles for this turn, at most ${MAX_START_SCREENSHOTS}`,
14984
15391
  composedPreview: "the composed preview of the current strip",
@@ -15101,6 +15508,36 @@ async function compactVisionImage(data, name) {
15101
15508
  }
15102
15509
  throw new Error("A project screenshot could not be reduced for analysis.");
15103
15510
  }
15511
+ async function withBoundAttachments(stored, state, variant, boundAssets, assetTable, readBytes) {
15512
+ const bound = state.shots.flatMap((shot) => {
15513
+ const assetId = boundAssets.get(shot.id);
15514
+ const ref = assetId ? assetTable.sources[assetId]?.ref : void 0;
15515
+ return ref ? [{ shot, ref }] : [];
15516
+ });
15517
+ if (bound.length === 0) return stored;
15518
+ const bytesByRef = await readBytes();
15519
+ if (!bytesByRef) return stored;
15520
+ const overlaid = /* @__PURE__ */ new Map();
15521
+ for (const { shot, ref } of bound) {
15522
+ const bytes = bytesByRef.get(ref);
15523
+ if (!bytes) continue;
15524
+ overlaid.set(shot.id, {
15525
+ shotId: shot.id,
15526
+ frameName: shot.frameName,
15527
+ variant,
15528
+ bytes: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64"),
15529
+ source: "supplied"
15530
+ });
15531
+ }
15532
+ if (overlaid.size === 0) return stored;
15533
+ return {
15534
+ screenManifest: [
15535
+ ...stored.screenManifest.filter((entry) => !overlaid.has(entry.shotId)),
15536
+ ...overlaid.values()
15537
+ ],
15538
+ missing: stored.missing
15539
+ };
15540
+ }
15104
15541
  async function renderedTurn(deps, state, screens) {
15105
15542
  const projectFile = serializeProject(state);
15106
15543
  const variant = projectRenderVariant(projectFile, state.baseLocale);
@@ -15324,6 +15761,16 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
15324
15761
  const allowance = { remaining: null, refunded: 0, settled: false };
15325
15762
  let storedRecord;
15326
15763
  let screenSources = null;
15764
+ let boundAssets = /* @__PURE__ */ new Map();
15765
+ let attachmentBytes = null;
15766
+ const readAttachmentBytes = () => {
15767
+ if (!runtime.readAttachments) return Promise.resolve(null);
15768
+ attachmentBytes ??= runtime.readAttachments(project2.id, attachments.map((attachment) => attachment.ref)).catch((error) => {
15769
+ runtime.log("mcp_agent_turn_attachment_read_failed", { turnKey, ...safeFailureDiagnostic(error) });
15770
+ return null;
15771
+ });
15772
+ return attachmentBytes;
15773
+ };
15327
15774
  const context = {
15328
15775
  userId: deps.userId,
15329
15776
  projectId: project2.id,
@@ -15365,10 +15812,11 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
15365
15812
  renderComposed: async (state) => {
15366
15813
  if (!screenSources || "error" in screenSources) throw new Error("The project screenshots are unavailable.");
15367
15814
  const variant = projectRenderVariant(serializeProject(state), state.baseLocale);
15368
- const resolved = await screenSources.resolve(variant);
15369
- if (screenSources.expected.length > 0 && resolved.screenManifest.length === 0) {
15815
+ const stored = await screenSources.resolve(variant);
15816
+ if (screenSources.expected.length > 0 && stored.screenManifest.length === 0 && boundAssets.size === 0) {
15370
15817
  throw new Error("The project has no readable screenshots for Agent inspection.");
15371
15818
  }
15819
+ const resolved = await withBoundAttachments(stored, state, variant, boundAssets, assetTable, readAttachmentBytes);
15372
15820
  return renderedTurn(deps, state, resolved);
15373
15821
  },
15374
15822
  unresolvedLayout: (rendered) => describeUnresolvedLayout(rendered.layoutFindings),
@@ -15407,6 +15855,9 @@ async function runMcpProjectAgentTurn(deps, input, runtime) {
15407
15855
  return write.error === "conflict" ? { ok: false, reason: "stale_version" } : { ok: false, reason: "write_failed" };
15408
15856
  },
15409
15857
  emit: async (event) => {
15858
+ if (event.type === "draft_applied") {
15859
+ boundAssets = new Map(event.assetBindings.map((binding) => [binding.deviceId, binding.assetId]));
15860
+ }
15410
15861
  if (event.type === "turn_completed") {
15411
15862
  const generationIds = event.result.legacy.passes.map((pass) => pass.generationId);
15412
15863
  if (generationIds.length > 0) {
@@ -15540,6 +15991,12 @@ function localAgentTurnRuntime(bridge) {
15540
15991
  }
15541
15992
  });
15542
15993
  },
15994
+ // #882 — the inspection render's pixels for devices a draft added or re-pointed. The control
15995
+ // plane authorizes and signs; the bytes come down here, where the render runs.
15996
+ readAttachments: async (projectId, refs) => {
15997
+ const screens = await bridge.readAttachmentScreens(projectId, refs);
15998
+ return new Map(await Promise.all(screens.map(async ({ ref, url }) => [ref, new Uint8Array(await downloadPng(url, "Supplied screenshot"))])));
15999
+ },
15543
16000
  writeProjectRecord: async (projectId, expectedVersion, record8) => {
15544
16001
  try {
15545
16002
  const written = await bridge.updateProjectRecord(projectId, record8, expectedVersion);
@@ -15577,6 +16034,7 @@ var init_localRefineTurn = __esm({
15577
16034
  init_agentTurnAdapter();
15578
16035
  init_hostedBridge();
15579
16036
  init_refineProjectReceipt();
16037
+ init_uploads();
15580
16038
  }
15581
16039
  });
15582
16040
 
@@ -15724,7 +16182,8 @@ var init_projectClaimClient = __esm({
15724
16182
  import { readFile as readFile4 } from "node:fs/promises";
15725
16183
  function localToolDeps(token2, options = {}) {
15726
16184
  const base = {
15727
- renderer: new StripRenderer({ serve: "static" }),
16185
+ // #889 — the first render on a machine with no Playwright Chromium downloads it once, here only.
16186
+ renderer: new StripRenderer({ serve: "static", installBrowser: () => installChromium() }),
15728
16187
  // Token-backed cloud writes cross the same authority in the control plane. This local handler
15729
16188
  // stays permissive to avoid a second billing lookup; tokenless holds fail at persistence.
15730
16189
  authorizeCapability: async (request) => ({
@@ -15914,6 +16373,7 @@ var init_localDeps = __esm({
15914
16373
  "use strict";
15915
16374
  init_env();
15916
16375
  init_renderer();
16376
+ init_browserInstall();
15917
16377
  init_hostedBridge();
15918
16378
  init_localRefineTurn();
15919
16379
  init_projectClaimClient();
@@ -15939,10 +16399,10 @@ __export(localCredentials_exports, {
15939
16399
  });
15940
16400
  import { chmod, mkdir, readFile as readFile5, rename, unlink, writeFile } from "node:fs/promises";
15941
16401
  import { homedir } from "node:os";
15942
- import { dirname, join as join4 } from "node:path";
16402
+ import { dirname as dirname2, join as join5 } from "node:path";
15943
16403
  function credentialsPath() {
15944
- const dir = process.env.SHOTOPS_CONFIG_DIR || join4(homedir(), ".shotops");
15945
- return join4(dir, "credentials.json");
16404
+ const dir = process.env.SHOTOPS_CONFIG_DIR || join5(homedir(), ".shotops");
16405
+ return join5(dir, "credentials.json");
15946
16406
  }
15947
16407
  async function readFileJson() {
15948
16408
  let raw;
@@ -15961,7 +16421,7 @@ async function readFileJson() {
15961
16421
  }
15962
16422
  async function writeFileJson(next) {
15963
16423
  const target = credentialsPath();
15964
- await mkdir(dirname(target), { recursive: true, mode: 448 });
16424
+ await mkdir(dirname2(target), { recursive: true, mode: 448 });
15965
16425
  const tmp = `${target}.${process.pid}.tmp`;
15966
16426
  await writeFile(tmp, `${JSON.stringify(next, null, 2)}
15967
16427
  `, { mode: 384 });
@@ -16456,7 +16916,7 @@ var init_releaseCommitments = __esm({
16456
16916
  import { execFile } from "node:child_process";
16457
16917
  import { mkdtemp, open, readFile as readFile6, readdir, rm, stat, writeFile as writeFile2 } from "node:fs/promises";
16458
16918
  import { tmpdir } from "node:os";
16459
- import { extname as extname2, join as join5, resolve } from "node:path";
16919
+ import { extname as extname2, join as join6, resolve } from "node:path";
16460
16920
  import { inflateSync } from "fflate";
16461
16921
  async function readAt(path, offset, length) {
16462
16922
  const handle = await open(path, "r");
@@ -16537,8 +16997,8 @@ function plistStringArray(xml, key) {
16537
16997
  }
16538
16998
  async function parsePlist(bytes, source, runner) {
16539
16999
  if (bytes.subarray(0, 8).toString() === "bplist00") {
16540
- const scratch = await mkdtemp(join5(tmpdir(), "shotops-plist-"));
16541
- const path = join5(scratch, "Info.plist");
17000
+ const scratch = await mkdtemp(join6(tmpdir(), "shotops-plist-"));
17001
+ const path = join6(scratch, "Info.plist");
16542
17002
  try {
16543
17003
  await writeFile2(path, bytes, { mode: 384 });
16544
17004
  const json = await runner("plutil", ["-convert", "json", "-o", "-", path]);
@@ -16596,10 +17056,10 @@ async function findInfoPlists(root, path = root, found = []) {
16596
17056
  const entries = await readdir(path, { withFileTypes: true });
16597
17057
  for (const entry of entries) {
16598
17058
  if (entry.isSymbolicLink()) continue;
16599
- const child = join5(path, entry.name);
17059
+ const child = join6(path, entry.name);
16600
17060
  if (entry.isDirectory()) {
16601
17061
  if (entry.name.endsWith(".app")) {
16602
- const candidate = join5(child, "Contents", "Info.plist");
17062
+ const candidate = join6(child, "Contents", "Info.plist");
16603
17063
  if ((await stat(candidate).catch(() => null))?.isFile()) found.push(candidate);
16604
17064
  }
16605
17065
  await findInfoPlists(root, child, found);
@@ -16614,8 +17074,8 @@ async function inspectPkg(path, runner) {
16614
17074
  if (!/status:\s*signed|signed by/i.test(signature.toString("utf8")) || /no signature|unsigned/i.test(signature.toString("utf8"))) {
16615
17075
  throw new Error("PKG is unsigned or pkgutil could not verify its signature");
16616
17076
  }
16617
- const scratch = await mkdtemp(join5(tmpdir(), "shotops-pkg-"));
16618
- const expanded = join5(scratch, "expanded");
17077
+ const scratch = await mkdtemp(join6(tmpdir(), "shotops-pkg-"));
17078
+ const expanded = join6(scratch, "expanded");
16619
17079
  try {
16620
17080
  await runner("pkgutil", ["--expand-full", path, expanded]).catch(() => {
16621
17081
  throw new Error("PKG is not a supported flat package (pkgutil --expand-full failed)");
@@ -16676,7 +17136,7 @@ var init_releaseBuild = __esm({
16676
17136
 
16677
17137
  // src/release/desiredReleaseSchema.ts
16678
17138
  import { readFile as readFile7 } from "node:fs/promises";
16679
- import { join as join6 } from "node:path";
17139
+ import { join as join7 } from "node:path";
16680
17140
  function blobPath(digestValue) {
16681
17141
  if (!SHA256.test(digestValue)) throw new Error(`not a SHA-256 digest: ${digestValue}`);
16682
17142
  return `blobs/${digestValue.slice(0, 2)}/${digestValue}`;
@@ -17272,7 +17732,7 @@ function parseCanonicalJson(text3, path) {
17272
17732
  return value;
17273
17733
  }
17274
17734
  async function readDesiredReleaseManifest(workspace) {
17275
- const path = join6(workspace, DESIRED_RELEASE_FILENAME);
17735
+ const path = join7(workspace, DESIRED_RELEASE_FILENAME);
17276
17736
  let text3;
17277
17737
  try {
17278
17738
  text3 = await readFile7(path, "utf8");
@@ -17305,7 +17765,7 @@ function inspectDesiredReleaseValue(value) {
17305
17765
  };
17306
17766
  }
17307
17767
  async function inspectDesiredReleaseWorkspace(workspace) {
17308
- const path = join6(workspace, DESIRED_RELEASE_FILENAME);
17768
+ const path = join7(workspace, DESIRED_RELEASE_FILENAME);
17309
17769
  let text3;
17310
17770
  try {
17311
17771
  text3 = await readFile7(path, "utf8");
@@ -17430,7 +17890,7 @@ var init_desiredReleaseSchema = __esm({
17430
17890
 
17431
17891
  // src/release/releaseRepository.ts
17432
17892
  import { lstat as lstat2, readFile as readFile8, readdir as readdir2, stat as stat2 } from "node:fs/promises";
17433
- import { basename as basename2, extname as extname3, isAbsolute as isAbsolute3, join as join7, resolve as resolve2 } from "node:path";
17893
+ import { basename as basename2, extname as extname3, isAbsolute as isAbsolute3, join as join8, resolve as resolve2 } from "node:path";
17434
17894
  async function readSmallText(path, label) {
17435
17895
  const { size } = await regularFile(path, label);
17436
17896
  if (size > MAX_TEXT_BYTES) throw new Error(`${label} exceeds ${MAX_TEXT_BYTES} bytes: ${path}`);
@@ -17453,11 +17913,11 @@ async function readFieldDirectory(root, path, allowed, label) {
17453
17913
  const entries = await readdir2(path, { withFileTypes: true });
17454
17914
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
17455
17915
  if (!entry.isFile() || entry.isSymbolicLink() || extname3(entry.name) !== ".txt") {
17456
- throw new Error(`unknown ${label} input: ${sourcePath(root, join7(path, entry.name))}`);
17916
+ throw new Error(`unknown ${label} input: ${sourcePath(root, join8(path, entry.name))}`);
17457
17917
  }
17458
17918
  const key = basename2(entry.name, ".txt");
17459
- if (!allowed.has(key)) throw new Error(`unknown ${label} field: ${sourcePath(root, join7(path, entry.name))}`);
17460
- fields2[key] = await textValue(root, join7(path, entry.name), `${label} field`);
17919
+ if (!allowed.has(key)) throw new Error(`unknown ${label} field: ${sourcePath(root, join8(path, entry.name))}`);
17920
+ fields2[key] = await textValue(root, join8(path, entry.name), `${label} field`);
17461
17921
  }
17462
17922
  return fields2;
17463
17923
  }
@@ -17521,13 +17981,13 @@ async function readAppPreviews(root, previewRoot, allLocales, selectedLocales, a
17521
17981
  }
17522
17982
  for (const localeEntry of localeEntries.sort((a, b) => a.name.localeCompare(b.name))) {
17523
17983
  if (!localeEntry.isDirectory() || localeEntry.isSymbolicLink() || !allLocales.includes(localeEntry.name)) {
17524
- throw new Error(`unknown app preview locale/input: ${sourcePath(root, join7(previewRoot, localeEntry.name))}`);
17984
+ throw new Error(`unknown app preview locale/input: ${sourcePath(root, join8(previewRoot, localeEntry.name))}`);
17525
17985
  }
17526
17986
  const locale2 = localeEntry.name;
17527
- const files = await readdir2(join7(previewRoot, locale2), { withFileTypes: true });
17987
+ const files = await readdir2(join8(previewRoot, locale2), { withFileTypes: true });
17528
17988
  const positions = /* @__PURE__ */ new Map();
17529
17989
  for (const file of files.sort((a, b) => a.name.localeCompare(b.name))) {
17530
- const path = join7(previewRoot, locale2, file.name);
17990
+ const path = join8(previewRoot, locale2, file.name);
17531
17991
  if (!file.isFile() || file.isSymbolicLink() || !PREVIEW_EXTENSIONS.has(extname3(file.name).toLowerCase())) {
17532
17992
  throw new Error(`unknown app preview input: ${sourcePath(root, path)}`);
17533
17993
  }
@@ -17551,7 +18011,7 @@ async function readAppPreviews(root, previewRoot, allLocales, selectedLocales, a
17551
18011
  return result;
17552
18012
  }
17553
18013
  async function readReviewAttachment(root, metadataRoot) {
17554
- const pointer = join7(metadataRoot, "app_review_attachment_file.txt");
18014
+ const pointer = join8(metadataRoot, "app_review_attachment_file.txt");
17555
18015
  let value;
17556
18016
  try {
17557
18017
  value = await readSmallText(pointer, "app review attachment pointer");
@@ -17572,7 +18032,7 @@ async function normalizeRepositoryReleaseInputs(repository, allLocales, selected
17572
18032
  const root = resolve2(repository);
17573
18033
  const info = await stat2(root).catch(() => null);
17574
18034
  if (!info?.isDirectory()) throw new Error(`repository does not exist or is not a directory: ${repository}`);
17575
- const metadataRoot = join7(root, "fastlane", "metadata");
18035
+ const metadataRoot = join8(root, "fastlane", "metadata");
17576
18036
  const localized = [];
17577
18037
  const app = {};
17578
18038
  let review = {};
@@ -17590,7 +18050,7 @@ async function normalizeRepositoryReleaseInputs(repository, allLocales, selected
17590
18050
  if (error.code !== "ENOENT") throw error;
17591
18051
  }
17592
18052
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
17593
- const path = join7(metadataRoot, entry.name);
18053
+ const path = join8(metadataRoot, entry.name);
17594
18054
  if (entry.isSymbolicLink()) throw new Error(`metadata input must not be a symbolic link: ${sourcePath(root, path)}`);
17595
18055
  if (entry.isDirectory()) {
17596
18056
  if (entry.name === "review_information") review = await readFieldDirectory(root, path, REVIEW_METADATA, "review information");
@@ -17624,7 +18084,7 @@ async function normalizeRepositoryReleaseInputs(repository, allLocales, selected
17624
18084
  compliance,
17625
18085
  appPreviews: await readAppPreviews(
17626
18086
  root,
17627
- join7(root, "fastlane", "app-previews"),
18087
+ join8(root, "fastlane", "app-previews"),
17628
18088
  allLocales,
17629
18089
  new Set(selectedLocales),
17630
18090
  allOutputs,
@@ -17759,7 +18219,7 @@ var init_releaseRepository = __esm({
17759
18219
  import { createReadStream as createReadStream2, createWriteStream } from "node:fs";
17760
18220
  import { chmod as chmod2, lstat as lstat3, mkdir as mkdir2, mkdtemp as mkdtemp2, open as open2, readdir as readdir3, rename as rename2, rm as rm2, stat as stat3 } from "node:fs/promises";
17761
18221
  import { writeFile as writeFile3 } from "node:fs/promises";
17762
- import { dirname as dirname2, join as join8, relative as relative3, resolve as resolve3, sep as sep2 } from "node:path";
18222
+ import { dirname as dirname3, join as join9, relative as relative3, resolve as resolve3, sep as sep2 } from "node:path";
17763
18223
  import { pipeline } from "node:stream/promises";
17764
18224
  async function secureDirectory(path) {
17765
18225
  await mkdir2(path, { recursive: true, mode: 448 });
@@ -17816,9 +18276,9 @@ async function sealReleaseWorkspace(destination, manifest, contents, hooks = {})
17816
18276
  await assertConverged(target, manifestText, existing.isDirectory() && !existing.isSymbolicLink());
17817
18277
  return { reused: true };
17818
18278
  }
17819
- const parent = dirname2(target);
18279
+ const parent = dirname3(target);
17820
18280
  await secureDirectory(parent);
17821
- const temporary = await mkdtemp2(join8(parent, `${TEMPORARY_WORKSPACE_PREFIX}${process.pid}-`));
18281
+ const temporary = await mkdtemp2(join9(parent, `${TEMPORARY_WORKSPACE_PREFIX}${process.pid}-`));
17822
18282
  if (process.platform !== "win32") await chmod2(temporary, 448);
17823
18283
  let renamed = false;
17824
18284
  try {
@@ -17826,9 +18286,9 @@ async function sealReleaseWorkspace(destination, manifest, contents, hooks = {})
17826
18286
  for (const [path, artifacts] of expectedBlobs(manifest)) {
17827
18287
  const content = contents.get(path);
17828
18288
  if (!content) throw new Error(`sealed ${describe(artifacts)} has no bytes to write: ${path}`);
17829
- const file = join8(temporary, ...path.split("/"));
17830
- await secureDirectory(dirname2(file));
17831
- directories.add(dirname2(file));
18289
+ const file = join9(temporary, ...path.split("/"));
18290
+ await secureDirectory(dirname3(file));
18291
+ directories.add(dirname3(file));
17832
18292
  await copyBlob(content, file);
17833
18293
  if (process.platform !== "win32") await chmod2(file, 384);
17834
18294
  const written = await stat3(file);
@@ -17841,7 +18301,7 @@ async function sealReleaseWorkspace(destination, manifest, contents, hooks = {})
17841
18301
  await fsyncPath(file, false);
17842
18302
  await boundary("blob", path);
17843
18303
  }
17844
- const manifestPath = join8(temporary, DESIRED_RELEASE_FILENAME);
18304
+ const manifestPath = join9(temporary, DESIRED_RELEASE_FILENAME);
17845
18305
  await writeFile3(manifestPath, manifestText, { mode: 384 });
17846
18306
  if (process.platform !== "win32") await chmod2(manifestPath, 384);
17847
18307
  await fsyncPath(manifestPath, false);
@@ -17878,7 +18338,7 @@ async function assertConverged(target, manifestText, isDirectory) {
17878
18338
  async function walkFiles(root, current, found) {
17879
18339
  const entries = await readdir3(current, { withFileTypes: true });
17880
18340
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
17881
- const path = join8(current, entry.name);
18341
+ const path = join9(current, entry.name);
17882
18342
  if (entry.isSymbolicLink()) found.push(relative3(root, path).split(sep2).join("/"));
17883
18343
  else if (entry.isDirectory()) await walkFiles(root, path, found);
17884
18344
  else found.push(relative3(root, path).split(sep2).join("/"));
@@ -17889,7 +18349,7 @@ async function verifyReleaseWorkspace(path) {
17889
18349
  const { manifest, text: text3 } = await readDesiredReleaseManifest(root);
17890
18350
  const expected = expectedBlobs(manifest);
17891
18351
  for (const [blob, artifacts] of expected) {
17892
- const file = join8(root, ...blob.split("/"));
18352
+ const file = join9(root, ...blob.split("/"));
17893
18353
  const stats = await lstat3(file).catch(() => null);
17894
18354
  if (!stats || stats.isSymbolicLink() || !stats.isFile()) {
17895
18355
  throw new Error(`sealed ${describe(artifacts)} is missing or unsafe: ${blob}`);
@@ -17905,7 +18365,7 @@ async function verifyReleaseWorkspace(path) {
17905
18365
  return { path: root, manifest, text: text3, unexpected };
17906
18366
  }
17907
18367
  function sealedBlobFile(workspace, digest4) {
17908
- return join8(resolve3(workspace), ...blobPath(digest4).split("/"));
18368
+ return join9(resolve3(workspace), ...blobPath(digest4).split("/"));
17909
18369
  }
17910
18370
  async function collectReleaseWorkspaceGarbage(root, options = {}) {
17911
18371
  const base = resolve3(root);
@@ -17920,7 +18380,7 @@ async function collectReleaseWorkspaceGarbage(root, options = {}) {
17920
18380
  throw error;
17921
18381
  }
17922
18382
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
17923
- const path = join8(base, entry.name);
18383
+ const path = join9(base, entry.name);
17924
18384
  if (!entry.name.startsWith(TEMPORARY_WORKSPACE_PREFIX)) {
17925
18385
  result.kept += 1;
17926
18386
  continue;
@@ -17930,7 +18390,7 @@ async function collectReleaseWorkspaceGarbage(root, options = {}) {
17930
18390
  result.skipped.push({ path, reason: "not a real directory" });
17931
18391
  continue;
17932
18392
  }
17933
- const complete = await lstat3(join8(path, DESIRED_RELEASE_FILENAME)).then((file) => file.isFile()).catch(() => false);
18393
+ const complete = await lstat3(join9(path, DESIRED_RELEASE_FILENAME)).then((file) => file.isFile()).catch(() => false);
17934
18394
  if (complete) {
17935
18395
  result.skipped.push({ path, reason: "holds a complete desired release" });
17936
18396
  continue;
@@ -17963,7 +18423,7 @@ var init_releaseWorkspace = __esm({
17963
18423
 
17964
18424
  // src/release/releaseManifest.ts
17965
18425
  import { homedir as homedir2 } from "node:os";
17966
- import { isAbsolute as isAbsolute4, join as join9, resolve as resolve4 } from "node:path";
18426
+ import { isAbsolute as isAbsolute4, join as join10, resolve as resolve4 } from "node:path";
17967
18427
  function chosenAxis(all, explicit, label) {
17968
18428
  const uniqueAll = Array.from(new Set(all.map((item) => item.trim()).filter(Boolean)));
17969
18429
  if (uniqueAll.length === 0) throw new Error(`project has no authored ${label}`);
@@ -17997,7 +18457,7 @@ function resolveBundleId(explicit, project2, build) {
17997
18457
  return expected;
17998
18458
  }
17999
18459
  function releaseWorkspaceRoot() {
18000
- return join9(process.env.SHOTOPS_CONFIG_DIR || join9(homedir2(), ".shotops"), "release-workspaces");
18460
+ return join10(process.env.SHOTOPS_CONFIG_DIR || join10(homedir2(), ".shotops"), "release-workspaces");
18001
18461
  }
18002
18462
  function releaseCopyWords(inputs, locale2) {
18003
18463
  const entry = inputs.localized.find((candidate) => candidate.locale === locale2);
@@ -18019,7 +18479,7 @@ async function assembleDesiredRelease(input) {
18019
18479
  const localeScope = chosenAxis(project2.locales, input.locales, "locales");
18020
18480
  const outputScope = chosenAxis(project2.outputs.map((output2) => output2.id), input.outputs, "outputs");
18021
18481
  const selectedOutputs2 = outputScope.selected.map((id) => project2.outputs.find((output2) => output2.id === id));
18022
- const buildPath = input.build ? isAbsolute4(input.build) ? input.build : join9(repository, input.build) : null;
18482
+ const buildPath = input.build ? isAbsolute4(input.build) ? input.build : join10(repository, input.build) : null;
18023
18483
  const build = buildPath ? await inspectBuildArtifact(buildPath, repository) : null;
18024
18484
  if (build && (isAbsolute4(build.source) || build.source.startsWith(".."))) {
18025
18485
  throw new Error(`build must live inside the repository so the desired release records no absolute path: ${build.source}`);
@@ -18131,7 +18591,7 @@ async function assembleDesiredRelease(input) {
18131
18591
  artifacts.push({
18132
18592
  role: "app_preview",
18133
18593
  custody: "sealed",
18134
- path: seal(preview2.sha256, { file: join9(repository, ...preview2.source.split("/")) }),
18594
+ path: seal(preview2.sha256, { file: join10(repository, ...preview2.source.split("/")) }),
18135
18595
  sha256: preview2.sha256,
18136
18596
  bytes: preview2.bytes,
18137
18597
  mediaType: mediaTypeForPath(preview2.source),
@@ -18149,7 +18609,7 @@ async function assembleDesiredRelease(input) {
18149
18609
  role: "review_attachment",
18150
18610
  custody: "sealed",
18151
18611
  path: seal(repositoryInputs.reviewAttachment.sha256, {
18152
- file: join9(repository, ...repositoryInputs.reviewAttachment.source.split("/"))
18612
+ file: join10(repository, ...repositoryInputs.reviewAttachment.source.split("/"))
18153
18613
  }),
18154
18614
  sha256: repositoryInputs.reviewAttachment.sha256,
18155
18615
  bytes: repositoryInputs.reviewAttachment.bytes,
@@ -18166,11 +18626,11 @@ async function assembleDesiredRelease(input) {
18166
18626
  const ccat = repositoryInputs.compliance?.export_compliance_ccat_file;
18167
18627
  if (typeof ccat === "string" && ccat.trim()) {
18168
18628
  if (isAbsolute4(ccat)) throw new Error("export_compliance_ccat_file must be repository-relative");
18169
- const document = await referencedFile(repository, join9(repository, ccat));
18629
+ const document = await referencedFile(repository, join10(repository, ccat));
18170
18630
  artifacts.push({
18171
18631
  role: "compliance_file",
18172
18632
  custody: "sealed",
18173
- path: seal(document.sha256, { file: join9(repository, ...document.source.split("/")) }),
18633
+ path: seal(document.sha256, { file: join10(repository, ...document.source.split("/")) }),
18174
18634
  sha256: document.sha256,
18175
18635
  bytes: document.bytes,
18176
18636
  mediaType: mediaTypeForPath(document.source),
@@ -18272,7 +18732,7 @@ async function assembleDesiredRelease(input) {
18272
18732
  };
18273
18733
  const desiredId = computeDesiredId(payload);
18274
18734
  const manifest = parseDesiredReleaseManifest({ ...payload, desiredId });
18275
- const destination = resolve4(input.destination || join9(releaseWorkspaceRoot(), desiredId));
18735
+ const destination = resolve4(input.destination || join10(releaseWorkspaceRoot(), desiredId));
18276
18736
  const { reused } = await sealReleaseWorkspace(destination, manifest, sealed, input.seal ?? {});
18277
18737
  if (reused) await verifyReleaseWorkspace(destination);
18278
18738
  return { path: destination, manifest, reused };
@@ -19068,7 +19528,7 @@ var init_releaseOperations = __esm({
19068
19528
  // src/release/releaseReviewArtifact.ts
19069
19529
  import { randomBytes as randomBytes3 } from "node:crypto";
19070
19530
  import { lstat as lstat4, mkdir as mkdir3, readFile as readFile9, rename as rename3, unlink as unlink2, writeFile as writeFile4 } from "node:fs/promises";
19071
- import { basename as basename3, dirname as dirname3, isAbsolute as isAbsolute5, join as join10, resolve as resolve5 } from "node:path";
19531
+ import { basename as basename3, dirname as dirname4, isAbsolute as isAbsolute5, join as join11, resolve as resolve5 } from "node:path";
19072
19532
  import { strFromU8, strToU8 as strToU82, unzipSync, zipSync as zipSync2 } from "fflate";
19073
19533
  function isRecord5(value) {
19074
19534
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -19167,7 +19627,7 @@ function screenshotReferences(plan) {
19167
19627
  }
19168
19628
  async function readScreenshot(root, reference) {
19169
19629
  if (isAbsolute5(reference.source)) throw new Error(`release screenshot path must be workspace-relative: ${reference.source}`);
19170
- const path = join10(root, ...blobPath(reference.sha256).split("/"));
19630
+ const path = join11(root, ...blobPath(reference.sha256).split("/"));
19171
19631
  const info = await lstat4(path).catch(() => null);
19172
19632
  if (!info?.isFile() || info.isSymbolicLink() || info.size !== reference.bytes) {
19173
19633
  throw new Error(`release screenshot is missing or changed: ${reference.source}`);
@@ -19180,8 +19640,8 @@ async function readScreenshot(root, reference) {
19180
19640
  return bytes;
19181
19641
  }
19182
19642
  async function atomicPrivateWrite(path, bytes) {
19183
- await mkdir3(dirname3(path), { recursive: true, mode: 448 });
19184
- const temporary = join10(dirname3(path), `.${basename3(path)}.${process.pid}.${randomBytes3(6).toString("hex")}.tmp`);
19643
+ await mkdir3(dirname4(path), { recursive: true, mode: 448 });
19644
+ const temporary = join11(dirname4(path), `.${basename3(path)}.${process.pid}.${randomBytes3(6).toString("hex")}.tmp`);
19185
19645
  try {
19186
19646
  await writeFile4(temporary, bytes, { mode: 384 });
19187
19647
  await rename3(temporary, path);
@@ -19307,16 +19767,16 @@ async function loadArtifact(path, now) {
19307
19767
  async function loadPlan(path, now) {
19308
19768
  const plan = await readCanonicalPlan(path);
19309
19769
  const screenshots2 = /* @__PURE__ */ new Map();
19310
- for (const reference of screenshotReferences(plan)) screenshots2.set(reference.sha256, await readScreenshot(dirname3(path), reference));
19770
+ for (const reference of screenshotReferences(plan)) screenshots2.set(reference.sha256, await readScreenshot(dirname4(path), reference));
19311
19771
  return { plan, screenshots: screenshots2, sourcePath: path, portable: false, approval: releasePlanApproval(plan, now) };
19312
19772
  }
19313
19773
  async function loadReleaseReviewSource(source, now = /* @__PURE__ */ new Date()) {
19314
19774
  let path = resolve5(source);
19315
19775
  const info = await lstat4(path).catch(() => null);
19316
19776
  if (info?.isDirectory()) {
19317
- const artifact = join10(path, "release-review.zip");
19777
+ const artifact = join11(path, "release-review.zip");
19318
19778
  const directory = path;
19319
- path = await lstat4(artifact).then(() => artifact).catch(() => join10(directory, "release-plan.json"));
19779
+ path = await lstat4(artifact).then(() => artifact).catch(() => join11(directory, "release-plan.json"));
19320
19780
  if (!await lstat4(path).catch(() => null)) {
19321
19781
  const inspected = await inspectDesiredReleaseWorkspace(directory).catch(() => null);
19322
19782
  if (inspected) {
@@ -19352,7 +19812,7 @@ var init_releaseReviewArtifact = __esm({
19352
19812
  // src/release/releaseJournal.ts
19353
19813
  import { chmod as chmod3, mkdir as mkdir4, open as open3, readdir as readdir4, readFile as readFile10, rename as rename4, unlink as unlink3, writeFile as writeFile5 } from "node:fs/promises";
19354
19814
  import { hostname } from "node:os";
19355
- import { join as join11, resolve as resolve6 } from "node:path";
19815
+ import { join as join12, resolve as resolve6 } from "node:path";
19356
19816
  function assertDetail(detail) {
19357
19817
  if (typeof detail !== "object" || detail === null || Array.isArray(detail)) {
19358
19818
  throw new ReleaseJournalError("corrupt", "journal detail is not an object");
@@ -19533,8 +19993,8 @@ async function openReleaseAttemptJournal(options) {
19533
19993
  await mkdir4(directory, { recursive: true, mode: 448 });
19534
19994
  if (process.platform !== "win32") await chmod3(directory, 448).catch(() => {
19535
19995
  });
19536
- const path = join11(directory, journalFileName(options.attemptId));
19537
- const releaseLock = await acquireAttemptLock(join11(directory, lockFileName(options.attemptId)), now);
19996
+ const path = join12(directory, journalFileName(options.attemptId));
19997
+ const releaseLock = await acquireAttemptLock(join12(directory, lockFileName(options.attemptId)), now);
19538
19998
  let entries;
19539
19999
  let checksum;
19540
20000
  try {
@@ -19619,7 +20079,7 @@ async function readLiveAttemptJournals(directory) {
19619
20079
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
19620
20080
  const match = entry.isFile() ? JOURNAL_FILE.exec(entry.name) : null;
19621
20081
  if (!match) continue;
19622
- const path = join11(directory, entry.name);
20082
+ const path = join12(directory, entry.name);
19623
20083
  const text3 = await readFile10(path, "utf8").catch(() => null);
19624
20084
  if (text3 === null) continue;
19625
20085
  const header = safeJson(text3.split("\n", 1)[0]);
@@ -19633,7 +20093,7 @@ async function readLiveAttemptJournals(directory) {
19633
20093
  return found;
19634
20094
  }
19635
20095
  function attemptDirectoryOf(workspace) {
19636
- return join11(resolve6(workspace), ATTEMPT_DIRECTORY_NAME);
20096
+ return join12(resolve6(workspace), ATTEMPT_DIRECTORY_NAME);
19637
20097
  }
19638
20098
  async function holdsLiveAttemptJournal(candidate) {
19639
20099
  const own = await readLiveAttemptJournals(attemptDirectoryOf(candidate));
@@ -19941,7 +20401,7 @@ var init_releaseReceipt = __esm({
19941
20401
 
19942
20402
  // src/release/releaseCommitBuild.ts
19943
20403
  import { chmod as chmod4, link, copyFile, mkdtemp as mkdtemp3, rm as rm3 } from "node:fs/promises";
19944
- import { basename as basename4, dirname as dirname4, join as join12 } from "node:path";
20404
+ import { basename as basename4, dirname as dirname5, join as join13 } from "node:path";
19945
20405
  function buildEventsOf(entries, attemptId) {
19946
20406
  const events = [];
19947
20407
  for (const entry of entries) {
@@ -20095,10 +20555,10 @@ function journalVouchesFor(events, approved, evidence) {
20095
20555
  return namedRemoteIds.size === 0 || namedRemoteIds.size === 1 && namedRemoteIds.has(evidence.remoteBuildId);
20096
20556
  }
20097
20557
  async function stageApprovedBytes(approved, workspace) {
20098
- const dir = await mkdtemp3(join12(dirname4(workspace), TRANSFER_STAGING_PREFIX));
20558
+ const dir = await mkdtemp3(join13(dirname5(workspace), TRANSFER_STAGING_PREFIX));
20099
20559
  if (process.platform !== "win32") await chmod4(dir, 448);
20100
20560
  const name = approved.fileName.toLowerCase().endsWith(`.${approved.format}`) ? approved.fileName : `${approved.fileName}.${approved.format}`;
20101
- const file = join12(dir, name);
20561
+ const file = join13(dir, name);
20102
20562
  try {
20103
20563
  await link(approved.blobFile, file);
20104
20564
  } catch {
@@ -20256,7 +20716,7 @@ async function commitReleaseBuild(input, deps) {
20256
20716
  const output2 = await deps.runTransporter(
20257
20717
  input.transporterPath,
20258
20718
  ["-m", "upload", "-assetFile", staged.file, "-apiKey", input.setup.keyId, "-apiIssuer", input.setup.issuerId],
20259
- { ...process.env, API_PRIVATE_KEYS_DIR: dirname4(input.setup.keyPath) }
20719
+ { ...process.env, API_PRIVATE_KEYS_DIR: dirname5(input.setup.keyPath) }
20260
20720
  );
20261
20721
  answer = readTransporterAnswer(output2);
20262
20722
  } catch {
@@ -20429,7 +20889,7 @@ __export(releaseRecovery_exports, {
20429
20889
  });
20430
20890
  import { readFile as readFile11, readdir as readdir5, rename as rename5, stat as stat4 } from "node:fs/promises";
20431
20891
  import { hostname as hostname2 } from "node:os";
20432
- import { join as join13, relative as relative4, resolve as resolve7, sep as sep3 } from "node:path";
20892
+ import { join as join14, relative as relative4, resolve as resolve7, sep as sep3 } from "node:path";
20433
20893
  function normalizedNow(now) {
20434
20894
  const value = now().toISOString();
20435
20895
  if (new Date(value).toISOString() !== value) throw new ReleaseRecoveryError("recovery clock did not return a normalized timestamp");
@@ -20489,7 +20949,7 @@ function processIsAlive2(pid) {
20489
20949
  }
20490
20950
  }
20491
20951
  async function lockState(root, attemptId) {
20492
- const text3 = await readFile11(join13(root, `attempt-${attemptId}.lock`), "utf8").catch((error) => {
20952
+ const text3 = await readFile11(join14(root, `attempt-${attemptId}.lock`), "utf8").catch((error) => {
20493
20953
  if (error.code === "ENOENT") return null;
20494
20954
  throw error;
20495
20955
  });
@@ -20513,7 +20973,7 @@ async function inspectLocalEvidence(input) {
20513
20973
  const workspacePath = resolve7(input.workspace);
20514
20974
  const info = await stat4(workspacePath).catch(() => null);
20515
20975
  if (!info?.isDirectory()) throw new ReleaseRecoveryError("the release workspace does not exist");
20516
- const planPath = resolve7(input.planPath ?? join13(workspacePath, PLAN_FILENAME));
20976
+ const planPath = resolve7(input.planPath ?? join14(workspacePath, PLAN_FILENAME));
20517
20977
  const plan = await readPlan(planPath);
20518
20978
  const attemptRoot = resolve7(input.attemptRoot ?? attemptDirectoryOf(workspacePath));
20519
20979
  const diagnostics = [];
@@ -20532,8 +20992,8 @@ async function inspectLocalEvidence(input) {
20532
20992
  let finalReceipt = null;
20533
20993
  let partialReceipt = null;
20534
20994
  let receiptState = "none";
20535
- const finalPath = join13(attemptRoot, RECEIPT_FILENAME);
20536
- const partialPath = join13(attemptRoot, PARTIAL_RECEIPT_FILENAME);
20995
+ const finalPath = join14(attemptRoot, RECEIPT_FILENAME);
20996
+ const partialPath = join14(attemptRoot, PARTIAL_RECEIPT_FILENAME);
20537
20997
  const final = await readJsonEvidence(finalPath);
20538
20998
  if (final.exists) {
20539
20999
  const location = safeEvidenceLocation(workspacePath, finalPath);
@@ -20567,7 +21027,7 @@ async function inspectLocalEvidence(input) {
20567
21027
  for (const entry of directory.sort((left, right) => left.name.localeCompare(right.name))) {
20568
21028
  const match = entry.isFile() ? JOURNAL_FILE2.exec(entry.name) : null;
20569
21029
  if (!match) continue;
20570
- const path = join13(attemptRoot, entry.name);
21030
+ const path = join14(attemptRoot, entry.name);
20571
21031
  const location = safeEvidenceLocation(workspacePath, path);
20572
21032
  if (location) evidenceLocations.push(location);
20573
21033
  const attemptId2 = match[1];
@@ -20604,7 +21064,7 @@ async function inspectLocalEvidence(input) {
20604
21064
  diagnostics.push("attempt_locked");
20605
21065
  }
20606
21066
  }
20607
- const backup = join13(attemptRoot, "screenshot-backup");
21067
+ const backup = join14(attemptRoot, "screenshot-backup");
20608
21068
  if ((await stat4(backup).catch(() => null))?.isDirectory()) {
20609
21069
  const location = safeEvidenceLocation(workspacePath, backup);
20610
21070
  if (location) evidenceLocations.push(location);
@@ -20640,17 +21100,17 @@ function attemptIdOf(value) {
20640
21100
  }
20641
21101
  async function inspectForeignAttempt(attemptRoot, planId) {
20642
21102
  const foreign = (value) => value !== null && typeof value.planId === "string" && SHA2565.test(value.planId) && value.planId !== planId;
20643
- const final = await readLooseRecord(join13(attemptRoot, RECEIPT_FILENAME));
21103
+ const final = await readLooseRecord(join14(attemptRoot, RECEIPT_FILENAME));
20644
21104
  if (foreign(final)) {
20645
21105
  return { attemptId: attemptIdOf(final.attemptId), planId: final.planId, finished: final.state === "completed" };
20646
21106
  }
20647
- const partial = await readLooseRecord(join13(attemptRoot, PARTIAL_RECEIPT_FILENAME));
21107
+ const partial = await readLooseRecord(join14(attemptRoot, PARTIAL_RECEIPT_FILENAME));
20648
21108
  if (foreign(partial)) return { attemptId: attemptIdOf(partial.attemptId), planId: partial.planId, finished: false };
20649
21109
  const directory = await readdir5(attemptRoot, { withFileTypes: true }).catch(() => []);
20650
21110
  for (const entry of directory.sort((left, right) => left.name.localeCompare(right.name))) {
20651
21111
  const match = entry.isFile() ? JOURNAL_FILE2.exec(entry.name) : null;
20652
21112
  if (!match) continue;
20653
- const text3 = await readFile11(join13(attemptRoot, entry.name), "utf8").catch(() => "");
21113
+ const text3 = await readFile11(join14(attemptRoot, entry.name), "utf8").catch(() => "");
20654
21114
  let header = null;
20655
21115
  try {
20656
21116
  header = JSON.parse(text3.split("\n")[0] ?? "");
@@ -22427,7 +22887,7 @@ var init_releaseCommitListing = __esm({
22427
22887
  // src/release/releaseCommitScreenshots.ts
22428
22888
  import { createHash as createHash6 } from "node:crypto";
22429
22889
  import { chmod as chmod5, lstat as lstat5, mkdir as mkdir5, mkdtemp as mkdtemp4, open as open4, readFile as readFile12, rename as rename6, rm as rm4, stat as stat5, writeFile as writeFile6 } from "node:fs/promises";
22430
- import { dirname as dirname5, join as join14, resolve as resolve8 } from "node:path";
22890
+ import { dirname as dirname6, join as join15, resolve as resolve8 } from "node:path";
22431
22891
  function fail4(message) {
22432
22892
  throw new Error(message);
22433
22893
  }
@@ -22591,7 +23051,7 @@ function parseScreenshotBackup(value) {
22591
23051
  async function verifyBackup(root, manifest) {
22592
23052
  for (const set of manifest.sets) {
22593
23053
  for (const image of set.images) {
22594
- const file = join14(root, ...image.path.split("/"));
23054
+ const file = join15(root, ...image.path.split("/"));
22595
23055
  const info = await lstat5(file).catch(() => null);
22596
23056
  if (!info?.isFile() || info.isSymbolicLink()) fail4(`the recovery backup is missing an image for ${set.locale}/${set.deviceSet}`);
22597
23057
  if (info.size !== image.bytes) fail4(`a recovery backup image for ${set.locale}/${set.deviceSet} changed size`);
@@ -22600,7 +23060,7 @@ async function verifyBackup(root, manifest) {
22600
23060
  }
22601
23061
  }
22602
23062
  async function readExistingBackup(root) {
22603
- const text3 = await readFile12(join14(root, BACKUP_FILENAME), "utf8").catch(() => null);
23063
+ const text3 = await readFile12(join15(root, BACKUP_FILENAME), "utf8").catch(() => null);
22604
23064
  if (text3 === null) return null;
22605
23065
  let value;
22606
23066
  try {
@@ -22614,23 +23074,23 @@ async function readExistingBackup(root) {
22614
23074
  }
22615
23075
  async function sealBackup(destination, manifest, blobs) {
22616
23076
  const target = resolve8(destination);
22617
- const parent = dirname5(target);
23077
+ const parent = dirname6(target);
22618
23078
  await secureDirectory2(parent);
22619
- const temporary = await mkdtemp4(join14(parent, `.shotops-screenshot-backup-${process.pid}-`));
23079
+ const temporary = await mkdtemp4(join15(parent, `.shotops-screenshot-backup-${process.pid}-`));
22620
23080
  let renamed = false;
22621
23081
  try {
22622
23082
  const directories = /* @__PURE__ */ new Set();
22623
23083
  for (const [path, bytes] of blobs) {
22624
- const file = join14(temporary, ...path.split("/"));
22625
- await secureDirectory2(dirname5(file));
22626
- directories.add(dirname5(file));
23084
+ const file = join15(temporary, ...path.split("/"));
23085
+ await secureDirectory2(dirname6(file));
23086
+ directories.add(dirname6(file));
22627
23087
  await writeFile6(file, bytes, { mode: 384 });
22628
23088
  const written = await stat5(file);
22629
23089
  if (written.size !== bytes.byteLength) fail4("a recovery backup image changed size while it was being written");
22630
23090
  if (await sha256File(file) !== sha256(bytes)) fail4("a recovery backup image changed content while it was being written");
22631
23091
  await fsyncPath2(file, false);
22632
23092
  }
22633
- const manifestPath = join14(temporary, BACKUP_FILENAME);
23093
+ const manifestPath = join15(temporary, BACKUP_FILENAME);
22634
23094
  await writeFile6(manifestPath, canonicalJson(manifest), { mode: 384 });
22635
23095
  await fsyncPath2(manifestPath, false);
22636
23096
  for (const directory of [...directories].sort((left, right) => right.length - left.length)) {
@@ -23190,7 +23650,7 @@ __export(releasePlan_exports, {
23190
23650
  import { createHash as createHash7 } from "node:crypto";
23191
23651
  import { createReadStream as createReadStream3 } from "node:fs";
23192
23652
  import { chmod as chmod6, lstat as lstat6, rename as rename7, writeFile as writeFile7 } from "node:fs/promises";
23193
- import { basename as basename5, dirname as dirname6, join as join15, resolve as resolve9 } from "node:path";
23653
+ import { basename as basename5, dirname as dirname7, join as join16, resolve as resolve9 } from "node:path";
23194
23654
  async function md5File(path) {
23195
23655
  const hash = createHash7("md5");
23196
23656
  await new Promise((resolvePromise, rejectPromise) => {
@@ -23827,7 +24287,7 @@ function envelopeFor2(plan) {
23827
24287
  };
23828
24288
  }
23829
24289
  async function atomicPrivateWrite2(path, value) {
23830
- const temporary = join15(dirname6(path), `.${basename5(path)}.${process.pid}.tmp`);
24290
+ const temporary = join16(dirname7(path), `.${basename5(path)}.${process.pid}.tmp`);
23831
24291
  try {
23832
24292
  await writeFile7(temporary, canonicalJson(value), { mode: 384 });
23833
24293
  if (process.platform !== "win32") await chmod6(temporary, 384);
@@ -23839,9 +24299,9 @@ async function atomicPrivateWrite2(path, value) {
23839
24299
  }
23840
24300
  async function createReleasePlan(input, deps = {}) {
23841
24301
  const workspace = resolve9(input.workspace);
23842
- const output2 = resolve9(input.output ?? join15(workspace, "release-plan.json"));
23843
- const envelopePath = join15(dirname6(output2), "release-plan-envelope.json");
23844
- const artifactPath = join15(dirname6(output2), "release-review.zip");
24302
+ const output2 = resolve9(input.output ?? join16(workspace, "release-plan.json"));
24303
+ const envelopePath = join16(dirname7(output2), "release-plan-envelope.json");
24304
+ const artifactPath = join16(dirname7(output2), "release-review.zip");
23845
24305
  const loaded = await verifyReleaseWorkspace(workspace);
23846
24306
  const assets = await desiredAssets(loaded.manifest, workspace);
23847
24307
  const setup = await (deps.readConfig ?? readReleaseConfig)();
@@ -24405,13 +24865,13 @@ __export(releaseCommit_exports, {
24405
24865
  validateReleaseCommitReceipt: () => validateReleaseCommitReceipt
24406
24866
  });
24407
24867
  import { readFile as readFile13 } from "node:fs/promises";
24408
- import { join as join16, resolve as resolve10 } from "node:path";
24868
+ import { join as join17, resolve as resolve10 } from "node:path";
24409
24869
  async function commitApprovedRelease(input, deps = {}) {
24410
24870
  const now = deps.now ?? (() => /* @__PURE__ */ new Date());
24411
24871
  const startedAt = now().toISOString();
24412
24872
  const attemptRoot = resolve10(input.attemptRoot ?? attemptDirectoryOf(input.workspace));
24413
- const receiptPath = join16(attemptRoot, RECEIPT_FILENAME2);
24414
- const planId = await loosePlanIdAt(input.planPath ?? join16(resolve10(input.workspace), PLAN_FILENAME2));
24873
+ const receiptPath = join17(attemptRoot, RECEIPT_FILENAME2);
24874
+ const planId = await loosePlanIdAt(input.planPath ?? join17(resolve10(input.workspace), PLAN_FILENAME2));
24415
24875
  let foreign = planId ? await inspectForeignAttempt(attemptRoot, planId) : null;
24416
24876
  if (foreign?.finished) {
24417
24877
  const archived = await archiveForeignAttempt(attemptRoot, foreign, now);
@@ -24486,7 +24946,7 @@ async function commitApprovedRelease(input, deps = {}) {
24486
24946
  durable = isReceipt(persisted, plan) ? persisted : receipt;
24487
24947
  await authority.finalize(plan, attempt.attemptId, durable);
24488
24948
  } else {
24489
- await writePartialReceipt(join16(attemptRoot, PARTIAL_RECEIPT_FILENAME2), receipt);
24949
+ await writePartialReceipt(join17(attemptRoot, PARTIAL_RECEIPT_FILENAME2), receipt);
24490
24950
  }
24491
24951
  await journal.record({
24492
24952
  stage: "receipt",
@@ -24572,7 +25032,7 @@ async function runPreflight(input, deps, recovery) {
24572
25032
  } catch (error) {
24573
25033
  refuse2("workspace_corrupt", error.message);
24574
25034
  }
24575
- const plan = await readApprovedPlan(input.planPath ?? join16(workspacePath, PLAN_FILENAME2));
25035
+ const plan = await readApprovedPlan(input.planPath ?? join17(workspacePath, PLAN_FILENAME2));
24576
25036
  if (plan.desired.desiredId !== workspace.manifest.desiredId) {
24577
25037
  refuse2("plan_mismatch", "this plan was built from a different desired release than the workspace holds");
24578
25038
  }
@@ -24740,7 +25200,7 @@ async function runStages(preflight, attemptId, journal, signal, deps, now) {
24740
25200
  {
24741
25201
  plan,
24742
25202
  workspace: preflight.workspacePath,
24743
- backup: join16(preflight.attemptRoot, SCREENSHOT_BACKUP_DIRNAME)
25203
+ backup: join17(preflight.attemptRoot, SCREENSHOT_BACKUP_DIRNAME)
24744
25204
  },
24745
25205
  {
24746
25206
  token: preflight.appleToken,
@@ -24794,7 +25254,7 @@ async function runStages(preflight, attemptId, journal, signal, deps, now) {
24794
25254
  }
24795
25255
  function readSealedPayload(workspacePath) {
24796
25256
  return async (reference) => {
24797
- const bytes = await readFile13(join16(workspacePath, ...reference.source.split("/")));
25257
+ const bytes = await readFile13(join17(workspacePath, ...reference.source.split("/")));
24798
25258
  if (bytes.byteLength !== reference.bytes || sha256(bytes) !== reference.sha256) {
24799
25259
  throw new Error("a sealed payload changed since this plan was approved");
24800
25260
  }
@@ -24913,8 +25373,8 @@ ${receipt.remainingOwnerAction}` : ""
24913
25373
  }
24914
25374
  async function readReleaseCommitStatus(workspace, attemptRoot) {
24915
25375
  const root = resolve10(attemptRoot ?? attemptDirectoryOf(workspace));
24916
- const final = await readPersistedReceipt(join16(root, RECEIPT_FILENAME2));
24917
- const partial = isReceipt(final) ? null : await readPersistedReceipt(join16(root, PARTIAL_RECEIPT_FILENAME2));
25376
+ const final = await readPersistedReceipt(join17(root, RECEIPT_FILENAME2));
25377
+ const partial = isReceipt(final) ? null : await readPersistedReceipt(join17(root, PARTIAL_RECEIPT_FILENAME2));
24918
25378
  const journals = await readLiveAttemptJournals(root);
24919
25379
  return {
24920
25380
  attemptRoot: root,
@@ -25285,7 +25745,7 @@ __export(releasePrepare_exports, {
25285
25745
  runReleasePrepareCommand: () => runReleasePrepareCommand
25286
25746
  });
25287
25747
  import { readFile as readFile14 } from "node:fs/promises";
25288
- import { isAbsolute as isAbsolute6, join as join17, resolve as resolve11 } from "node:path";
25748
+ import { isAbsolute as isAbsolute6, join as join18, resolve as resolve11 } from "node:path";
25289
25749
  function parseGcArgs(argv2) {
25290
25750
  const values = /* @__PURE__ */ new Map();
25291
25751
  let dryRun = false;
@@ -25393,7 +25853,7 @@ function parsePrepareArgs(argv2) {
25393
25853
  project: project2,
25394
25854
  repository,
25395
25855
  ...values.get("--bundle-id") ? { bundleId: values.get("--bundle-id").trim() } : {},
25396
- ...build ? { build: isAbsolute6(build) ? build : join17(repository, build) } : {},
25856
+ ...build ? { build: isAbsolute6(build) ? build : join18(repository, build) } : {},
25397
25857
  ...values.get("--locales") ? { locales: csv(values.get("--locales"), "--locales") } : {},
25398
25858
  ...values.get("--outputs") ? { outputs: csv(values.get("--outputs"), "--outputs") } : {},
25399
25859
  ...values.get("--output") ? { destination: resolve11(values.get("--output")) } : {},
@@ -26400,7 +26860,7 @@ import {
26400
26860
  writeFile as writeFile8
26401
26861
  } from "node:fs/promises";
26402
26862
  import { homedir as homedir3 } from "node:os";
26403
- import { delimiter, dirname as dirname7, extname as extname4, join as join18, resolve as resolve13 } from "node:path";
26863
+ import { delimiter, dirname as dirname8, extname as extname4, join as join19, resolve as resolve13 } from "node:path";
26404
26864
  import { stdin, stdout } from "node:process";
26405
26865
  import { createInterface } from "node:readline/promises";
26406
26866
  function cleanIdentifier(value, label) {
@@ -26411,7 +26871,7 @@ function cleanIdentifier(value, label) {
26411
26871
  return result;
26412
26872
  }
26413
26873
  function expandHome(path) {
26414
- return path === "~" ? homedir3() : path.startsWith("~/") ? join18(homedir3(), path.slice(2)) : path;
26874
+ return path === "~" ? homedir3() : path.startsWith("~/") ? join19(homedir3(), path.slice(2)) : path;
26415
26875
  }
26416
26876
  async function validatePrivateKeyPath(keyPath) {
26417
26877
  const requested = resolve13(expandHome(keyPath.trim()));
@@ -26446,7 +26906,7 @@ async function findExecutable(name, env = process.env) {
26446
26906
  const extensions = process.platform === "win32" ? (env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";") : [""];
26447
26907
  for (const dir of (env.PATH || "").split(delimiter).filter(Boolean)) {
26448
26908
  for (const extension of extensions) {
26449
- const candidate = join18(dir, `${name}${extension}`);
26909
+ const candidate = join19(dir, `${name}${extension}`);
26450
26910
  try {
26451
26911
  const info = await stat6(candidate);
26452
26912
  if (!info.isFile()) continue;
@@ -26579,8 +27039,8 @@ async function probeAppleAccess(input, fetchImpl = fetch, apiOrigin = APP_STORE_
26579
27039
  return { teams: [{ id: input.issuerId, apps }] };
26580
27040
  }
26581
27041
  function releaseConfigPath() {
26582
- const dir = process.env.SHOTOPS_CONFIG_DIR || join18(homedir3(), ".shotops");
26583
- return join18(dir, "release.json");
27042
+ const dir = process.env.SHOTOPS_CONFIG_DIR || join19(homedir3(), ".shotops");
27043
+ return join19(dir, "release.json");
26584
27044
  }
26585
27045
  async function readReleaseConfig(target = releaseConfigPath()) {
26586
27046
  let parsed;
@@ -26609,7 +27069,7 @@ async function writeReleaseConfig(input, target = releaseConfigPath()) {
26609
27069
  keyPath: input.keyPath
26610
27070
  }
26611
27071
  };
26612
- const dir = dirname7(target);
27072
+ const dir = dirname8(target);
26613
27073
  await mkdir6(dir, { recursive: true, mode: 448 });
26614
27074
  if (process.platform !== "win32") await chmod7(dir, 448);
26615
27075
  const temporary = `${target}.${process.pid}.tmp`;
@@ -26952,6 +27412,7 @@ wait, cancelled and cloud-only results.
26952
27412
  });
26953
27413
 
26954
27414
  // src/local.ts
27415
+ import { Console } from "node:console";
26955
27416
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26956
27417
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
26957
27418
 
@@ -26978,6 +27439,7 @@ init_screenshotInput();
26978
27439
  init_uploads();
26979
27440
  init_resultContract();
26980
27441
  init_renderCredits();
27442
+ init_browserInstall();
26981
27443
  init_appstore();
26982
27444
  init_productionOperationStart();
26983
27445
  function dedupeLocales(list) {
@@ -27090,7 +27552,13 @@ function bundleProductionSnapshot(input) {
27090
27552
  const locale2 = input.locales[0] ?? "en-US";
27091
27553
  const projectFile = input.loaded?.projectFile;
27092
27554
  const variant = projectFile ? projectRenderVariant(projectFile, locale2, input.panelPresetId ?? void 0) : void 0;
27093
- const sources = input.screens ? plannedSourceCells(input.screens.plan(variant ?? { locale: locale2 })) : input.panelEntries && input.panelEntries.length > 0 ? input.panelSourceEntries ? input.locales.flatMap(
27555
+ const variants = projectFile ? Array.from(new Map(
27556
+ (input.outputs.length > 0 ? input.outputs.map((id) => deviceById(id)?.panelPresetId) : [input.panelPresetId ?? void 0]).flatMap((preset) => input.locales.map((targetLocale) => {
27557
+ const target = projectRenderVariant(projectFile, targetLocale, preset);
27558
+ return [variantKey(target), target];
27559
+ }))
27560
+ ).values()) : [];
27561
+ const sources = input.screens ? variants.flatMap((target) => plannedSourceCells(input.screens.plan(target))) : input.panelEntries && input.panelEntries.length > 0 ? input.panelSourceEntries ? input.locales.flatMap(
27094
27562
  (targetLocale) => panelSourceCells(
27095
27563
  input.panelSourceEntries.map(
27096
27564
  (entry) => resolveLocaleVariant(entry, targetLocale)
@@ -27133,7 +27601,10 @@ function bundleProductionSnapshot(input) {
27133
27601
  panelPresetId: input.panelPresetId
27134
27602
  },
27135
27603
  baseLocale: projectFile ? projectBaseLocale2(projectFile) : "en-US",
27136
- ...input.screens ? { gaps: repairGaps(input.screens.repairFindings) } : {}
27604
+ ...input.screens ? { gaps: [
27605
+ ...repairGaps(input.screens.repairFindings),
27606
+ ...projectCopyGaps(projectFile, input.locales, variant?.family ?? BASE_SCREEN_FAMILY)
27607
+ ] } : {}
27137
27608
  });
27138
27609
  if (!resolved.ok) return resolved;
27139
27610
  const readiness = requireReadiness({
@@ -27163,7 +27634,7 @@ async function handleEmitBundle(deps, args, signal) {
27163
27634
  const scope = openChargeScope(deps);
27164
27635
  const attempt = {};
27165
27636
  try {
27166
- return await runCharged(scope, () => emitBundle(deps, args, scope, attempt, signal));
27637
+ return await refuseOnRendererSetupFailure(() => runCharged(scope, () => emitBundle(deps, args, scope, attempt, signal)));
27167
27638
  } finally {
27168
27639
  finishProductionRender(attempt.access);
27169
27640
  }
@@ -27638,7 +28109,8 @@ async function emitBundle(deps, args, scope, attempt, signal) {
27638
28109
  unexpandedSavedNote(projectSizeCovered),
27639
28110
  projectScreenUsageNote(result.shots ?? []),
27640
28111
  projectStylingIgnoredNote(args),
27641
- result.layoutNote
28112
+ result.layoutNote,
28113
+ result.setupNote
27642
28114
  );
27643
28115
  } else {
27644
28116
  if (!screenshotSlots || screenshotSlots.length === 0) {
@@ -27733,7 +28205,7 @@ async function emitBundle(deps, args, scope, attempt, signal) {
27733
28205
  previewPng = result.stripBase64 ? Buffer.from(result.stripBase64, "base64") : null;
27734
28206
  projectRecord = result.projectRecord;
27735
28207
  layout = result.layout;
27736
- note = joinNotes(note, result.layoutNote);
28208
+ note = joinNotes(note, result.layoutNote, result.setupNote);
27737
28209
  }
27738
28210
  const declaredLocales = collectVariantLocales([...(screenshotSlots ?? []).flat(), ...args.panels ?? []]);
27739
28211
  if (declaredLocales.length > 0) {
@@ -27857,6 +28329,7 @@ async function emitMultiOutputBundle(deps, args, outputs, locales, projectName2,
27857
28329
  const projectShots = [];
27858
28330
  let layout;
27859
28331
  let layoutNote;
28332
+ let setupNote;
27860
28333
  for (const output2 of outputs) {
27861
28334
  const localeSlots = [];
27862
28335
  for (const locale2 of locales) {
@@ -27881,6 +28354,7 @@ async function emitMultiOutputBundle(deps, args, outputs, locales, projectName2,
27881
28354
  }))
27882
28355
  });
27883
28356
  if (!previewPng && result.stripBase64) previewPng = Buffer.from(result.stripBase64, "base64");
28357
+ setupNote ??= result.setupNote;
27884
28358
  if (!layout && result.layout) {
27885
28359
  layout = result.layout;
27886
28360
  const scope2 = outputs.length > 1 || locales.length > 1 ? ` (measured on ${output2.label} / ${locale2})` : "";
@@ -27952,6 +28426,7 @@ async function emitMultiOutputBundle(deps, args, outputs, locales, projectName2,
27952
28426
  projectScreenUsageNote(projectShots),
27953
28427
  outputNote,
27954
28428
  layoutNote,
28429
+ setupNote,
27955
28430
  resolutionGapNote(plan.snapshot)
27956
28431
  );
27957
28432
  return textResult({
@@ -28013,7 +28488,9 @@ async function emitMultiLocaleBundle(deps, args, locales, projectName2, scope, p
28013
28488
  const projectShots = [];
28014
28489
  let layout;
28015
28490
  let layoutNote;
28491
+ let setupNote;
28016
28492
  const noteLayout = (result, loc) => {
28493
+ setupNote ??= result.setupNote;
28017
28494
  if (layout || !result.layout) return;
28018
28495
  layout = result.layout;
28019
28496
  const scope2 = locales.length > 1 ? ` (measured on ${loc})` : "";
@@ -28119,7 +28596,7 @@ async function emitMultiLocaleBundle(deps, args, locales, projectName2, scope, p
28119
28596
  });
28120
28597
  if (!measuredReady.ok) return measuredReady.result;
28121
28598
  const zip = buildMultiLocaleFastlaneBundleZip(localeSlots, { bundleId: args.bundleId, panelLabel });
28122
- note = joinNotes(note, projectScreenUsageNote(projectShots), layoutNote);
28599
+ note = joinNotes(note, projectScreenUsageNote(projectShots), layoutNote, setupNote);
28123
28600
  const filename2 = bundleZipFilename(projectName2);
28124
28601
  const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength, deps.forceOutput);
28125
28602
  const storedZip = outputMode === "urls" ? await storeAsset(
@@ -28972,7 +29449,7 @@ async function handleDeleteAssets(deps, args) {
28972
29449
  // src/runtime/widgets.ts
28973
29450
  init_packagePaths();
28974
29451
  import { readFileSync } from "node:fs";
28975
- import { join as join2 } from "node:path";
29452
+ import { join as join3 } from "node:path";
28976
29453
  var WIDGET_MIME = "text/html;profile=mcp-app";
28977
29454
  var PANELS_WIDGET_URI = "ui://shotops/panels/v4.html";
28978
29455
  var PANELS_WIDGET_URIS = [
@@ -28999,7 +29476,7 @@ function readBundle(name) {
28999
29476
  if (cached !== void 0) return cached;
29000
29477
  let bundle;
29001
29478
  try {
29002
- bundle = readFileSync(join2(WIDGET_DIST, name), "utf8");
29479
+ bundle = readFileSync(join3(WIDGET_DIST, name), "utf8");
29003
29480
  } catch {
29004
29481
  bundle = null;
29005
29482
  }
@@ -29130,7 +29607,7 @@ function registerTools(server, deps) {
29130
29607
  "render_strip",
29131
29608
  {
29132
29609
  title: "Render App Store screenshots",
29133
- description: 'Make the store screenshots: raw app screen captures in, finished marketing panels out. Each screenshot is composited into a styled 3D device mockup, laid out as an App Store screenshot strip with the headlines you pass, and returned as per-panel PNGs at the App Store panel size `panelPresetId` names. This is the tool for a strip built from screenshots you have in hand; to reproduce one a designer already saved, use render_project. In ChatGPT, call import_screenshot first and pass the returned refs in the preferred flat form: { "screenshots": [{ "ref": "\u2026" }] } \u2014 each flat entry is one panel, and nested slots only when ONE panel should hold several phones. Art-direct it with `style`/`look` or a project\'s saved look, iterate with a low-res `preview` at a quarter the render cost, and take results `inline` or as `urls` for large payloads. Screenshots can vary per store locale via { "locales": \u2026 } entries \u2014 `locale` picks which variant renders (missing variants fall back to en-US). For real, full-resolution screenshots call request_screenshot_upload first \u2014 inline base64 is for small payloads only. CALL describe_look FIRST when nobody has said how the strip should look \u2014 it returns the five palette presets, the three layout templates and every styling field with its default, and guessing a colour scheme out of 30 raw fields is how strips end up looking improvised. PICK `style.layout` FROM THE LONGEST HEADLINE IN THE SET, once, before anything else: "standard" reserves 2 lines (~36 characters) with the whole device under them, "bleed" reserves 4 (~72) with the device running off the bottom edge, "top-bleed" inverts that. The text region is RESERVED, NOT FITTED \u2014 held at full size whether or not the headline fills it, because a fixed reservation is the only thing that makes every device in a swiped SET land on the same line. Do not close a gap by shrinking vOffset on the short panels, and never assemble a composition out of raw phoneHeight/vOffset numbers: pick a layout, then override one field if you must (the template expands first, your field wins). Past the reservation the caption band grows down INTO the phone \u2014 the failure to avoid. THE RESPONSE ANSWERS BACK: an optional `layout` block names every caption sitting on a device, every caption collision and every device pair overlapping without reading as composed, by frame number and depth. When the repair is deterministic it also carries `layout.correctedLook` \u2014 pass that straight back as `look` to re-render clean. No `layout` key means the composition measured clean. Read it before you show the panels. This tool ONLY renders images: it never creates or updates a project, and the result says so \u2014 `contract.effects.projectMutation` is false on every render. A render with no `project` also carries a `nextStep` with action `offer_save_project`, which states that no project exists and names save_project as the door. That is an OFFER to put to the user, never consent already given: save only after they show they want this work preserved, shared, reused or kept editable, and if you cannot ask them, return the panels with the offer standing. No store credential is involved.',
29610
+ description: 'Make the store screenshots: raw app screen captures in, finished marketing panels out. Each screenshot is composited into a styled 3D device mockup, laid out as an App Store screenshot strip with the headlines you pass, and returned as per-panel PNGs at the App Store panel size `panelPresetId` names. This is the tool for a strip built from screenshots you have in hand; to reproduce one a designer already saved, use render_project. In ChatGPT, call import_screenshot first and pass the returned refs in the preferred flat form: { "screenshots": [{ "ref": "\u2026" }] } \u2014 each flat entry is one panel, and nested slots only when ONE panel should hold several phones. Art-direct it with `style`/`look` or a project\'s saved look, iterate with a low-res `preview` at a quarter the render cost, and take results `inline` or as `urls` for large payloads. Screenshots can vary per store locale via { "locales": \u2026 } entries \u2014 `locale` picks which variant renders (missing variants fall back to en-US). For real, full-resolution screenshots call request_screenshot_upload first \u2014 inline base64 is for small payloads only. CALL describe_look FIRST when nobody has said how the strip should look \u2014 it returns the five palette presets, the seven layout templates and every styling field with its default, and guessing a colour scheme out of 30 raw fields is how strips end up looking improvised. PICK `style.layout` ONCE, BEFORE ANYTHING ELSE \u2014 off the EDGE the user asked for if they named one, otherwise off the LONGEST HEADLINE in the set: "standard" reserves 2 lines (~36 characters) with the whole device under them, "bleed" reserves 4 (~72) with the device running off the bottom edge, "top-bleed" inverts that. The side bleeds carry the device into the NEIGHBOURING screenshot so the set reads as one swipe \u2014 "lean-right" / "lean-left" put the headline in a narrow column beside it (~26 characters), "swipe" pushes a third of the device across and keeps the full-width headline, "corner-bleed" goes off the bottom and the right at once. A side bleed is for the WHOLE set: apply one to every panel or none, and never mirror it on neighbouring panels \u2014 two devices leaning toward each other overlap. The text region is RESERVED, NOT FITTED \u2014 held at full size whether or not the headline fills it, because a fixed reservation is the only thing that makes every device in a swiped SET land on the same line. Do not close a gap by shrinking vOffset on the short panels, and never assemble a composition out of raw phoneHeight/vOffset numbers: pick a layout, then override one field if you must (the template expands first, your field wins). Past the reservation the caption band grows down INTO the phone \u2014 the failure to avoid. THE RESPONSE ANSWERS BACK: an optional `layout` block names every caption sitting on a device, every caption collision and every device pair overlapping without reading as composed, by frame number and depth. When the repair is deterministic it also carries `layout.correctedLook` \u2014 pass that straight back as `look` to re-render clean. No `layout` key means the composition measured clean. Read it before you show the panels. This tool ONLY renders images: it never creates or updates a project, and the result says so \u2014 `contract.effects.projectMutation` is false on every render. A render with no `project` also carries a `nextStep` with action `offer_save_project`, which states that no project exists and names save_project as the door. That is an OFFER to put to the user, never consent already given: save only after they show they want this work preserved, shared, reused or kept editable, and if you cannot ask them, return the panels with the offer standing. No store credential is involved.',
29134
29611
  inputSchema: renderStripAdvertisedShape,
29135
29612
  outputSchema: renderStripToolOutputSchema,
29136
29613
  // ADDITIVE, not destructive: this can mint private generated assets but cannot change a
@@ -29279,7 +29756,7 @@ function registerTools(server, deps) {
29279
29756
  "describe_look",
29280
29757
  {
29281
29758
  title: "Design presets & style catalog",
29282
- description: "Decide what the strip should LOOK like before rendering it. Returns five named starting-point presets (each bundling a background, device body, caption colour and typeface that agree with each other), a short two-question intake script for asking the user their vibe and their headlines, and the full styling field catalog + defaults (device/camera, background, captions, App Store panel sizes). CALL THIS BEFORE the first render of a new strip whenever the user has not said how it should look \u2014 guessing a colour scheme out of 30 raw fields is how strips end up looking improvised. It is also the one complete copy of that material: the connect-time server instructions are truncated by most clients, so anything you need about PALETTE (presets: noir, daylight, pop, clay, studio \u2014 each bundling background + device body + caption colour + typeface so they agree) or COMPOSITION (layouts: standard, bleed, top-bleed \u2014 how many headline lines are reserved and where the device sits under them) is here and nowhere else in full. The two are independent axes and you usually want one of each: a preset never pins placement, a layout never pins colour. SKIP this call when the user already said what they want, when the project has a saved look (read_look / useSavedLook), or when you are iterating on a strip you already rendered.",
29759
+ description: "Decide what the strip should LOOK like before rendering it. Returns five named starting-point presets (each bundling a background, device body, caption colour and typeface that agree with each other), a short two-question intake script for asking the user their vibe and their headlines, and the full styling field catalog + defaults (device/camera, background, captions, App Store panel sizes). CALL THIS BEFORE the first render of a new strip whenever the user has not said how it should look \u2014 guessing a colour scheme out of 30 raw fields is how strips end up looking improvised. It is also the one complete copy of that material: the connect-time server instructions are truncated by most clients, so anything you need about PALETTE (presets: noir, daylight, pop, clay, studio \u2014 each bundling background + device body + caption colour + typeface so they agree) or COMPOSITION (layouts: standard, bleed, top-bleed, lean-right, lean-left, swipe, corner-bleed \u2014 how many headline lines are reserved and where the device sits under them) is here and nowhere else in full. The two are independent axes and you usually want one of each: a preset never pins placement, a layout never pins colour. SKIP this call when the user already said what they want, when the project has a saved look (read_look / useSavedLook), or when you are iterating on a strip you already rendered.",
29283
29760
  inputSchema: describeLookShape,
29284
29761
  outputSchema: describeLookOutputSchema,
29285
29762
  annotations: READ_ONLY,
@@ -29705,7 +30182,11 @@ function tokenFromArgv(argv2) {
29705
30182
  return idx !== -1 ? argv2[idx + 1] : void 0;
29706
30183
  }
29707
30184
  var LOCAL_INTAKE = 'request_screenshot_upload is hosted-only; import_screenshot accepts { "path": "/abs/shot.png" } entries here (its { "url" } / { "file" } sources need a hosted account). Either way you can pass local files straight to render_strip / emit_bundle as { "path": "..." } screenshot entries.';
30185
+ function silenceStdoutConsole() {
30186
+ globalThis.console = new Console({ stdout: process.stderr, stderr: process.stderr });
30187
+ }
29708
30188
  async function startStdioServer(token2) {
30189
+ silenceStdoutConsole();
29709
30190
  const deps = localToolDeps(token2);
29710
30191
  const localInstructions = token2 ? `${SERVER_INSTRUCTIONS}
29711
30192