frogoe 0.6.0 → 0.7.0

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/cli.js CHANGED
@@ -25,7 +25,8 @@ var init_templates = __esm({
25
25
  ].join("\n");
26
26
  briefTemplate = `---
27
27
  title: My Game
28
- verb: tap # tap | hold | steer | aim \u2014 ONE word, the core action
28
+ verb: tap # tap|hold|steer|aim|swap|place|type|draw|idle \u2014 ONE word, the core action
29
+ session: blitz # blitz (default) | round | toy \u2014 how the run ends
29
30
  mood: TODO \u2014 one phrase: "dawn uplift", "arcade cabinet at midnight"...
30
31
  palette:
31
32
  bg: "#101418" # TODO
@@ -384,7 +385,7 @@ var init_add2 = __esm({
384
385
  });
385
386
 
386
387
  // src/fetch-policy.ts
387
- var DEFAULT_FETCH_POLICY, FetchUnavailableError, FetchNotServedError, sleep, fetchWithPolicy;
388
+ var DEFAULT_FETCH_POLICY, FetchUnavailableError, FetchNotServedError, sleep, fetchWithPolicy, fetchBufferWithPolicy;
388
389
  var init_fetch_policy = __esm({
389
390
  "src/fetch-policy.ts"() {
390
391
  "use strict";
@@ -444,7 +445,50 @@ var init_fetch_policy = __esm({
444
445
  } catch (error) {
445
446
  const retryable = error instanceof FetchUnavailableError || !(error instanceof FetchNotServedError);
446
447
  if (!retryable || attempt === policy.maxAttempts) {
447
- throw error;
448
+ if (error instanceof FetchUnavailableError || error instanceof FetchNotServedError) {
449
+ throw error;
450
+ }
451
+ throw new FetchUnavailableError(url, error);
452
+ }
453
+ await sleep(Math.random() * policy.baseDelayMs * 2 ** (attempt - 1));
454
+ } finally {
455
+ clearTimeout(timer);
456
+ }
457
+ }
458
+ throw new FetchUnavailableError(url, new Error("unreachable"));
459
+ };
460
+ fetchBufferWithPolicy = async (url, options) => {
461
+ const policy = { ...DEFAULT_FETCH_POLICY, ...options?.policy };
462
+ const doFetch = options?.fetchImpl ?? fetch;
463
+ const started = Date.now();
464
+ for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
465
+ if (Date.now() - started > policy.maxElapsedMs) {
466
+ throw new FetchUnavailableError(url, new Error("wall-clock budget exceeded"));
467
+ }
468
+ const controller = new AbortController();
469
+ const timer = setTimeout(() => {
470
+ controller.abort();
471
+ }, policy.attemptTimeoutMs);
472
+ try {
473
+ const res = await doFetch(url, {
474
+ headers: options?.headers,
475
+ redirect: "follow",
476
+ signal: controller.signal
477
+ });
478
+ if (res.status >= 500) {
479
+ throw new FetchUnavailableError(url, new Error(`upstream ${res.status}`));
480
+ }
481
+ if (res.status >= 400) {
482
+ throw new FetchNotServedError(url, res.status);
483
+ }
484
+ return Buffer.from(await res.arrayBuffer());
485
+ } catch (error) {
486
+ const retryable = error instanceof FetchUnavailableError || !(error instanceof FetchNotServedError);
487
+ if (!retryable || attempt === policy.maxAttempts) {
488
+ if (error instanceof FetchUnavailableError || error instanceof FetchNotServedError) {
489
+ throw error;
490
+ }
491
+ throw new FetchUnavailableError(url, error);
448
492
  }
449
493
  await sleep(Math.random() * policy.baseDelayMs * 2 ** (attempt - 1));
450
494
  } finally {
@@ -456,15 +500,109 @@ var init_fetch_policy = __esm({
456
500
  }
457
501
  });
458
502
 
459
- // src/bundle.ts
503
+ // src/net/font-proxy.ts
460
504
  import { createHash } from "crypto";
461
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
505
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
462
506
  import path3 from "path";
463
- var ALLOWED_HOSTS, PIN_EXEMPT_HOSTS, FONT_UA, MIME, sha256, dataUri, assertAllowedRemote, cdnPlugin, importMapPlugin, inlineFontCss, localDataUri, LEAK_PATTERN, bundle;
507
+ var ALLOWED_FONT_HOSTS, FONT_UA, UPSTREAM_TIMEOUT_MS, FONT_LINK_PATTERN, CSS_URL_PATTERN, proxyPathFor, PROXY_PATH_PREFIX, isLegacyCachedCss, rewriteFontLinks, allowedUrl, decodeProxyToken, rewriteCssFontRefs, typeFor, cacheFileFor, isBinaryEntry, CACHE_MAGIC, readCachedFont, writeCachedFont, proxyFontRequest;
508
+ var init_font_proxy = __esm({
509
+ "src/net/font-proxy.ts"() {
510
+ "use strict";
511
+ ALLOWED_FONT_HOSTS = /* @__PURE__ */ new Set(["fonts.googleapis.com", "fonts.gstatic.com"]);
512
+ FONT_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36";
513
+ UPSTREAM_TIMEOUT_MS = 4e3;
514
+ FONT_LINK_PATTERN = /href="(https:\/\/(?:fonts\.googleapis\.com|fonts\.gstatic\.com)\/[^"]+)"/gu;
515
+ CSS_URL_PATTERN = /url\((https:\/\/[^)]+)\)/gu;
516
+ proxyPathFor = (url) => `/__frogoe/font/${Buffer.from(url, "utf-8").toString("base64url")}`;
517
+ PROXY_PATH_PREFIX = "/__frogoe/font/";
518
+ isLegacyCachedCss = (css) => css.includes(PROXY_PATH_PREFIX);
519
+ rewriteFontLinks = (html) => html.replaceAll(FONT_LINK_PATTERN, (_match, url) => `href="${proxyPathFor(url)}"`);
520
+ allowedUrl = (url) => {
521
+ try {
522
+ const parsed = new URL(url);
523
+ return parsed.protocol === "https:" && ALLOWED_FONT_HOSTS.has(parsed.hostname);
524
+ } catch {
525
+ return false;
526
+ }
527
+ };
528
+ decodeProxyToken = (token) => {
529
+ const url = Buffer.from(token, "base64url").toString("utf-8");
530
+ return allowedUrl(url) ? url : null;
531
+ };
532
+ rewriteCssFontRefs = (css) => css.replaceAll(
533
+ CSS_URL_PATTERN,
534
+ (match, url) => allowedUrl(url) ? `url(${proxyPathFor(url)})` : match
535
+ );
536
+ typeFor = (file) => file.endsWith(".css") ? "text/css; charset=utf-8" : "font/woff2";
537
+ cacheFileFor = (cacheDir, url) => {
538
+ const ext = url.includes("css2?") || url.endsWith(".css") ? ".css" : ".woff2";
539
+ return path3.join(cacheDir, createHash("sha256").update(url).digest("hex") + ext);
540
+ };
541
+ isBinaryEntry = (file) => file.endsWith(".woff2");
542
+ CACHE_MAGIC = Buffer.from("FROGOEF1");
543
+ readCachedFont = (url, cacheDir) => {
544
+ const file = cacheFileFor(cacheDir, url);
545
+ if (!existsSync3(file)) return null;
546
+ const raw = readFileSync3(file);
547
+ if (isBinaryEntry(file)) {
548
+ if (raw.length < CACHE_MAGIC.length || !raw.subarray(0, CACHE_MAGIC.length).equals(CACHE_MAGIC)) {
549
+ return null;
550
+ }
551
+ return raw.subarray(CACHE_MAGIC.length);
552
+ }
553
+ return raw;
554
+ };
555
+ writeCachedFont = (url, cacheDir, body) => {
556
+ const file = cacheFileFor(cacheDir, url);
557
+ mkdirSync3(cacheDir, { recursive: true });
558
+ writeFileSync3(file, isBinaryEntry(file) ? Buffer.concat([CACHE_MAGIC, Buffer.from(body)]) : body);
559
+ return file;
560
+ };
561
+ proxyFontRequest = async (url, cacheDir, fetchImpl = fetch) => {
562
+ if (!allowedUrl(url)) {
563
+ return { body: "", contentType: "text/plain; charset=utf-8", fromCache: false, status: 403 };
564
+ }
565
+ const cachePath = cacheFileFor(cacheDir, url);
566
+ const isCss = cachePath.endsWith(".css");
567
+ const raw = readCachedFont(url, cacheDir);
568
+ if (raw !== null && !(isCss && isLegacyCachedCss(raw.toString("utf-8")))) {
569
+ const body = isCss ? rewriteCssFontRefs(raw.toString("utf-8")) : raw;
570
+ return { body, cachePath, contentType: typeFor(cachePath), fromCache: true, status: 200 };
571
+ }
572
+ try {
573
+ const res = await fetchImpl(url, {
574
+ headers: { "user-agent": FONT_UA },
575
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
576
+ });
577
+ if (!res.ok) {
578
+ return {
579
+ body: "",
580
+ contentType: typeFor(cachePath),
581
+ fromCache: false,
582
+ status: 502
583
+ };
584
+ }
585
+ const rawBody = Buffer.from(await res.arrayBuffer());
586
+ writeCachedFont(url, cacheDir, rawBody);
587
+ const body = isCss ? rewriteCssFontRefs(rawBody.toString("utf-8")) : rawBody;
588
+ return { body, cachePath, contentType: typeFor(cachePath), fromCache: false, status: 200 };
589
+ } catch {
590
+ return { body: "", contentType: typeFor(cachePath), fromCache: false, status: 504 };
591
+ }
592
+ };
593
+ }
594
+ });
595
+
596
+ // src/bundle.ts
597
+ import { createHash as createHash2 } from "crypto";
598
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
599
+ import path4 from "path";
600
+ var ALLOWED_HOSTS, PIN_EXEMPT_HOSTS, MIME, sha256, dataUri, assertAllowedRemote, cdnPlugin, importMapPlugin, inlineFontCss, localDataUri, LEAK_PATTERN, bundle;
464
601
  var init_bundle = __esm({
465
602
  "src/bundle.ts"() {
466
603
  "use strict";
467
604
  init_fetch_policy();
605
+ init_font_proxy();
468
606
  ALLOWED_HOSTS = /* @__PURE__ */ new Set([
469
607
  "cdn.jsdelivr.net",
470
608
  "esm.sh",
@@ -478,7 +616,6 @@ var init_bundle = __esm({
478
616
  "fonts.googleapis.test",
479
617
  "fonts.gstatic.test"
480
618
  ]);
481
- FONT_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36";
482
619
  MIME = {
483
620
  gif: "image/gif",
484
621
  jpeg: "image/jpeg",
@@ -491,7 +628,7 @@ var init_bundle = __esm({
491
628
  webp: "image/webp",
492
629
  woff2: "font/woff2"
493
630
  };
494
- sha256 = (data) => createHash("sha256").update(data).digest("hex");
631
+ sha256 = (data) => createHash2("sha256").update(data).digest("hex");
495
632
  dataUri = (mime, body) => {
496
633
  const base64 = Buffer.from(body, "binary").toString("base64");
497
634
  return `data:${mime};base64,${base64}`;
@@ -551,16 +688,34 @@ var init_bundle = __esm({
551
688
  if (/^https?:\/\//.test(target)) {
552
689
  return { namespace: "frogoe-cdn", path: target };
553
690
  }
554
- return { path: path3.resolve(dir, target) };
691
+ return { path: path4.resolve(dir, target) };
555
692
  });
556
693
  }
557
694
  }
558
695
  });
559
696
  inlineFontCss = async (cssUrl, options, assets) => {
560
- const css = await fetchWithPolicy(cssUrl, {
561
- fetchImpl: options.fetchImpl,
562
- headers: { "user-agent": FONT_UA }
563
- });
697
+ const cacheDir = path4.join(path4.resolve(options.dir), ".frogoe", "font-cache");
698
+ const fontFetchError = (url, cause) => new Error(
699
+ `bundle/font-unreachable \u2014 ${url} (${cause instanceof Error ? cause.message : String(cause)})
700
+ No local cache for it and the network failed \u2014 retry when the network recovers,
701
+ or run \`frogoe check <game>\` once first: it warms the shared font cache the bundler reads.`,
702
+ { cause: cause instanceof Error ? cause : void 0 }
703
+ );
704
+ let css;
705
+ const cachedCss = readCachedFont(cssUrl, cacheDir);
706
+ if (cachedCss !== null) {
707
+ css = cachedCss.toString("utf-8");
708
+ } else {
709
+ try {
710
+ css = await fetchWithPolicy(cssUrl, {
711
+ fetchImpl: options.fetchImpl,
712
+ headers: { "user-agent": FONT_UA }
713
+ });
714
+ } catch (error) {
715
+ throw fontFetchError(cssUrl, error);
716
+ }
717
+ writeCachedFont(cssUrl, cacheDir, css);
718
+ }
564
719
  assets.push({
565
720
  bytes: css.length,
566
721
  kind: "css",
@@ -574,8 +729,21 @@ var init_bundle = __esm({
574
729
  return cached2;
575
730
  }
576
731
  assertAllowedRemote(url, options.extraAllowedHosts);
577
- const res = options.fetchImpl ? await options.fetchImpl(url, { headers: { "user-agent": FONT_UA } }) : await fetch(url, { headers: { "user-agent": FONT_UA } });
578
- const buffer = Buffer.from(await res.arrayBuffer());
732
+ let buffer;
733
+ const raw2 = readCachedFont(url, cacheDir);
734
+ if (raw2 !== null) {
735
+ buffer = raw2;
736
+ } else {
737
+ try {
738
+ buffer = await fetchBufferWithPolicy(url, {
739
+ fetchImpl: options.fetchImpl,
740
+ headers: { "user-agent": FONT_UA }
741
+ });
742
+ writeCachedFont(url, cacheDir, buffer);
743
+ } catch (error) {
744
+ throw fontFetchError(url, error);
745
+ }
746
+ }
579
747
  const uri = `data:font/woff2;base64,${buffer.toString("base64")}`;
580
748
  seen.set(url, uri);
581
749
  assets.push({
@@ -586,6 +754,18 @@ var init_bundle = __esm({
586
754
  });
587
755
  return uri;
588
756
  };
757
+ let raw = css;
758
+ for (const match of css.matchAll(/url\((\/__frogoe\/font\/[A-Za-z0-9_-]+)\)/gu)) {
759
+ const ref = match[1] ?? "";
760
+ const decoded = ref === "" ? null : decodeProxyToken(ref.slice(PROXY_PATH_PREFIX.length));
761
+ if (decoded !== null) {
762
+ raw = raw.replaceAll(ref, decoded);
763
+ }
764
+ }
765
+ if (raw !== css) {
766
+ writeCachedFont(cssUrl, cacheDir, raw);
767
+ css = raw;
768
+ }
589
769
  let out = css;
590
770
  const urls = [...css.matchAll(/url\((https:\/\/[^)]+)\)/gu)].map((m) => m[1] ?? "");
591
771
  for (const url of urls) {
@@ -596,25 +776,25 @@ var init_bundle = __esm({
596
776
  return out;
597
777
  };
598
778
  localDataUri = (dir, href) => {
599
- const file = path3.resolve(dir, href.replace(/^\.\//u, ""));
600
- if (!existsSync3(file)) {
779
+ const file = path4.resolve(dir, href.replace(/^\.\//u, ""));
780
+ if (!existsSync4(file)) {
601
781
  throw new Error(`bundle/missing-asset: ${href} not found in the game folder`);
602
782
  }
603
- const ext = path3.extname(file).slice(1).toLowerCase();
783
+ const ext = path4.extname(file).slice(1).toLowerCase();
604
784
  const mime = MIME[ext];
605
785
  if (!mime) {
606
786
  throw new Error(`bundle/unsupported-asset: .${ext} (${href})`);
607
787
  }
608
- return dataUri(mime, readFileSync3(file, "binary"));
788
+ return dataUri(mime, readFileSync4(file, "binary"));
609
789
  };
610
790
  LEAK_PATTERN = /(?:src|href)\s*=\s*"(https?:\/\/[^"]+)"/gi;
611
791
  bundle = async (options) => {
612
- const dir = path3.resolve(options.dir);
613
- const indexPath = path3.join(dir, "index.html");
614
- if (!existsSync3(indexPath)) {
792
+ const dir = path4.resolve(options.dir);
793
+ const indexPath = path4.join(dir, "index.html");
794
+ if (!existsSync4(indexPath)) {
615
795
  throw new Error("frogoe bundle: no index.html \u2014 run from a game folder");
616
796
  }
617
- const html = readFileSync3(indexPath, "utf-8");
797
+ const html = readFileSync4(indexPath, "utf-8");
618
798
  const assets = [];
619
799
  const warnings = [];
620
800
  let out = html;
@@ -633,7 +813,7 @@ var init_bundle = __esm({
633
813
  const { build } = await import("esbuild");
634
814
  const result = await build({
635
815
  bundle: true,
636
- entryPoints: [path3.resolve(dir, entryMatch[1])],
816
+ entryPoints: [path4.resolve(dir, entryMatch[1])],
637
817
  format: "esm",
638
818
  logLevel: "silent",
639
819
  plugins: [importMapPlugin(dir, imports), cdnPlugin(options, assets)],
@@ -661,7 +841,7 @@ ${bundled}
661
841
  ${css}
662
842
  </style>`;
663
843
  } else {
664
- const css = readFileSync3(path3.resolve(dir, href), "utf-8");
844
+ const css = readFileSync4(path4.resolve(dir, href), "utf-8");
665
845
  replacement = `<style>
666
846
  ${css}
667
847
  </style>`;
@@ -676,7 +856,7 @@ ${css}
676
856
  if (href.endsWith(".js") && entryMatch?.[1] === href) {
677
857
  continue;
678
858
  }
679
- if (existsSync3(path3.resolve(dir, href))) {
859
+ if (existsSync4(path4.resolve(dir, href))) {
680
860
  out = out.replaceAll(href, localDataUri(dir, href));
681
861
  }
682
862
  }
@@ -708,10 +888,22 @@ ${css}
708
888
  });
709
889
 
710
890
  // ../lint/src/brief.ts
711
- var KEY_PATTERN, WS, stripComment, parseLine, parseBrief;
891
+ var VERBS, SESSIONS, KEY_PATTERN, WS, stripComment, parseLine, parseBrief;
712
892
  var init_brief = __esm({
713
893
  "../lint/src/brief.ts"() {
714
894
  "use strict";
895
+ VERBS = [
896
+ "aim",
897
+ "draw",
898
+ "hold",
899
+ "idle",
900
+ "place",
901
+ "steer",
902
+ "swap",
903
+ "tap",
904
+ "type"
905
+ ];
906
+ SESSIONS = ["blitz", "round", "toy"];
715
907
  KEY_PATTERN = /^[a-z-]+$/u;
716
908
  WS = /\s/u;
717
909
  stripComment = (value) => {
@@ -767,8 +959,8 @@ var init_brief = __esm({
767
959
  });
768
960
 
769
961
  // ../lint/src/art.ts
770
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
771
- import path4 from "path";
962
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
963
+ import path5 from "path";
772
964
  var SPECS, MIN_DRAW_CALLS, DRAW_CALL, SPRITE_CALL, checkArt, checkScene, hexColorsOf, stripComments;
773
965
  var init_art = __esm({
774
966
  "../lint/src/art.ts"() {
@@ -782,16 +974,16 @@ var init_art = __esm({
782
974
  DRAW_CALL = /(?:fillRect|strokeRect|clearRect|beginPath|closePath|arc|ellipse|moveTo|lineTo|quadraticCurveTo|bezierCurveTo|fillText|strokeText|drawImage|roundRect|fill|stroke)\s*\(/gu;
783
975
  SPRITE_CALL = /\bdraw[A-Z]\w*\s*\(/gu;
784
976
  checkArt = (dir, findings) => {
785
- const briefPath = path4.join(dir, "BRIEF.md");
786
- const brief = existsSync4(briefPath) ? parseBrief(readFileSync4(briefPath, "utf-8")) : void 0;
977
+ const briefPath = path5.join(dir, "BRIEF.md");
978
+ const brief = existsSync5(briefPath) ? parseBrief(readFileSync5(briefPath, "utf-8")) : void 0;
787
979
  const briefFonts = (brief?.fonts ?? "").split(",").map((token) => token.trim().toLowerCase()).filter((token) => token.length > 0);
788
- const gamePath = path4.join(dir, "game.js");
789
- const gameSource = existsSync4(gamePath) ? readFileSync4(gamePath, "utf-8") : "";
980
+ const gamePath = path5.join(dir, "game.js");
981
+ const gameSource = existsSync5(gamePath) ? readFileSync5(gamePath, "utf-8") : "";
790
982
  const gameColors = hexColorsOf(stripComments(gameSource));
791
- const htmlSource = existsSync4(path4.join(dir, "index.html")) ? readFileSync4(path4.join(dir, "index.html"), "utf-8") : "";
983
+ const htmlSource = existsSync5(path5.join(dir, "index.html")) ? readFileSync5(path5.join(dir, "index.html"), "utf-8") : "";
792
984
  for (const spec of SPECS) {
793
- const scenePath = path4.join(dir, spec.file);
794
- if (!existsSync4(scenePath)) {
985
+ const scenePath = path5.join(dir, spec.file);
986
+ if (!existsSync5(scenePath)) {
795
987
  findings.push({
796
988
  code: "art/missing",
797
989
  file: spec.file,
@@ -802,7 +994,7 @@ var init_art = __esm({
802
994
  });
803
995
  continue;
804
996
  }
805
- const source = stripComments(readFileSync4(scenePath, "utf-8"));
997
+ const source = stripComments(readFileSync5(scenePath, "utf-8"));
806
998
  checkScene(source, spec, gameColors, briefFonts, htmlSource, findings);
807
999
  }
808
1000
  };
@@ -963,19 +1155,47 @@ var init_contrast = __esm({
963
1155
  });
964
1156
 
965
1157
  // ../lint/src/check.ts
966
- import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync5 } from "fs";
967
- import path5 from "path";
968
- var VERBS, read, linesOf, findLine, checkBrief, checkFolder, checkPin, checkProject;
1158
+ import { existsSync as existsSync6, readdirSync, readFileSync as readFileSync6 } from "fs";
1159
+ import path6 from "path";
1160
+ var HANDLER_PATTERNS, VERB_REQUIREMENTS, read, linesOf, findLine, checkBrief, checkFolder, checkPin, checkProject;
969
1161
  var init_check = __esm({
970
1162
  "../lint/src/check.ts"() {
971
1163
  "use strict";
972
1164
  init_brief();
973
1165
  init_contrast();
974
1166
  init_art();
975
- VERBS = /* @__PURE__ */ new Set(["tap", "hold", "steer", "aim"]);
1167
+ HANDLER_PATTERNS = {
1168
+ down: /input\.on\(\s*["']down["']/u,
1169
+ drag: /input\.on\(\s*["']drag["']/u,
1170
+ up: /input\.on\(\s*["']up["']/u
1171
+ };
1172
+ VERB_REQUIREMENTS = {
1173
+ aim: [
1174
+ { label: 'input.on("down"', pattern: HANDLER_PATTERNS.down },
1175
+ { label: 'input.on("drag"', pattern: HANDLER_PATTERNS.drag }
1176
+ ],
1177
+ draw: [
1178
+ { label: 'input.on("down"', pattern: HANDLER_PATTERNS.down },
1179
+ { label: 'input.on("drag"', pattern: HANDLER_PATTERNS.drag },
1180
+ { label: 'input.on("up"', pattern: HANDLER_PATTERNS.up }
1181
+ ],
1182
+ hold: [
1183
+ { label: 'input.on("down"', pattern: HANDLER_PATTERNS.down },
1184
+ { label: 'input.on("up"', pattern: HANDLER_PATTERNS.up }
1185
+ ],
1186
+ idle: [{ label: 'input.on("down"', pattern: HANDLER_PATTERNS.down }],
1187
+ place: [{ label: 'input.on("down"', pattern: HANDLER_PATTERNS.down }],
1188
+ steer: [
1189
+ { label: 'input.on("down"', pattern: HANDLER_PATTERNS.down },
1190
+ { label: 'input.on("drag"', pattern: HANDLER_PATTERNS.drag }
1191
+ ],
1192
+ swap: [{ label: 'input.on("down"', pattern: HANDLER_PATTERNS.down }],
1193
+ tap: [{ label: 'input.on("down"', pattern: HANDLER_PATTERNS.down }],
1194
+ type: [{ label: 'input.on("down"', pattern: HANDLER_PATTERNS.down }]
1195
+ };
976
1196
  read = (file) => {
977
1197
  try {
978
- return readFileSync5(file, "utf-8");
1198
+ return readFileSync6(file, "utf-8");
979
1199
  } catch {
980
1200
  return "";
981
1201
  }
@@ -986,8 +1206,8 @@ var init_check = __esm({
986
1206
  return idx === -1 ? void 0 : idx + 1;
987
1207
  };
988
1208
  checkBrief = (dir, findings) => {
989
- const file = path5.join(dir, "BRIEF.md");
990
- if (!existsSync5(file)) {
1209
+ const file = path6.join(dir, "BRIEF.md");
1210
+ if (!existsSync6(file)) {
991
1211
  findings.push({
992
1212
  code: "brief/missing",
993
1213
  file: "BRIEF.md",
@@ -995,7 +1215,7 @@ var init_check = __esm({
995
1215
  message: "feed games declare intent before code",
996
1216
  severity: "error"
997
1217
  });
998
- return;
1218
+ return null;
999
1219
  }
1000
1220
  const source = read(file);
1001
1221
  const todoLine = findLine(source, /TODO/u);
@@ -1018,14 +1238,32 @@ var init_check = __esm({
1018
1238
  message: "no frontmatter block found",
1019
1239
  severity: "error"
1020
1240
  });
1021
- return;
1241
+ return null;
1242
+ }
1243
+ if (brief.verb !== void 0 && !VERBS.includes(brief.verb)) {
1244
+ findings.push({
1245
+ code: "brief/verb",
1246
+ file: "BRIEF.md",
1247
+ fix: `verb "${brief.verb}" is not in the enum (${VERBS.join("|")}) \u2014 pick the ONE word that names the core action (frogoe-core \u2192 brief-format)`,
1248
+ message: "unknown core verb",
1249
+ severity: "error"
1250
+ });
1251
+ }
1252
+ if (brief.session !== void 0 && !SESSIONS.includes(brief.session)) {
1253
+ findings.push({
1254
+ code: "brief/session",
1255
+ file: "BRIEF.md",
1256
+ fix: `session "${brief.session}" is not in the enum (${SESSIONS.join("|")}) \u2014 blitz is the short arcade loop (default), round is turn-based, toy never ends (frogoe-core \u2192 brief-format)`,
1257
+ message: "unknown session shape",
1258
+ severity: "error"
1259
+ });
1022
1260
  }
1023
1261
  const problems = [];
1024
1262
  if (!brief.title || brief.title.length < 2 || brief.title.length > 40) {
1025
1263
  problems.push("title (2\u201340 chars)");
1026
1264
  }
1027
- if (!brief.verb || !VERBS.has(brief.verb)) {
1028
- problems.push("verb \u2208 tap|hold|steer|aim");
1265
+ if (!brief.verb || !VERBS.includes(brief.verb)) {
1266
+ problems.push(`verb (${VERBS.join("|")})`);
1029
1267
  }
1030
1268
  if (!brief.mood) {
1031
1269
  problems.push("mood (one phrase)");
@@ -1068,9 +1306,10 @@ var init_check = __esm({
1068
1306
  });
1069
1307
  }
1070
1308
  }
1309
+ return brief;
1071
1310
  };
1072
- checkFolder = (dir, findings) => {
1073
- const index = read(path5.join(dir, "index.html"));
1311
+ checkFolder = (dir, findings, brief) => {
1312
+ const index = read(path6.join(dir, "index.html"));
1074
1313
  if (!index) {
1075
1314
  findings.push({
1076
1315
  code: "folder/index-missing",
@@ -1140,7 +1379,7 @@ var init_check = __esm({
1140
1379
  });
1141
1380
  }
1142
1381
  }
1143
- const game = read(path5.join(dir, "game.js"));
1382
+ const game = read(path6.join(dir, "game.js"));
1144
1383
  if (!game) {
1145
1384
  findings.push({
1146
1385
  code: "folder/game-missing",
@@ -1169,6 +1408,21 @@ var init_check = __esm({
1169
1408
  severity: "warning"
1170
1409
  });
1171
1410
  }
1411
+ const requirements = brief?.verb !== void 0 ? VERB_REQUIREMENTS[brief.verb] : void 0;
1412
+ if (requirements !== void 0) {
1413
+ const missing = requirements.filter((req) => !req.pattern.test(game));
1414
+ if (missing.length > 0) {
1415
+ findings.push({
1416
+ code: "input/verb-mismatch",
1417
+ file: "game.js",
1418
+ fix: `verb "${String(brief?.verb)}" requires ${missing.map((m) => m.label).join(" + ")} \u2014 wire it inside defineGame (frogoe-core \u2192 brief-format, verb table)`,
1419
+ line: findLine(game, HANDLER_PATTERNS.down) ?? void 0,
1420
+ message: `declared verb "${String(brief?.verb)}" but its input wiring is missing`,
1421
+ recipe: "frogoe-core \u2192 brief-format (verb \u2192 handler table)",
1422
+ severity: "error"
1423
+ });
1424
+ }
1425
+ }
1172
1426
  const gameLine = (pattern) => findLine(game, pattern);
1173
1427
  const dragLine = gameLine(/\+=\s*(?:p|pt|pointer)\.(?:dx|dy)\b/u);
1174
1428
  if (dragLine !== void 0) {
@@ -1218,12 +1472,12 @@ var init_check = __esm({
1218
1472
  severity: "warning"
1219
1473
  });
1220
1474
  }
1221
- const blocksDir = path5.join(dir, "blocks");
1475
+ const blocksDir = path6.join(dir, "blocks");
1222
1476
  let markup = index;
1223
- if (existsSync5(blocksDir)) {
1477
+ if (existsSync6(blocksDir)) {
1224
1478
  for (const f of readdirSync(blocksDir)) {
1225
1479
  if (f.endsWith(".html")) {
1226
- markup += read(path5.join(blocksDir, f));
1480
+ markup += read(path6.join(blocksDir, f));
1227
1481
  }
1228
1482
  }
1229
1483
  }
@@ -1243,8 +1497,8 @@ var init_check = __esm({
1243
1497
  }
1244
1498
  };
1245
1499
  checkPin = (dir, findings) => {
1246
- const pinFile = path5.join(dir, "frogoe.json");
1247
- if (!existsSync5(pinFile)) {
1500
+ const pinFile = path6.join(dir, "frogoe.json");
1501
+ if (!existsSync6(pinFile)) {
1248
1502
  findings.push({
1249
1503
  code: "folder/contract-pin",
1250
1504
  file: "frogoe.json",
@@ -1259,7 +1513,7 @@ var init_check = __esm({
1259
1513
  pin = String(JSON.parse(read(pinFile)).contract ?? "");
1260
1514
  } catch {
1261
1515
  }
1262
- const contract = read(path5.join(dir, ".frogoe/contract.js"));
1516
+ const contract = read(path6.join(dir, ".frogoe/contract.js"));
1263
1517
  const marker = /frogoe contract v([\d.]+)/u.exec(contract.slice(0, 400));
1264
1518
  if (!contract) {
1265
1519
  findings.push({
@@ -1282,8 +1536,8 @@ var init_check = __esm({
1282
1536
  checkProject = (dir) => {
1283
1537
  const findings = [];
1284
1538
  checkArt(dir, findings);
1285
- checkBrief(dir, findings);
1286
- checkFolder(dir, findings);
1539
+ const brief = checkBrief(dir, findings);
1540
+ checkFolder(dir, findings, brief);
1287
1541
  checkPin(dir, findings);
1288
1542
  findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0));
1289
1543
  return {
@@ -1307,28 +1561,28 @@ var init_src = __esm({
1307
1561
  });
1308
1562
 
1309
1563
  // src/runtime-source.ts
1310
- import { readFileSync as readFileSync6 } from "fs";
1311
- import path6 from "path";
1564
+ import { readFileSync as readFileSync7 } from "fs";
1565
+ import path7 from "path";
1312
1566
  import { fileURLToPath as fileURLToPath2 } from "url";
1313
1567
  var here2, cached, runtimeSource, functions, runtimeFunctions;
1314
1568
  var init_runtime_source = __esm({
1315
1569
  "src/runtime-source.ts"() {
1316
1570
  "use strict";
1317
- here2 = path6.dirname(fileURLToPath2(import.meta.url));
1571
+ here2 = path7.dirname(fileURLToPath2(import.meta.url));
1318
1572
  cached = null;
1319
1573
  runtimeSource = () => {
1320
1574
  if (cached !== null) return cached;
1321
1575
  const candidates = [
1322
- path6.join(here2, "injected-runtime.js"),
1576
+ path7.join(here2, "injected-runtime.js"),
1323
1577
  // source mode (src/ dir)
1324
- path6.join(here2, "dist", "injected-runtime.js"),
1578
+ path7.join(here2, "dist", "injected-runtime.js"),
1325
1579
  // dist mode
1326
- path6.join(here2, "..", "dist", "injected-runtime.js")
1580
+ path7.join(here2, "..", "dist", "injected-runtime.js")
1327
1581
  // dist/cli.js sibling
1328
1582
  ];
1329
1583
  for (const p of candidates) {
1330
1584
  try {
1331
- cached = readFileSync6(p, "utf-8");
1585
+ cached = readFileSync7(p, "utf-8");
1332
1586
  return cached;
1333
1587
  } catch {
1334
1588
  }
@@ -1588,9 +1842,9 @@ var init_art_verify = __esm({
1588
1842
 
1589
1843
  // src/net/tunnel.ts
1590
1844
  import { spawn, spawnSync } from "child_process";
1591
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, chmodSync, writeFileSync as writeFileSync3 } from "fs";
1845
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, chmodSync, writeFileSync as writeFileSync4 } from "fs";
1592
1846
  import os from "os";
1593
- import path7 from "path";
1847
+ import path8 from "path";
1594
1848
  import { gunzipSync } from "zlib";
1595
1849
  var URL_PATTERN, parseTunnelUrl, TAG_PATTERN, assertSafeTag, octalAt, extractSingleFile, ENV_PIN, assetName, binaryFileName, cacheBase, resolveLatestTag, mb, downloadWithProgress, binaryEchoes, resolveBinary, startTunnel;
1596
1850
  var init_tunnel = __esm({
@@ -1647,9 +1901,9 @@ var init_tunnel = __esm({
1647
1901
  };
1648
1902
  binaryFileName = (platform) => platform === "win32" ? "cloudflared.exe" : "cloudflared";
1649
1903
  cacheBase = (platform, env, home) => {
1650
- if (platform === "darwin") return path7.join(home, "Library", "Caches");
1651
- if (platform === "win32") return env.LOCALAPPDATA ?? path7.join(home, "AppData", "Local");
1652
- return path7.join(home, ".cache");
1904
+ if (platform === "darwin") return path8.join(home, "Library", "Caches");
1905
+ if (platform === "win32") return env.LOCALAPPDATA ?? path8.join(home, "AppData", "Local");
1906
+ return path8.join(home, ".cache");
1653
1907
  };
1654
1908
  resolveLatestTag = async () => {
1655
1909
  const res = await fetch("https://api.github.com/repos/cloudflare/cloudflared/releases/latest", {
@@ -1700,15 +1954,15 @@ var init_tunnel = __esm({
1700
1954
  );
1701
1955
  }
1702
1956
  const tag = process.env[ENV_PIN] ? assertSafeTag(process.env[ENV_PIN]) : assertSafeTag(await resolveLatestTag());
1703
- const root = path7.join(cacheBase(platform, process.env, os.homedir()), "frogoe", "cloudflared");
1704
- const dir = path7.join(root, tag);
1705
- const bin = path7.join(dir, binaryFileName(platform));
1706
- const rootResolved = path7.resolve(root);
1707
- const binResolved = path7.resolve(bin);
1708
- if (!binResolved.startsWith(rootResolved + path7.sep)) {
1957
+ const root = path8.join(cacheBase(platform, process.env, os.homedir()), "frogoe", "cloudflared");
1958
+ const dir = path8.join(root, tag);
1959
+ const bin = path8.join(dir, binaryFileName(platform));
1960
+ const rootResolved = path8.resolve(root);
1961
+ const binResolved = path8.resolve(bin);
1962
+ if (!binResolved.startsWith(rootResolved + path8.sep)) {
1709
1963
  throw new Error("cloudflared binary path escaped the frogoe cache \u2014 refusing to execute");
1710
1964
  }
1711
- if (existsSync6(bin) && binaryEchoes(bin, tag)) return { downloaded: false, path: bin };
1965
+ if (existsSync7(bin) && binaryEchoes(bin, tag)) return { downloaded: false, path: bin };
1712
1966
  onProgress?.(`downloading cloudflared ${tag} (~25 MB, once)\u2026`);
1713
1967
  const res = await fetch(
1714
1968
  `https://github.com/cloudflare/cloudflared/releases/download/${tag}/${asset}`
@@ -1719,8 +1973,8 @@ var init_tunnel = __esm({
1719
1973
  });
1720
1974
  const binary = asset.endsWith(".tgz") ? extractSingleFile(gunzipSync(raw), "cloudflared") : raw;
1721
1975
  if (!binary) throw new Error("cloudflared archive did not contain the binary");
1722
- mkdirSync3(dir, { recursive: true });
1723
- writeFileSync3(bin, binary);
1976
+ mkdirSync4(dir, { recursive: true });
1977
+ writeFileSync4(bin, binary);
1724
1978
  if (platform !== "win32") chmodSync(bin, 493);
1725
1979
  if (!binaryEchoes(bin, tag)) {
1726
1980
  throw new Error(
@@ -1805,8 +2059,8 @@ var init_tunnel = __esm({
1805
2059
  });
1806
2060
 
1807
2061
  // src/browser/lock.ts
1808
- import { existsSync as existsSync7, mkdirSync as mkdirSync4, rmSync, statSync, utimesSync } from "fs";
1809
- import path8 from "path";
2062
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, rmSync, statSync, utimesSync } from "fs";
2063
+ import path9 from "path";
1810
2064
  var DEFAULT_LOCK_TIMINGS, LOCK_DIR_NAME, RECLAIM_DIR_NAME, isErrno, sleep2, tryAcquireDirLock, isDirLockStale, touch, reclaimStaleLock, withInstallLock;
1811
2065
  var init_lock = __esm({
1812
2066
  "src/browser/lock.ts"() {
@@ -1828,7 +2082,7 @@ var init_lock = __esm({
1828
2082
  sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
1829
2083
  tryAcquireDirLock = (lockDir) => {
1830
2084
  try {
1831
- mkdirSync4(lockDir, { recursive: false });
2085
+ mkdirSync5(lockDir, { recursive: false });
1832
2086
  return true;
1833
2087
  } catch (error) {
1834
2088
  if (!isErrno(error, "EEXIST")) throw error;
@@ -1863,14 +2117,14 @@ var init_lock = __esm({
1863
2117
  withInstallLock = async (fn, rootDir, options) => {
1864
2118
  const timings = options?.timings ?? DEFAULT_LOCK_TIMINGS;
1865
2119
  const log = options?.log ?? ((line) => console.error(line));
1866
- const lockDir = path8.join(rootDir, LOCK_DIR_NAME);
1867
- const reclaimDir = path8.join(rootDir, RECLAIM_DIR_NAME);
1868
- if (!existsSync7(rootDir)) mkdirSync4(rootDir, { recursive: true });
2120
+ const lockDir = path9.join(rootDir, LOCK_DIR_NAME);
2121
+ const reclaimDir = path9.join(rootDir, RECLAIM_DIR_NAME);
2122
+ if (!existsSync8(rootDir)) mkdirSync5(rootDir, { recursive: true });
1869
2123
  let deadline = Date.now() + timings.staleMs;
1870
2124
  const waitStart = Date.now();
1871
2125
  let lastNoticeMs = 0;
1872
2126
  for (; ; ) {
1873
- if (existsSync7(reclaimDir)) {
2127
+ if (existsSync8(reclaimDir)) {
1874
2128
  if (isDirLockStale(reclaimDir, timings.staleMs)) {
1875
2129
  rmSync(reclaimDir, { recursive: true, force: true });
1876
2130
  }
@@ -1908,9 +2162,9 @@ var init_lock = __esm({
1908
2162
  });
1909
2163
 
1910
2164
  // src/browser/manager.ts
1911
- import { cpSync as cpSync2, existsSync as existsSync8, mkdirSync as mkdirSync5, readdirSync as readdirSync2, renameSync, rmSync as rmSync2 } from "fs";
2165
+ import { cpSync as cpSync2, existsSync as existsSync9, mkdirSync as mkdirSync6, readdirSync as readdirSync2, renameSync, rmSync as rmSync2 } from "fs";
1912
2166
  import { homedir } from "os";
1913
- import path9 from "path";
2167
+ import path10 from "path";
1914
2168
  var CHROME_BUILD, BROWSER_PATH_ENV, CACHE_ROOT, CACHE_DIR, ARCHIVE_CHECKSUMS, messageOf, positiveIntEnv, isCorruptInstallError, browserPathHintForPlatform, wrapDownloadFailure, isTimeoutError, wrapLaunchFailure, findInCache, legacyCacheDir, migrateLegacyCache, pruneLegacyCache, progressReporter, downloadBrowser, resolved, ensureBrowser;
1915
2169
  var init_manager = __esm({
1916
2170
  "src/browser/manager.ts"() {
@@ -1919,8 +2173,8 @@ var init_manager = __esm({
1919
2173
  init_lock();
1920
2174
  CHROME_BUILD = "131.0.6778.204";
1921
2175
  BROWSER_PATH_ENV = "FROGOE_BROWSER_PATH";
1922
- CACHE_ROOT = path9.join(cacheBase(process.platform, process.env, homedir()), "frogoe");
1923
- CACHE_DIR = path9.join(CACHE_ROOT, "chrome");
2176
+ CACHE_ROOT = path10.join(cacheBase(process.platform, process.env, homedir()), "frogoe");
2177
+ CACHE_DIR = path10.join(CACHE_ROOT, "chrome");
1924
2178
  ARCHIVE_CHECKSUMS = {
1925
2179
  linux: "afaac86e302c4874245991a3d509d529c50ecdd447affff0cdc2b08520906c32",
1926
2180
  mac: "933463c27a951d3fc153c408c4b8a96c89b9344379963f68a5ff9aa1718bcaf9",
@@ -1991,28 +2245,28 @@ see skills/frogoe-cli \u2192 references/live-sandbox.md)`,
1991
2245
  );
1992
2246
  };
1993
2247
  findInCache = async (cacheDir, buildId) => {
1994
- if (!existsSync8(cacheDir)) return {};
2248
+ if (!existsSync9(cacheDir)) return {};
1995
2249
  const { Browser, detectBrowserPlatform, getInstalledBrowsers } = await import("@puppeteer/browsers");
1996
2250
  const platform = detectBrowserPlatform();
1997
2251
  const match = (await getInstalledBrowsers({ cacheDir })).find(
1998
2252
  (entry) => entry.browser === Browser.CHROMEHEADLESSSHELL && entry.buildId === buildId && entry.platform === platform
1999
2253
  );
2000
2254
  if (!match) return {};
2001
- if (existsSync8(match.executablePath)) return { executablePath: match.executablePath };
2255
+ if (existsSync9(match.executablePath)) return { executablePath: match.executablePath };
2002
2256
  return { staleInstallPath: match.path };
2003
2257
  };
2004
- legacyCacheDir = (dir) => path9.resolve(dir, "node_modules", ".frogoe-browser");
2258
+ legacyCacheDir = (dir) => path10.resolve(dir, "node_modules", ".frogoe-browser");
2005
2259
  migrateLegacyCache = async (legacyDir, cacheDir, buildId, log) => {
2006
- if (!existsSync8(legacyDir)) return null;
2260
+ if (!existsSync9(legacyDir)) return null;
2007
2261
  const { Browser, getInstalledBrowsers } = await import("@puppeteer/browsers");
2008
2262
  const legacy = (await getInstalledBrowsers({ cacheDir: legacyDir })).find(
2009
- (entry) => entry.browser === Browser.CHROMEHEADLESSSHELL && entry.buildId === buildId && existsSync8(entry.executablePath)
2263
+ (entry) => entry.browser === Browser.CHROMEHEADLESSSHELL && entry.buildId === buildId && existsSync9(entry.executablePath)
2010
2264
  );
2011
2265
  if (!legacy) return null;
2012
- const browserRootName = path9.basename(path9.dirname(legacy.path));
2013
- const installName = path9.basename(legacy.path);
2014
- const destination = path9.join(cacheDir, browserRootName, installName);
2015
- mkdirSync5(path9.dirname(destination), { recursive: true });
2266
+ const browserRootName = path10.basename(path10.dirname(legacy.path));
2267
+ const installName = path10.basename(legacy.path);
2268
+ const destination = path10.join(cacheDir, browserRootName, installName);
2269
+ mkdirSync6(path10.dirname(destination), { recursive: true });
2016
2270
  rmSync2(destination, { recursive: true, force: true });
2017
2271
  try {
2018
2272
  try {
@@ -2034,16 +2288,16 @@ see skills/frogoe-cli \u2192 references/live-sandbox.md)`,
2034
2288
  }
2035
2289
  const found = await findInCache(cacheDir, buildId);
2036
2290
  if (!found.executablePath) return null;
2037
- rmSync2(path9.dirname(legacy.path), { recursive: true, force: true });
2291
+ rmSync2(path10.dirname(legacy.path), { recursive: true, force: true });
2038
2292
  rmSync2(legacyDir, { recursive: true, force: true });
2039
2293
  log?.(` browser: migrated the cached chrome-headless-shell into ${cacheDir} (one-time)`);
2040
2294
  return found.executablePath;
2041
2295
  };
2042
2296
  pruneLegacyCache = (legacyDir, buildId) => {
2043
- if (!existsSync8(legacyDir)) return false;
2297
+ if (!existsSync9(legacyDir)) return false;
2044
2298
  try {
2045
- const browserRoot = path9.join(legacyDir, "chrome-headless-shell");
2046
- if (existsSync8(browserRoot)) {
2299
+ const browserRoot = path10.join(legacyDir, "chrome-headless-shell");
2300
+ if (existsSync9(browserRoot)) {
2047
2301
  const entries = readdirSync2(browserRoot);
2048
2302
  if (entries.length > 0 && !entries.some((name) => name.endsWith(`-${buildId}`))) {
2049
2303
  return false;
@@ -2121,7 +2375,7 @@ see skills/frogoe-cli \u2192 references/live-sandbox.md)`,
2121
2375
  const log = (line) => console.error(line);
2122
2376
  const fromEnv = process.env[BROWSER_PATH_ENV]?.trim();
2123
2377
  if (fromEnv !== void 0 && fromEnv !== "") {
2124
- if (!existsSync8(fromEnv)) {
2378
+ if (!existsSync9(fromEnv)) {
2125
2379
  throw new Error(
2126
2380
  `frogoe browser: ${BROWSER_PATH_ENV} is set but "${fromEnv}" does not exist \u2014 fix the path or unset the variable, then re-run`
2127
2381
  );
@@ -2421,8 +2675,8 @@ var init_records = __esm({
2421
2675
  });
2422
2676
 
2423
2677
  // src/telemetry/session.ts
2424
- import { appendFileSync, mkdirSync as mkdirSync6, readdirSync as readdirSync3 } from "fs";
2425
- import path10 from "path";
2678
+ import { appendFileSync, mkdirSync as mkdirSync7, readdirSync as readdirSync3 } from "fs";
2679
+ import path11 from "path";
2426
2680
  var pad, sessionStamp, createSessionStore, latestSessionFile;
2427
2681
  var init_session = __esm({
2428
2682
  "src/telemetry/session.ts"() {
@@ -2433,25 +2687,25 @@ var init_session = __esm({
2433
2687
  return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
2434
2688
  };
2435
2689
  createSessionStore = (gameDir, startedWall) => {
2436
- const dir = path10.join(gameDir, ".frogoe", "sessions");
2690
+ const dir = path11.join(gameDir, ".frogoe", "sessions");
2437
2691
  let file;
2438
2692
  return {
2439
2693
  file: () => file,
2440
2694
  write: (records) => {
2441
2695
  if (records.length === 0) return;
2442
2696
  if (!file) {
2443
- mkdirSync6(dir, { recursive: true });
2444
- file = path10.join(dir, `${sessionStamp(startedWall)}.jsonl`);
2697
+ mkdirSync7(dir, { recursive: true });
2698
+ file = path11.join(dir, `${sessionStamp(startedWall)}.jsonl`);
2445
2699
  }
2446
2700
  appendFileSync(file, records.map((r) => JSON.stringify(r)).join("\n") + "\n", "utf-8");
2447
2701
  }
2448
2702
  };
2449
2703
  };
2450
2704
  latestSessionFile = (gameDir) => {
2451
- const dir = path10.join(gameDir, ".frogoe", "sessions");
2705
+ const dir = path11.join(gameDir, ".frogoe", "sessions");
2452
2706
  try {
2453
2707
  const files = readdirSync3(dir).filter((f) => f.endsWith(".jsonl")).sort();
2454
- return files.length > 0 ? path10.join(dir, files[files.length - 1] ?? "") : null;
2708
+ return files.length > 0 ? path11.join(dir, files[files.length - 1] ?? "") : null;
2455
2709
  } catch {
2456
2710
  return null;
2457
2711
  }
@@ -2464,9 +2718,9 @@ var run_exports = {};
2464
2718
  __export(run_exports, {
2465
2719
  startServer: () => startServer
2466
2720
  });
2467
- import { existsSync as existsSync9, readFileSync as readFileSync7, statSync as statSync2, watch } from "fs";
2721
+ import { existsSync as existsSync10, readFileSync as readFileSync8, statSync as statSync2, watch } from "fs";
2468
2722
  import os3 from "os";
2469
- import path11 from "path";
2723
+ import path12 from "path";
2470
2724
  import { createAdaptorServer } from "@hono/node-server";
2471
2725
  import { getConnInfo } from "@hono/node-server/conninfo";
2472
2726
  import { Hono } from "hono";
@@ -2475,6 +2729,7 @@ var init_run = __esm({
2475
2729
  "src/run.ts"() {
2476
2730
  "use strict";
2477
2731
  init_ip();
2732
+ init_font_proxy();
2478
2733
  init_records();
2479
2734
  init_session();
2480
2735
  buildDevScript = (version) => `<script>(function(){var v="${String(version)}";try{var es=new EventSource("/__frogoe/reload");es.onmessage=function(){location.reload()};es.onerror=function(){es.close()};}catch(e){}setInterval(function(){fetch("/__frogoe/version",{cache:"no-store"}).then(function(r){return r.text()}).then(function(t){if(t!==v)location.reload()}).catch(function(){})},2000);var fps=[],cnt=0,sec=performance.now(),evs=[],up0=performance.now();function up(){return (performance.now()-up0)/1000}function tick(){cnt++;var n=performance.now();if(n-sec>=1000){fps.push(cnt);cnt=0;sec=n}requestAnimationFrame(tick)}requestAnimationFrame(tick);addEventListener("error",function(e){evs.push({type:"error",msg:String(e.message||e).slice(0,200),up:up()})});addEventListener("unhandledrejection",function(e){evs.push({type:"rejection",msg:String(e.reason).slice(0,200),up:up()})});document.addEventListener("visibilitychange",function(){evs.push({type:document.hidden?"hidden":"visible",up:up()})});function flush(beacon){var p={v:1,up:up(),fps:fps.splice(0),events:evs.splice(0)};if(performance.memory)p.mem=Math.round(performance.memory.usedJSHeapSize/1048576);var b=JSON.stringify(p);if(beacon&&navigator.sendBeacon){navigator.sendBeacon("/__frogoe/metrics",new Blob([b],{type:"application/json"}));return}fetch("/__frogoe/metrics",{method:"POST",body:b,headers:{"content-type":"application/json"},keepalive:true}).catch(function(){});}document.addEventListener("visibilitychange",function(){if(document.hidden)flush(true)});addEventListener("pagehide",function(){flush(true)});setInterval(function(){flush(false)},5000);})();</script>`;
@@ -2494,8 +2749,8 @@ var init_run = __esm({
2494
2749
  woff2: "font/woff2"
2495
2750
  };
2496
2751
  startServer = async (dir, requestedPort = 0, telemetry) => {
2497
- const root = path11.resolve(dir);
2498
- if (!existsSync9(path11.join(root, "index.html"))) {
2752
+ const root = path12.resolve(dir);
2753
+ if (!existsSync10(path12.join(root, "index.html"))) {
2499
2754
  throw new Error(`frogoe run: no index.html in ${root} \u2014 is this a game folder?`);
2500
2755
  }
2501
2756
  const clients = /* @__PURE__ */ new Set();
@@ -2537,6 +2792,22 @@ var init_run = __esm({
2537
2792
  "/__frogoe/version",
2538
2793
  (c) => c.text(String(version), 200, { "cache-control": "no-store" })
2539
2794
  );
2795
+ const fontCacheDir = path12.join(root, ".frogoe", "font-cache");
2796
+ app.get("/__frogoe/font/:token", async (c) => {
2797
+ const url = decodeProxyToken(c.req.param("token"));
2798
+ if (url === null) {
2799
+ return c.text("forbidden", 403);
2800
+ }
2801
+ const result = await proxyFontRequest(url, fontCacheDir);
2802
+ return new Response(result.body, {
2803
+ headers: {
2804
+ "cache-control": "no-store",
2805
+ "content-type": result.contentType,
2806
+ "x-frogoe-cache": result.fromCache ? "hit" : "miss"
2807
+ },
2808
+ status: result.status
2809
+ });
2810
+ });
2540
2811
  app.post("/__frogoe/metrics", async (c) => {
2541
2812
  try {
2542
2813
  const payload = JSON.parse(await c.req.text());
@@ -2553,23 +2824,24 @@ var init_run = __esm({
2553
2824
  });
2554
2825
  app.get("*", (c) => {
2555
2826
  const raw = decodeURIComponent(new URL(c.req.url).pathname);
2556
- const safe = path11.normalize(raw).replaceAll("\\", "/");
2557
- let file = path11.join(root, safe === "/" ? "index.html" : safe);
2827
+ const safe = path12.normalize(raw).replaceAll("\\", "/");
2828
+ let file = path12.join(root, safe === "/" ? "index.html" : safe);
2558
2829
  if (!file.startsWith(root)) {
2559
2830
  return c.text("forbidden", 403);
2560
2831
  }
2561
- if (existsSync9(file) && statSync2(file).isDirectory()) {
2562
- file = path11.join(file, "index.html");
2832
+ if (existsSync10(file) && statSync2(file).isDirectory()) {
2833
+ file = path12.join(file, "index.html");
2563
2834
  }
2564
- if (!existsSync9(file)) {
2835
+ if (!existsSync10(file)) {
2565
2836
  return c.text(`frogoe run: not found: ${raw}`, 404);
2566
2837
  }
2567
- const body = readFileSync7(file);
2568
- const ext = path11.extname(file).slice(1).toLowerCase();
2838
+ const body = readFileSync8(file);
2839
+ const ext = path12.extname(file).slice(1).toLowerCase();
2569
2840
  const type = MIME2[ext] ?? "application/octet-stream";
2570
2841
  if (ext === "html" || ext === "htm") {
2571
2842
  const html = body.toString("utf-8");
2572
- const injected = /<\/body>/iu.test(html) ? html.replace(/<\/body>/iu, `${buildDevScript(version)}</body>`) : html + buildDevScript(version);
2843
+ const proxied = rewriteFontLinks(html);
2844
+ const injected = /<\/body>/iu.test(proxied) ? proxied.replace(/<\/body>/iu, `${buildDevScript(version)}</body>`) : proxied + buildDevScript(version);
2573
2845
  return c.body(injected, 200, {
2574
2846
  "cache-control": "no-store",
2575
2847
  "content-type": type
@@ -2594,7 +2866,7 @@ var init_run = __esm({
2594
2866
  const lan = lanInfo.ip ? `http://${lanInfo.ip}:${port}` : void 0;
2595
2867
  let timer;
2596
2868
  const watcher = watch(root, { recursive: true }, (_event, file) => {
2597
- const first = file?.split(path11.sep)[0];
2869
+ const first = file?.split(path12.sep)[0];
2598
2870
  if (first === "snapshots" || first === ".frogoe" || first === "dist") {
2599
2871
  return;
2600
2872
  }
@@ -2628,8 +2900,8 @@ var init_run = __esm({
2628
2900
  });
2629
2901
 
2630
2902
  // src/raster.ts
2631
- import { existsSync as existsSync10, mkdirSync as mkdirSync7, readFileSync as readFileSync8, statSync as statSync3 } from "fs";
2632
- import path12 from "path";
2903
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync9, statSync as statSync3 } from "fs";
2904
+ import path13 from "path";
2633
2905
  var SCENES, sleep3, rasterScriptFor, rasterizeArt;
2634
2906
  var init_raster = __esm({
2635
2907
  "src/raster.ts"() {
@@ -2644,17 +2916,17 @@ var init_raster = __esm({
2644
2916
  {
2645
2917
  draw: "drawPoster",
2646
2918
  height: 1920,
2647
- out: path12.join("dist", "assets", "poster.png"),
2919
+ out: path13.join("dist", "assets", "poster.png"),
2648
2920
  size: "w, h",
2649
- source: path12.join("assets", "poster.js"),
2921
+ source: path13.join("assets", "poster.js"),
2650
2922
  width: 1080
2651
2923
  },
2652
2924
  {
2653
2925
  draw: "drawIcon",
2654
2926
  height: 1024,
2655
- out: path12.join("dist", "assets", "icon.png"),
2927
+ out: path13.join("dist", "assets", "icon.png"),
2656
2928
  size: "size",
2657
- source: path12.join("assets", "icon.js"),
2929
+ source: path13.join("assets", "icon.js"),
2658
2930
  width: 1024
2659
2931
  }
2660
2932
  ];
@@ -2664,7 +2936,7 @@ var init_raster = __esm({
2664
2936
  const analyze = scene.draw === "drawIcon" ? "analyzeIconCorners" : "analyzeTitleBand";
2665
2937
  return `(async () => {
2666
2938
  ${runtimeSource()}
2667
- const mod = await import("./assets/${path12.basename(scene.source)}");
2939
+ const mod = await import("./assets/${path13.basename(scene.source)}");
2668
2940
  const canvas = document.createElement("canvas");
2669
2941
  canvas.id = "__frogoe_raster";
2670
2942
  canvas.width = ${String(scene.width)};
@@ -2691,8 +2963,8 @@ var init_raster = __esm({
2691
2963
  })()`;
2692
2964
  };
2693
2965
  rasterizeArt = async (options) => {
2694
- const dir = path12.resolve(options.dir);
2695
- const missing = SCENES.filter((scene) => !existsSync10(path12.join(dir, scene.source)));
2966
+ const dir = path13.resolve(options.dir);
2967
+ const missing = SCENES.filter((scene) => !existsSync11(path13.join(dir, scene.source)));
2696
2968
  if (missing.length > 0) {
2697
2969
  throw new Error(
2698
2970
  `bundle/art-missing \u2014 author the identity scenes first (${missing.map((scene) => scene.source).join(", ")}); run \`frogoe check\` and see frogoe-creative \u2192 references/art.md`
@@ -2703,7 +2975,7 @@ var init_raster = __esm({
2703
2975
  const browser = await launchBrowser({ legacyDirs: [legacyCacheDir(dir)] });
2704
2976
  try {
2705
2977
  const base = server.urls.local.replace(/\/$/u, "");
2706
- const brief = parseBrief(readFileSync8(path12.join(dir, "BRIEF.md"), "utf-8"));
2978
+ const brief = parseBrief(readFileSync9(path13.join(dir, "BRIEF.md"), "utf-8"));
2707
2979
  const palette = {
2708
2980
  accent: brief?.accent ?? "#ff3b3b",
2709
2981
  bg: brief?.bg ?? "#101418",
@@ -2749,7 +3021,7 @@ var init_raster = __esm({
2749
3021
  }
2750
3022
  } else {
2751
3023
  const report = await page.evaluate("window.__frogoeArtReport");
2752
- const gameSource = readFileSync8(path12.join(dir, "game.js"), "utf-8");
3024
+ const gameSource = readFileSync9(path13.join(dir, "game.js"), "utf-8");
2753
3025
  const verdict = verifyIconFullbleed(report, [...hexColorsOf(gameSource)]);
2754
3026
  if (verdict !== null) throw new Error(verdict);
2755
3027
  if (metrics.coverage < 0.08) {
@@ -2761,8 +3033,8 @@ var init_raster = __esm({
2761
3033
  await sleep3(80);
2762
3034
  const canvas = await page.$("#__frogoe_raster");
2763
3035
  if (canvas === null) throw new Error(`bundle/art-crash \u2014 ${scene.source}: no canvas`);
2764
- const outPath = path12.join(dir, scene.out);
2765
- mkdirSync7(path12.dirname(outPath), { recursive: true });
3036
+ const outPath = path13.join(dir, scene.out);
3037
+ mkdirSync8(path13.dirname(outPath), { recursive: true });
2766
3038
  await canvas.screenshot({ omitBackground: true, path: outPath, type: "png" });
2767
3039
  files.push({ bytes: statSync3(outPath).size, file: scene.out });
2768
3040
  } finally {
@@ -2784,8 +3056,8 @@ __export(bundle_exports, {
2784
3056
  command: () => command2
2785
3057
  });
2786
3058
  import { defineCommand as defineCommand2 } from "citty";
2787
- import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync4 } from "fs";
2788
- import path13 from "path";
3059
+ import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync5 } from "fs";
3060
+ import path14 from "path";
2789
3061
  var command2;
2790
3062
  var init_bundle2 = __esm({
2791
3063
  "src/commands/bundle.ts"() {
@@ -2801,9 +3073,9 @@ var init_bundle2 = __esm({
2801
3073
  async run({ args }) {
2802
3074
  const dir = args.dir ? String(args.dir) : process.cwd();
2803
3075
  const report = await bundle({ dir });
2804
- const outPath = args.out ? path13.resolve(String(args.out)) : path13.join(dir, "dist", "index.html");
2805
- mkdirSync8(path13.dirname(outPath), { recursive: true });
2806
- writeFileSync4(outPath, report.artifact, "utf-8");
3076
+ const outPath = args.out ? path14.resolve(String(args.out)) : path14.join(dir, "dist", "index.html");
3077
+ mkdirSync9(path14.dirname(outPath), { recursive: true });
3078
+ writeFileSync5(outPath, report.artifact, "utf-8");
2807
3079
  const art = await rasterizeArt({ dir });
2808
3080
  for (const warning of [...report.warnings, ...art.warnings]) {
2809
3081
  console.log(` \u26A0 ${warning}`);
@@ -3062,6 +3334,9 @@ var init_driver = __esm({
3062
3334
  });
3063
3335
  await page.mouse.up();
3064
3336
  },
3337
+ async type(text) {
3338
+ await page.keyboard.type(text, { delay: 30 });
3339
+ },
3065
3340
  async clickRetryAwaitReload(timeoutMs) {
3066
3341
  const interactable = `(() => {
3067
3342
  const b = document.querySelector("[data-block-retry]");
@@ -3118,7 +3393,7 @@ var init_types = __esm({
3118
3393
  });
3119
3394
 
3120
3395
  // src/live/decisions.ts
3121
- var FPS_FLOOR2, FPS_SUSTAINED_WINDOW, FROZEN_STREAK, outlineFinding, collapseFinding, pageErrorFinding, consoleErrorFinding, canvasMissingFinding, canvasUnpaintedFinding, contractMissingFinding, stateStuckFinding, earlyDeathFinding, fpsFinding, fpsSustainedFinding, frozenFrameFinding, pausedFinding, stateCorruptFinding, playabilityFinding, finishEventFinding, neverEndsFinding, audioLockedFinding, noGameoverCardFinding, THROTTLE_RATE, THROTTLED_FPS_FLOOR, fpsThrottledFinding, noRetryFinding, retryDeadFinding, rebootFinding;
3396
+ var FPS_FLOOR2, FPS_SUSTAINED_WINDOW, FROZEN_STREAK, END_BUDGET_MS, ROUND_END_GRACE_MS, endBudgetMs, warnsWhenItNeverEnds, outlineFinding, collapseFinding, pageErrorFinding, consoleErrorFinding, canvasMissingFinding, canvasUnpaintedFinding, contractMissingFinding, stateStuckFinding, earlyDeathFinding, fpsFinding, fpsSustainedFinding, frozenFrameFinding, pausedFinding, stateCorruptFinding, playabilityFinding, finishEventFinding, neverEndsFinding, audioLockedFinding, noGameoverCardFinding, THROTTLE_RATE, THROTTLED_FPS_FLOOR, fpsThrottledFinding, noRetryFinding, retryDeadFinding, rebootFinding;
3122
3397
  var init_decisions = __esm({
3123
3398
  "src/live/decisions.ts"() {
3124
3399
  "use strict";
@@ -3126,6 +3401,10 @@ var init_decisions = __esm({
3126
3401
  FPS_FLOOR2 = 30;
3127
3402
  FPS_SUSTAINED_WINDOW = 3;
3128
3403
  FROZEN_STREAK = 3;
3404
+ END_BUDGET_MS = 45e3;
3405
+ ROUND_END_GRACE_MS = 8e3;
3406
+ endBudgetMs = (session) => session === "round" ? ROUND_END_GRACE_MS : session === "toy" ? 0 : END_BUDGET_MS;
3407
+ warnsWhenItNeverEnds = (session) => session === "blitz";
3129
3408
  outlineFinding = (measures) => {
3130
3409
  const bare = measures.filter((m) => !m.hasOutline);
3131
3410
  if (bare.length === 0 || !bare[0]) {
@@ -3341,7 +3620,7 @@ var init_decisions = __esm({
3341
3620
  neverEndsFinding = (budgetMs) => finding({
3342
3621
  code: "live/never-ends",
3343
3622
  file: "game.js",
3344
- fix: `no death within ${Math.round(budgetMs / 1e3)}s of passive play \u2014 fine for endless/sandbox games, but feed games are short replayable loops; most deaths should arrive in seconds`,
3623
+ fix: `no death within ${Math.round(budgetMs / 1e3)}s of passive play \u2014 blitz feed games are short loops; declare session: round in BRIEF.md for turn-based games or session: toy for endless toys, and the gate stops waiting`,
3345
3624
  message: "game never reached the over state",
3346
3625
  phase: "end",
3347
3626
  severity: "warning"
@@ -3418,12 +3697,11 @@ var init_decisions = __esm({
3418
3697
  });
3419
3698
 
3420
3699
  // src/live/phases.ts
3421
- var END_BUDGET_MS, RETRY_NAV_MS, PLAY_STEPS, PLAY_STEP_MS, HOLD_STEP_INDEX, DRAG_STEP_INDEX, DRAG_SPAN, HOLD_MS, POLL_MS, GRACE_MS, STABILITY_CYCLES, START_BURST_TAPS, DESKTOP_FPS_MS, sleep4, jitterX, jitterY, hasError, runBootChecks, runDesktopPass, overShotName, retryShotName, waitForOver, runStartBurst, verifyDeath, runLifecycle;
3700
+ var RETRY_NAV_MS, PLAY_STEPS, PLAY_STEP_MS, HOLD_STEP_INDEX, DRAG_STEP_INDEX, DRAG_SPAN, HOLD_MS, POLL_MS, GRACE_MS, STABILITY_CYCLES, START_BURST_TAPS, DESKTOP_FPS_MS, sleep4, jitterX, jitterY, ladderFor, execStep, hasError, runBootChecks, runDesktopPass, overShotName, retryShotName, waitForOver, runStartBurst, verifyDeath, runLifecycle;
3422
3701
  var init_phases = __esm({
3423
3702
  "src/live/phases.ts"() {
3424
3703
  "use strict";
3425
3704
  init_decisions();
3426
- END_BUDGET_MS = 45e3;
3427
3705
  RETRY_NAV_MS = 8e3;
3428
3706
  PLAY_STEPS = 7;
3429
3707
  PLAY_STEP_MS = 850;
@@ -3441,6 +3719,93 @@ var init_phases = __esm({
3441
3719
  });
3442
3720
  jitterX = (step) => step * 37 % 121 - 60;
3443
3721
  jitterY = (step) => step * 53 % 181 - 90;
3722
+ ladderFor = (verb, viewport) => {
3723
+ const cx = Math.round(viewport.width / 2);
3724
+ const cy = Math.round(viewport.height / 2);
3725
+ const tap = (step) => ({
3726
+ kind: "tap",
3727
+ x: Math.round(cx + jitterX(step)),
3728
+ y: Math.round(cy + jitterY(step))
3729
+ });
3730
+ const sweep = (step) => {
3731
+ const x = Math.round(cx + jitterX(step));
3732
+ const y = Math.round(cy + jitterY(step));
3733
+ return { kind: "drag", x1: x - DRAG_SPAN, x2: x + DRAG_SPAN, y1: y, y2: y };
3734
+ };
3735
+ const base = () => Array.from({ length: PLAY_STEPS }, (_, step) => {
3736
+ if (step === HOLD_STEP_INDEX) {
3737
+ return { kind: "hold", ms: HOLD_MS, x: tap(step).x, y: tap(step).y };
3738
+ }
3739
+ if (step === DRAG_STEP_INDEX) return sweep(step);
3740
+ return tap(step);
3741
+ });
3742
+ switch (verb) {
3743
+ case "swap": {
3744
+ const gap = 34;
3745
+ return [
3746
+ { kind: "tap", x: cx - gap, y: Math.round(cy + jitterY(0)) },
3747
+ { kind: "tap", x: cx + gap, y: Math.round(cy + jitterY(0)) },
3748
+ { kind: "tap", x: Math.round(cx + jitterX(2)), y: cy - gap },
3749
+ { kind: "tap", x: Math.round(cx + jitterX(2)), y: cy + gap },
3750
+ { kind: "tap", x: cx - gap, y: Math.round(cy + jitterY(4)) },
3751
+ { kind: "tap", x: cx + gap, y: Math.round(cy + jitterY(4)) },
3752
+ sweep(0),
3753
+ { kind: "drag", x1: cx, x2: cx, y1: cy + DRAG_SPAN, y2: cy - DRAG_SPAN }
3754
+ ];
3755
+ }
3756
+ case "place": {
3757
+ return [
3758
+ { kind: "tap", x: cx - 80, y: cy - 60 },
3759
+ { kind: "tap", x: cx + 40, y: cy + 20 },
3760
+ { kind: "tap", x: cx + 80, y: cy - 40 },
3761
+ { kind: "tap", x: cx - 30, y: cy + 70 },
3762
+ { kind: "tap", x: Math.round(cx + jitterX(4)), y: cy },
3763
+ { kind: "tap", x: cx - 70, y: cy + 80 },
3764
+ { kind: "drag", x1: cx - 60, x2: cx + 60, y1: cy, y2: cy }
3765
+ ];
3766
+ }
3767
+ case "type": {
3768
+ return [
3769
+ { kind: "type", text: "frogoe" },
3770
+ tap(0),
3771
+ { kind: "type", text: "glow" },
3772
+ tap(2),
3773
+ { kind: "hold", ms: HOLD_MS, x: tap(3).x, y: tap(3).y },
3774
+ sweep(5),
3775
+ { kind: "type", text: "game" },
3776
+ tap(6)
3777
+ ];
3778
+ }
3779
+ case "draw": {
3780
+ return [
3781
+ { kind: "drag", x1: cx - 90, x2: cx + 90, y1: cy - 50, y2: cy + 30 },
3782
+ { kind: "tap", x: cx + 60, y: cy - 60 },
3783
+ { kind: "drag", x1: cx + 80, x2: cx - 70, y1: cy - 20, y2: cy + 70 },
3784
+ { kind: "hold", ms: HOLD_MS, x: cx, y: cy },
3785
+ { kind: "drag", x1: cx - 40, x2: cx + 70, y1: cy + 90, y2: cy - 90 },
3786
+ { kind: "tap", x: cx - 60, y: cy + 20 }
3787
+ ];
3788
+ }
3789
+ default:
3790
+ return base();
3791
+ }
3792
+ };
3793
+ execStep = async (driver, step) => {
3794
+ switch (step.kind) {
3795
+ case "tap":
3796
+ await driver.tap(step.x, step.y);
3797
+ break;
3798
+ case "hold":
3799
+ await driver.hold(step.x, step.y, step.ms);
3800
+ break;
3801
+ case "drag":
3802
+ await driver.drag(step.x1, step.y1, step.x2, step.y2);
3803
+ break;
3804
+ case "type":
3805
+ await driver.type(step.text);
3806
+ break;
3807
+ }
3808
+ };
3444
3809
  hasError = (findings) => findings.some((f) => f.severity === "error");
3445
3810
  runBootChecks = async (driver, ctx) => {
3446
3811
  const findings = [];
@@ -3504,8 +3869,8 @@ var init_phases = __esm({
3504
3869
  };
3505
3870
  overShotName = (cycle) => cycle === 0 ? "live-mobile-over.png" : `live-mobile-over-${cycle + 1}.png`;
3506
3871
  retryShotName = (cycle) => cycle === 0 ? "live-mobile-retry.png" : `live-mobile-retry-${cycle + 1}.png`;
3507
- waitForOver = async (driver, doSleep) => {
3508
- for (let waited = 0; waited < END_BUDGET_MS; waited += POLL_MS) {
3872
+ waitForOver = async (driver, doSleep, budgetMs) => {
3873
+ for (let waited = 0; waited < budgetMs; waited += POLL_MS) {
3509
3874
  await doSleep(POLL_MS);
3510
3875
  if (await driver.gameState() === "over") {
3511
3876
  return true;
@@ -3554,6 +3919,7 @@ var init_phases = __esm({
3554
3919
  if (hasError(boot)) {
3555
3920
  return { findings, lifecycle: { ends: false, retryReloads: 0 }, playability: "no-input" };
3556
3921
  }
3922
+ const ladder = ladderFor(ctx.verb ?? "tap", ctx.viewport);
3557
3923
  const mark = await driver.fpsMark();
3558
3924
  const hashes = [];
3559
3925
  let streak = 0;
@@ -3562,16 +3928,8 @@ var init_phases = __esm({
3562
3928
  let sawPaused = false;
3563
3929
  let sawStuck = false;
3564
3930
  let corrupt = null;
3565
- for (let step = 0; step < PLAY_STEPS; step++) {
3566
- const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
3567
- const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
3568
- if (step === HOLD_STEP_INDEX) {
3569
- await driver.hold(x, y, HOLD_MS);
3570
- } else if (step === DRAG_STEP_INDEX) {
3571
- await driver.drag(x - DRAG_SPAN, y, x + DRAG_SPAN, y);
3572
- } else {
3573
- await driver.tap(x, y);
3574
- }
3931
+ for (const step of ladder) {
3932
+ await execStep(driver, step);
3575
3933
  await doSleep(PLAY_STEP_MS);
3576
3934
  const state = await driver.gameState();
3577
3935
  if (state === "over") {
@@ -3639,13 +3997,17 @@ var init_phases = __esm({
3639
3997
  findings.push(audioFinding);
3640
3998
  }
3641
3999
  }
4000
+ const session = ctx.session ?? "blitz";
4001
+ const budget = endBudgetMs(session);
3642
4002
  let ends = false;
3643
4003
  let retryReloads = 0;
3644
- if (!sawOver) {
3645
- sawOver = await waitForOver(driver, doSleep);
4004
+ if (!sawOver && budget > 0) {
4005
+ sawOver = await waitForOver(driver, doSleep, budget);
3646
4006
  }
3647
4007
  if (!sawOver) {
3648
- findings.push(neverEndsFinding(END_BUDGET_MS));
4008
+ if (warnsWhenItNeverEnds(session)) {
4009
+ findings.push(neverEndsFinding(END_BUDGET_MS));
4010
+ }
3649
4011
  } else {
3650
4012
  ends = true;
3651
4013
  let canRetry = await verifyDeath(driver, ctx, findings, 0);
@@ -3672,7 +4034,7 @@ var init_phases = __esm({
3672
4034
  await ctx.shot?.(retryShotName(cycle));
3673
4035
  if (cycle < STABILITY_CYCLES - 1) {
3674
4036
  await runStartBurst(driver, ctx);
3675
- const overAgain = await waitForOver(driver, doSleep);
4037
+ const overAgain = await waitForOver(driver, doSleep, END_BUDGET_MS);
3676
4038
  if (!overAgain) {
3677
4039
  findings.push(neverEndsFinding(END_BUDGET_MS));
3678
4040
  break;
@@ -3684,16 +4046,8 @@ var init_phases = __esm({
3684
4046
  const throttleMark = await driver.fpsMark();
3685
4047
  await driver.setCpuThrottling(THROTTLE_RATE);
3686
4048
  await runStartBurst(driver, ctx);
3687
- for (let step = 0; step < PLAY_STEPS; step++) {
3688
- const x = Math.round(ctx.viewport.width / 2 + jitterX(step));
3689
- const y = Math.round(ctx.viewport.height / 2 + jitterY(step));
3690
- if (step === HOLD_STEP_INDEX) {
3691
- await driver.hold(x, y, HOLD_MS);
3692
- } else if (step === DRAG_STEP_INDEX) {
3693
- await driver.drag(x - DRAG_SPAN, y, x + DRAG_SPAN, y);
3694
- } else {
3695
- await driver.tap(x, y);
3696
- }
4049
+ for (const step of ladder) {
4050
+ await execStep(driver, step);
3697
4051
  await doSleep(PLAY_STEP_MS);
3698
4052
  }
3699
4053
  const throttledBuckets = await driver.fpsSince(throttleMark);
@@ -3717,12 +4071,13 @@ var live_exports = {};
3717
4071
  __export(live_exports, {
3718
4072
  collectLive: () => collectLive
3719
4073
  });
3720
- import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync5 } from "fs";
3721
- import path14 from "path";
3722
- var VIEWPORTS, waitForServer, collectLive;
4074
+ import { mkdirSync as mkdirSync10, readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "fs";
4075
+ import path15 from "path";
4076
+ var VIEWPORTS, declaredIntent, waitForServer, collectLive;
3723
4077
  var init_live = __esm({
3724
4078
  "src/live/index.ts"() {
3725
4079
  "use strict";
4080
+ init_src();
3726
4081
  init_launch();
3727
4082
  init_manager();
3728
4083
  init_driver();
@@ -3731,6 +4086,17 @@ var init_live = __esm({
3731
4086
  { height: 844, name: "mobile", width: 390 },
3732
4087
  { height: 800, name: "desktop", width: 1280 }
3733
4088
  ];
4089
+ declaredIntent = (dir) => {
4090
+ try {
4091
+ const brief = parseBrief(readFileSync10(path15.join(dir, "BRIEF.md"), "utf-8"));
4092
+ return {
4093
+ session: brief?.session !== void 0 && SESSIONS.includes(brief.session) ? brief.session : "blitz",
4094
+ verb: brief?.verb !== void 0 && VERBS.includes(brief.verb) ? brief.verb : "tap"
4095
+ };
4096
+ } catch {
4097
+ return { session: "blitz", verb: "tap" };
4098
+ }
4099
+ };
3734
4100
  waitForServer = async (url) => {
3735
4101
  for (let attempt = 0; attempt < 10; attempt++) {
3736
4102
  try {
@@ -3744,7 +4110,7 @@ var init_live = __esm({
3744
4110
  }
3745
4111
  };
3746
4112
  collectLive = async (options) => {
3747
- const dir = path14.resolve(options.dir);
4113
+ const dir = path15.resolve(options.dir);
3748
4114
  const settle = options.settleMs ?? 2e3;
3749
4115
  const findings = [];
3750
4116
  const screenshots = [];
@@ -3754,8 +4120,9 @@ var init_live = __esm({
3754
4120
  };
3755
4121
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_run(), run_exports));
3756
4122
  const server = await startServer2(dir);
3757
- const snapshotDir = path14.join(dir, "snapshots");
3758
- mkdirSync9(snapshotDir, { recursive: true });
4123
+ const snapshotDir = path15.join(dir, "snapshots");
4124
+ mkdirSync10(snapshotDir, { recursive: true });
4125
+ const intent = declaredIntent(dir);
3759
4126
  const browser = await launchBrowser({ legacyDirs: [legacyCacheDir(dir)] });
3760
4127
  try {
3761
4128
  let serverReady = false;
@@ -3768,8 +4135,8 @@ var init_live = __esm({
3768
4135
  size: { height: viewport2.height, width: viewport2.width }
3769
4136
  });
3770
4137
  const shot = async (name) => {
3771
- writeFileSync5(path14.join(snapshotDir, name), await driver.screenshot());
3772
- screenshots.push(path14.join("snapshots", name));
4138
+ writeFileSync6(path15.join(snapshotDir, name), await driver.screenshot());
4139
+ screenshots.push(path15.join("snapshots", name));
3773
4140
  };
3774
4141
  if (!serverReady) {
3775
4142
  await waitForServer(server.urls.local);
@@ -3781,6 +4148,8 @@ var init_live = __esm({
3781
4148
  const outcome = await runLifecycle(driver, {
3782
4149
  settleMs: settle,
3783
4150
  shot,
4151
+ verb: intent.verb,
4152
+ session: intent.session,
3784
4153
  viewport: viewport2
3785
4154
  });
3786
4155
  findings.push(...outcome.findings);
@@ -4204,9 +4573,9 @@ var init_compose = __esm({
4204
4573
  });
4205
4574
 
4206
4575
  // src/manifest.ts
4207
- import { createHash as createHash2 } from "crypto";
4208
- import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
4209
- import path15 from "path";
4576
+ import { createHash as createHash3 } from "crypto";
4577
+ import { existsSync as existsSync12, readFileSync as readFileSync11 } from "fs";
4578
+ import path16 from "path";
4210
4579
  var DESCRIPTION_CAP, descriptionFrom, buildManifest;
4211
4580
  var init_manifest = __esm({
4212
4581
  "src/manifest.ts"() {
@@ -4225,16 +4594,16 @@ var init_manifest = __esm({
4225
4594
  return null;
4226
4595
  };
4227
4596
  buildManifest = (options) => {
4228
- const dir = path15.resolve(options.dir);
4229
- const briefSource = readFileSync9(path15.join(dir, "BRIEF.md"), "utf-8");
4597
+ const dir = path16.resolve(options.dir);
4598
+ const briefSource = readFileSync11(path16.join(dir, "BRIEF.md"), "utf-8");
4230
4599
  const brief = parseBrief(briefSource);
4231
4600
  if (!brief?.title) return null;
4232
- const pin = existsSync11(path15.join(dir, "frogoe.json")) ? JSON.parse(readFileSync9(path15.join(dir, "frogoe.json"), "utf-8")).contract ?? "0.1.0" : "0.1.0";
4233
- const artifact = options.artifactHtml ?? readFileSync9(path15.join(dir, "dist", "index.html"), "utf-8");
4234
- const sha2562 = createHash2("sha256").update(artifact, "utf-8").digest("hex");
4601
+ const pin = existsSync12(path16.join(dir, "frogoe.json")) ? JSON.parse(readFileSync11(path16.join(dir, "frogoe.json"), "utf-8")).contract ?? "0.1.0" : "0.1.0";
4602
+ const artifact = options.artifactHtml ?? readFileSync11(path16.join(dir, "dist", "index.html"), "utf-8");
4603
+ const sha2562 = createHash3("sha256").update(artifact, "utf-8").digest("hex");
4235
4604
  const mediaSha = (file) => {
4236
- const full = path15.join(dir, "dist", "assets", file);
4237
- return existsSync11(full) ? createHash2("sha256").update(readFileSync9(full)).digest("hex") : null;
4605
+ const full = path16.join(dir, "dist", "assets", file);
4606
+ return existsSync12(full) ? createHash3("sha256").update(readFileSync11(full)).digest("hex") : null;
4238
4607
  };
4239
4608
  return {
4240
4609
  artifact: "index.html",
@@ -4243,7 +4612,7 @@ var init_manifest = __esm({
4243
4612
  description: descriptionFrom(briefSource),
4244
4613
  entry: "index.html",
4245
4614
  fonts: brief.fonts ?? null,
4246
- icon: existsSync11(path15.join(dir, "dist", "assets", "icon.png")) ? "assets/icon.png" : null,
4615
+ icon: existsSync12(path16.join(dir, "dist", "assets", "icon.png")) ? "assets/icon.png" : null,
4247
4616
  iconSha256: mediaSha("icon.png"),
4248
4617
  mood: brief.mood ?? null,
4249
4618
  palette: {
@@ -4252,8 +4621,9 @@ var init_manifest = __esm({
4252
4621
  fg: brief.fg ?? "",
4253
4622
  ...brief.outline ? { outline: brief.outline } : {}
4254
4623
  },
4255
- poster: existsSync11(path15.join(dir, "dist", "assets", "poster.png")) ? "assets/poster.png" : null,
4624
+ poster: existsSync12(path16.join(dir, "dist", "assets", "poster.png")) ? "assets/poster.png" : null,
4256
4625
  posterSha256: mediaSha("poster.png"),
4626
+ session: brief.session !== void 0 && SESSIONS.includes(brief.session) ? brief.session : "blitz",
4257
4627
  title: brief.title,
4258
4628
  verb: brief.verb ?? "tap"
4259
4629
  };
@@ -4266,8 +4636,8 @@ var embed_exports = {};
4266
4636
  __export(embed_exports, {
4267
4637
  command: () => command4
4268
4638
  });
4269
- import { existsSync as existsSync12, mkdirSync as mkdirSync10, readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "fs";
4270
- import path16 from "path";
4639
+ import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync12, writeFileSync as writeFileSync7 } from "fs";
4640
+ import path17 from "path";
4271
4641
  import { defineCommand as defineCommand4 } from "citty";
4272
4642
  var dataUri2, command4;
4273
4643
  var init_embed = __esm({
@@ -4277,15 +4647,15 @@ var init_embed = __esm({
4277
4647
  init_card();
4278
4648
  init_compose();
4279
4649
  init_manifest();
4280
- dataUri2 = (file) => existsSync12(file) ? `data:image/png;base64,${readFileSync10(file).toString("base64")}` : null;
4650
+ dataUri2 = (file) => existsSync13(file) ? `data:image/png;base64,${readFileSync12(file).toString("base64")}` : null;
4281
4651
  command4 = defineCommand4({
4282
4652
  args: {
4283
4653
  dir: { type: "positional", required: false, description: "game folder (default: cwd)" }
4284
4654
  },
4285
4655
  async run({ args }) {
4286
4656
  const dir = args.dir ? String(args.dir) : process.cwd();
4287
- const artifactPath = path16.join(dir, "dist", "index.html");
4288
- if (!existsSync12(artifactPath)) {
4657
+ const artifactPath = path17.join(dir, "dist", "index.html");
4658
+ if (!existsSync13(artifactPath)) {
4289
4659
  throw new Error(
4290
4660
  "frogoe embed: no dist/index.html \u2014 run `frogoe bundle` first (and `frogoe check` before it: check \u2192 bundle \u2192 embed)"
4291
4661
  );
@@ -4301,20 +4671,20 @@ var init_embed = __esm({
4301
4671
  if (!manifest) {
4302
4672
  throw new Error("frogoe embed: could not build the manifest from BRIEF.md");
4303
4673
  }
4304
- const posterDataUri = dataUri2(path16.join(dir, "dist", "assets", "poster.png"));
4305
- const iconDataUri = dataUri2(path16.join(dir, "dist", "assets", "icon.png"));
4306
- const gameHtml = readFileSync10(artifactPath, "utf-8");
4674
+ const posterDataUri = dataUri2(path17.join(dir, "dist", "assets", "poster.png"));
4675
+ const iconDataUri = dataUri2(path17.join(dir, "dist", "assets", "icon.png"));
4676
+ const gameHtml = readFileSync12(artifactPath, "utf-8");
4307
4677
  const { payloadB64 } = composePayload(gameHtml);
4308
4678
  const html = composeEmbedHtml({ iconDataUri, manifest, payloadB64, posterDataUri });
4309
- mkdirSync10(path16.join(dir, "dist"), { recursive: true });
4310
- writeFileSync6(
4311
- path16.join(dir, "dist", "manifest.json"),
4679
+ mkdirSync11(path17.join(dir, "dist"), { recursive: true });
4680
+ writeFileSync7(
4681
+ path17.join(dir, "dist", "manifest.json"),
4312
4682
  `${JSON.stringify(manifest, null, 2)}
4313
4683
  `,
4314
4684
  "utf-8"
4315
4685
  );
4316
- const outPath = path16.join(dir, "dist", "embed.html");
4317
- writeFileSync6(outPath, html, "utf-8");
4686
+ const outPath = path17.join(dir, "dist", "embed.html");
4687
+ writeFileSync7(outPath, html, "utf-8");
4318
4688
  console.log(` frogoe embed \u2192 ${outPath}`);
4319
4689
  console.log(
4320
4690
  ` ${html.length.toLocaleString("en-US")} bytes \xB7 sandbox allow-scripts \xB7 sha256 ${manifest.artifactSha256.slice(0, 12)}`
@@ -4441,8 +4811,8 @@ var vision_exports = {};
4441
4811
  __export(vision_exports, {
4442
4812
  command: () => command5
4443
4813
  });
4444
- import { existsSync as existsSync13, readFileSync as readFileSync11 } from "fs";
4445
- import path17 from "path";
4814
+ import { existsSync as existsSync14, readFileSync as readFileSync13 } from "fs";
4815
+ import path18 from "path";
4446
4816
  import { defineCommand as defineCommand5 } from "citty";
4447
4817
  var HELPERS, PAGE_SCRIPT, show, metricLine, command5;
4448
4818
  var init_vision = __esm({
@@ -4562,14 +4932,14 @@ var init_vision = __esm({
4562
4932
  objects: { type: "boolean", description: "SPRITES objects only" }
4563
4933
  },
4564
4934
  async run({ args }) {
4565
- const dir = path17.resolve(args.dir ? String(args.dir) : process.cwd());
4566
- const briefPath = path17.join(dir, "BRIEF.md");
4567
- if (!existsSync13(briefPath)) {
4935
+ const dir = path18.resolve(args.dir ? String(args.dir) : process.cwd());
4936
+ const briefPath = path18.join(dir, "BRIEF.md");
4937
+ if (!existsSync14(briefPath)) {
4568
4938
  throw new Error(
4569
4939
  "frogoe vision: no BRIEF.md in this folder \u2014 run this from a game (frogoe init)"
4570
4940
  );
4571
4941
  }
4572
- const brief = parseBrief(readFileSync11(briefPath, "utf-8"));
4942
+ const brief = parseBrief(readFileSync13(briefPath, "utf-8"));
4573
4943
  if (!brief) {
4574
4944
  throw new Error("frogoe vision: BRIEF.md is present but unparsable \u2014 fill the frontmatter");
4575
4945
  }
@@ -4676,7 +5046,7 @@ var init_vision = __esm({
4676
5046
  console.log(show(icon, pretty));
4677
5047
  }
4678
5048
  }
4679
- if (!targeted && !existsSync13(path17.join(dir, "assets", "poster.js"))) {
5049
+ if (!targeted && !existsSync14(path18.join(dir, "assets", "poster.js"))) {
4680
5050
  console.log("\n(note: run `frogoe check` first \u2014 vision only looks, it never gates)");
4681
5051
  }
4682
5052
  },
@@ -4758,7 +5128,7 @@ var report_exports = {};
4758
5128
  __export(report_exports, {
4759
5129
  command: () => command8
4760
5130
  });
4761
- import { readFileSync as readFileSync12 } from "fs";
5131
+ import { readFileSync as readFileSync14 } from "fs";
4762
5132
  import { defineCommand as defineCommand8 } from "citty";
4763
5133
  var command8;
4764
5134
  var init_report = __esm({
@@ -4777,7 +5147,7 @@ var init_report = __esm({
4777
5147
  console.log(`frogoe report: no sessions in ${dir} \u2014 play a run under \`frogoe run\` first`);
4778
5148
  return;
4779
5149
  }
4780
- const records = readFileSync12(file, "utf-8").split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
5150
+ const records = readFileSync14(file, "utf-8").split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
4781
5151
  const s = summarizeRecords(records);
4782
5152
  console.log(`
4783
5153
  frogoe report \u2014 ${file}`);
@@ -4815,8 +5185,8 @@ var init_firewall = __esm({
4815
5185
  return null;
4816
5186
  }
4817
5187
  };
4818
- binaryBlocked = (path18) => {
4819
- const out = run(`--getappblocked "${path18}"`);
5188
+ binaryBlocked = (path19) => {
5189
+ const out = run(`--getappblocked "${path19}"`);
4820
5190
  if (out === null) return null;
4821
5191
  if (/blocked/iu.test(out)) return true;
4822
5192
  if (/allowed/iu.test(out)) return false;
@@ -5000,8 +5370,8 @@ var init_run2 = __esm({
5000
5370
 
5001
5371
  // src/utils/skillsManifest.ts
5002
5372
  import { execFile } from "child_process";
5003
- import { createHash as createHash3 } from "crypto";
5004
- import { existsSync as existsSync14, readdirSync as readdirSync4, readFileSync as readFileSync13, statSync as statSync4 } from "fs";
5373
+ import { createHash as createHash4 } from "crypto";
5374
+ import { existsSync as existsSync15, readdirSync as readdirSync4, readFileSync as readFileSync15, statSync as statSync4 } from "fs";
5005
5375
  import { homedir as homedir2 } from "os";
5006
5376
  import { isAbsolute, join, relative, resolve, sep } from "path";
5007
5377
  import { promisify } from "util";
@@ -5023,13 +5393,13 @@ function listFilesSorted(dir) {
5023
5393
  }
5024
5394
  function hashSkillBundle(skillDir) {
5025
5395
  const files = listFilesSorted(skillDir);
5026
- const h = createHash3("sha256");
5396
+ const h = createHash4("sha256");
5027
5397
  for (const f of files) {
5028
5398
  const rel = relative(skillDir, f).split(sep).join("/");
5029
5399
  h.update(rel);
5030
5400
  h.update("\0");
5031
5401
  const ext = rel.slice(rel.lastIndexOf("."));
5032
- const buf = readFileSync13(f);
5402
+ const buf = readFileSync15(f);
5033
5403
  if (TEXT_EXT.has(ext)) h.update(buf.toString("utf8").replace(/\r\n/g, "\n"), "utf8");
5034
5404
  else h.update(buf);
5035
5405
  h.update("\0");
@@ -5037,7 +5407,7 @@ function hashSkillBundle(skillDir) {
5037
5407
  return { hash: h.digest("hex").slice(0, 16), files: files.length };
5038
5408
  }
5039
5409
  function buildManifest2(skillsRoot, meta) {
5040
- const names = readdirSync4(skillsRoot).filter((n) => existsSync14(join(skillsRoot, n, "SKILL.md"))).sort();
5410
+ const names = readdirSync4(skillsRoot).filter((n) => existsSync15(join(skillsRoot, n, "SKILL.md"))).sort();
5041
5411
  const skills = {};
5042
5412
  for (const name of names) skills[name] = hashSkillBundle(join(skillsRoot, name));
5043
5413
  return { source: meta.source, skills };
@@ -5062,7 +5432,7 @@ function discoverSkillRoots(base, scope) {
5062
5432
  const candidates = [];
5063
5433
  const add = (hostBase, host) => {
5064
5434
  const dir = join(hostBase, host, "skills");
5065
- if (existsSync14(dir) && statSync4(dir).isDirectory())
5435
+ if (existsSync15(dir) && statSync4(dir).isDirectory())
5066
5436
  candidates.push({ dir, agent: agentLabel(host), scope });
5067
5437
  };
5068
5438
  for (const host of listSubdirs(base)) add(base, host);
@@ -5089,7 +5459,7 @@ function scopeForDir(dir, home, cwd) {
5089
5459
  }
5090
5460
  function locateInstall(skillNames, opts = {}) {
5091
5461
  if (opts.dir) {
5092
- return existsSync14(opts.dir) ? {
5462
+ return existsSync15(opts.dir) ? {
5093
5463
  dir: opts.dir,
5094
5464
  agent: agentFromDir(opts.dir),
5095
5465
  scope: scopeForDir(opts.dir, opts.home ?? homedir2(), opts.cwd ?? process.cwd())
@@ -5100,7 +5470,7 @@ function locateInstall(skillNames, opts = {}) {
5100
5470
  ...discoverSkillRoots(opts.cwd ?? process.cwd(), "project")
5101
5471
  ];
5102
5472
  for (const root of roots) {
5103
- if (skillNames.some((n) => existsSync14(join(root.dir, n, "SKILL.md")))) return root;
5473
+ if (skillNames.some((n) => existsSync15(join(root.dir, n, "SKILL.md")))) return root;
5104
5474
  }
5105
5475
  return null;
5106
5476
  }
@@ -5108,7 +5478,7 @@ function hashInstalled(root, skillNames) {
5108
5478
  const out = {};
5109
5479
  for (const name of skillNames) {
5110
5480
  const skillDir = join(root.dir, name);
5111
- if (existsSync14(join(skillDir, "SKILL.md"))) out[name] = hashSkillBundle(skillDir);
5481
+ if (existsSync15(join(skillDir, "SKILL.md"))) out[name] = hashSkillBundle(skillDir);
5112
5482
  }
5113
5483
  return out;
5114
5484
  }
@@ -5145,7 +5515,7 @@ function findRepoManifest(cwd = process.cwd()) {
5145
5515
  let dir = cwd;
5146
5516
  for (let i = 0; i < 16; i++) {
5147
5517
  const p = join(dir, MANIFEST_FILE);
5148
- if (existsSync14(p)) return p;
5518
+ if (existsSync15(p)) return p;
5149
5519
  const parent = join(dir, "..");
5150
5520
  if (parent === dir) break;
5151
5521
  dir = parent;
@@ -5185,9 +5555,9 @@ async function remoteHeadSha(repoSlug) {
5185
5555
  }
5186
5556
  function resolveLocalManifest(source) {
5187
5557
  const direct = source.endsWith(".json") ? source : join(source, MANIFEST_FILE);
5188
- if (existsSync14(direct)) return JSON.parse(readFileSync13(direct, "utf8"));
5558
+ if (existsSync15(direct)) return JSON.parse(readFileSync15(direct, "utf8"));
5189
5559
  const skillsRoot = source.endsWith("skills") ? source : join(source, "skills");
5190
- if (existsSync14(skillsRoot)) return buildManifest2(skillsRoot, { source: skillsRoot });
5560
+ if (existsSync15(skillsRoot)) return buildManifest2(skillsRoot, { source: skillsRoot });
5191
5561
  throw new Error(`No skills manifest found at: ${source}`);
5192
5562
  }
5193
5563
  async function fetchRemoteManifest(source) {
@@ -5210,7 +5580,7 @@ async function resolveLatestManifest(source, cwd = process.cwd(), opts = {}) {
5210
5580
  }
5211
5581
  if (!source && !opts.canonical) {
5212
5582
  const repoManifest = findRepoManifest(cwd);
5213
- if (repoManifest) return JSON.parse(readFileSync13(repoManifest, "utf8"));
5583
+ if (repoManifest) return JSON.parse(readFileSync15(repoManifest, "utf8"));
5214
5584
  }
5215
5585
  return fetchRemoteManifest(source);
5216
5586
  }
@@ -5477,7 +5847,7 @@ import { defineCommand as defineCommand11, runMain } from "citty";
5477
5847
  // package.json
5478
5848
  var package_default = {
5479
5849
  name: "frogoe",
5480
- version: "0.6.0",
5850
+ version: "0.7.0",
5481
5851
  description: "froge CLI \u2014 the agent's hands: init, add, run, check, bundle",
5482
5852
  homepage: "https://github.com/frogoe/engine#readme",
5483
5853
  bugs: "https://github.com/frogoe/engine/issues",