gipity 1.0.424 → 1.0.425

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/index.js CHANGED
@@ -6862,11 +6862,11 @@ async function getAccountSlug() {
6862
6862
  accountSlugCache = res.data.accountSlug;
6863
6863
  return accountSlugCache;
6864
6864
  }
6865
- async function publicPost(path4, body) {
6865
+ async function publicPost(path4, body, extraHeaders) {
6866
6866
  const url = `${baseUrl()}${path4}`;
6867
6867
  const res = await fetch(url, {
6868
6868
  method: "POST",
6869
- headers: { ...clientHeaders(), "Content-Type": "application/json" },
6869
+ headers: { ...clientHeaders(), "Content-Type": "application/json", ...extraHeaders },
6870
6870
  body: JSON.stringify(body)
6871
6871
  });
6872
6872
  if (!res.ok) {
@@ -7380,6 +7380,7 @@ gipity bug report --category <cli|deploy|template|kit|db|docs|skill|service|sand
7380
7380
  - **Never include PII or user data** (emails, names, secrets, tokens, prompt/file contents) \u2014 describe the platform problem in the abstract.
7381
7381
  - File it for *platform* problems, not your own mistakes or the app's own bugs. One report per distinct problem.
7382
7382
  - Reports go to a review queue for the team to triage into fixes; see what you've filed with \`gipity bug list\`.
7383
+ - Filed one by mistake? Withdraw it yourself: \`gipity bug retract <report-id> [--reason "<why>"]\` \u2014 works while it's still queued (status new/triaged). Never file a second report asking a human to close the first.
7383
7384
 
7384
7385
  ## Tool output is complete and synchronous
7385
7386
 
@@ -8585,7 +8586,8 @@ function readGipityIgnore(root) {
8585
8586
  }
8586
8587
  function effectiveIgnore(root, configIgnore) {
8587
8588
  const base = configIgnore && configIgnore.length ? configIgnore : DEFAULT_SYNC_IGNORE;
8588
- return [...base, GIPITY_IGNORE_FILE, ...readGipityIgnore(root)];
8589
+ const merged = [...base, GIPITY_IGNORE_FILE, ...readGipityIgnore(root)];
8590
+ return [...merged, ...SCRATCH_IGNORE.filter((p) => !merged.includes(p))];
8589
8591
  }
8590
8592
  function isLocalTreeClean() {
8591
8593
  try {
@@ -10335,6 +10337,7 @@ init_auth();
10335
10337
  import { readFileSync as readFileSync11 } from "node:fs";
10336
10338
  init_api();
10337
10339
  init_colors();
10340
+ init_auth();
10338
10341
  init_config();
10339
10342
 
10340
10343
  // src/page-fixtures.ts
@@ -10391,6 +10394,17 @@ function normalizeEvalResult(raw) {
10391
10394
  }
10392
10395
  return { result: raw, noValue: false };
10393
10396
  }
10397
+ var EXPR_ECHO_MAX_CHARS = 120;
10398
+ function summarizeExpr(expr) {
10399
+ const lines = expr.split("\n");
10400
+ const meaningful = lines.filter((l) => l.trim() !== "");
10401
+ const oneLine = meaningful.length <= 1;
10402
+ if (oneLine && expr.trim().length <= EXPR_ECHO_MAX_CHARS) return expr.trim();
10403
+ const first = (meaningful[0] ?? "").trim();
10404
+ const head = first.length > EXPR_ECHO_MAX_CHARS ? `${first.slice(0, EXPR_ECHO_MAX_CHARS - 1)}\u2026` : first;
10405
+ const shape = oneLine ? `(${expr.trim().length} chars)` : `(+${meaningful.length - 1} more ${meaningful.length - 1 === 1 ? "line" : "lines"}, ${expr.trim().length} chars)`;
10406
+ return `${head} ${shape}`;
10407
+ }
10394
10408
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10395
10409
  var MAX_WAIT_MS = 3e4;
10396
10410
  function capWaitMs(rawWait, url) {
@@ -10435,12 +10449,15 @@ function evalExecTimeoutMessage(result) {
10435
10449
  return `the expression hit the ~${EVAL_EXEC_BUDGET_MS / 1e3}s in-page execution budget \u2014 the eval body (including its own await/setTimeout pauses) ran longer than that. This budget is the time the expression itself is allowed to run; it is separate from --wait, which only sleeps BEFORE the eval and cannot extend it. Split a long interactive check into several shorter 'page eval' calls (e.g. one per state to verify), keeping each body's in-page waits well under ${EVAL_EXEC_BUDGET_MS / 1e3}s.`;
10436
10450
  }
10437
10451
  var JS_DECOY_FLAGS = ["--js", "--javascript", "--script", "--code", "--expr", "--eval", "--exec"];
10438
- var pageEvalCommand = new Command("eval").description("Evaluate JS in a real browser on a page (DOM, computed styles, element rects; inline expr or --file script)").argument("<url>", "URL to load").argument("[expr]", "JavaScript to evaluate in page context (inline expression or statement body with return/await; result is JSON-serialized). Omit when using --file. Time budget: the body has ~20s to finish after page load - keep driver scripts within it.").option("--file <path>", "Read the script body from a file instead of the inline <expr> arg (mutually exclusive). Runs as an async function body, so top-level return/await work. Same ~20s post-load budget as <expr>.").option(
10452
+ var pageEvalCommand = new Command("eval").description("Evaluate JS in a real browser on a page (DOM, computed styles, element rects; inline expr or --file script). ONE client per call - to verify realtime/presence across concurrent clients use `page test --observe` instead").argument("<url>", "URL to load").argument("[expr]", "JavaScript to evaluate in page context (inline expression or statement body with return/await; result is JSON-serialized). Omit when using --file. Time budget: the body has ~20s to finish after page load - keep driver scripts within it.").option("--file <path>", "Read the script body from a file instead of the inline <expr> arg (mutually exclusive). Runs as an async function body, so top-level return/await work. Same ~20s post-load budget as <expr>.").option(
10439
10453
  "--fixture <path>",
10440
10454
  "Host a local file and expose it to the eval as `fixtureUrl` (and under `fixtures` by basename) to fetch in-page. For verifying a render/parse path against a real binary (an MP3, an image) - no size limit, auto-deleted after the run. Repeat for several files (single-value so it never swallows the inline <expr>).",
10441
10455
  (val, prev) => [...prev, val],
10442
10456
  []
10443
- ).option("--wait <ms>", "Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000)", "500").option("--wait-for <selector>", "Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)").option("--wait-timeout <ms>", "Max ms to wait for --wait-for before giving up", "5000").option("--auth", "Evaluate signed in as you (your Gipity account), so a page behind a Sign-in-with-Gipity login is reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.").option("--json", "Output as JSON").action((url, exprArg, opts) => run("Page eval", async () => {
10457
+ ).option(
10458
+ "--reload <expr>",
10459
+ "After the first eval, reload the page IN PLACE (localStorage/sessionStorage/cookies preserved) and evaluate this second expression against the post-reload DOM. One command verifies persisted state survives a reload: seed/assert state with <expr>, then assert the restored UI here."
10460
+ ).option("--reload-file <path>", "Read the post-reload expression from a file instead of inline --reload (mutually exclusive)").option("--wait <ms>", "Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000)", "500").option("--wait-for <selector>", "Wait until this CSS selector appears before evaluating (deterministic; replaces --wait)").option("--wait-timeout <ms>", "Max ms to wait for --wait-for before giving up", "5000").option("--auth", "Evaluate signed in as you (your Gipity account), so a page behind a Sign-in-with-Gipity login is reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.").option("--json", "Output as JSON").action((url, exprArg, opts) => run("Page eval", async () => {
10444
10461
  const decoy = JS_DECOY_FLAGS.find((f) => opts[f.slice(2)] !== void 0);
10445
10462
  if (decoy) {
10446
10463
  pageEvalCommand.error(
@@ -10468,6 +10485,17 @@ var pageEvalCommand = new Command("eval").description("Evaluate JS in a real bro
10468
10485
  pageEvalCommand.error(`error: Cannot read file: ${opts.file}`);
10469
10486
  }
10470
10487
  }
10488
+ if (opts.reload !== void 0 && opts.reloadFile) {
10489
+ pageEvalCommand.error("error: Pass either --reload <expr> or --reload-file <path>, not both");
10490
+ }
10491
+ let reloadExpr = opts.reload;
10492
+ if (opts.reloadFile) {
10493
+ try {
10494
+ reloadExpr = readFileSync11(opts.reloadFile, "utf8");
10495
+ } catch {
10496
+ pageEvalCommand.error(`error: Cannot read file: ${opts.reloadFile}`);
10497
+ }
10498
+ }
10471
10499
  const waitMs = capWaitMs(opts.wait, url);
10472
10500
  const parsedTimeout = parseInt(opts.waitTimeout, 10);
10473
10501
  const waitForTimeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 0 ? parsedTimeout : 5e3;
@@ -10494,29 +10522,47 @@ return (${expr});`;
10494
10522
  url,
10495
10523
  expr: sentExpr,
10496
10524
  waitMs,
10525
+ reloadExpr,
10497
10526
  waitForSelector: opts.waitFor || void 0,
10498
10527
  waitForTimeoutMs: opts.waitFor ? waitForTimeoutMs : void 0,
10499
10528
  auth: opts.auth || void 0
10500
10529
  });
10501
- const d = await pollEvalResult(kickoff.data.evalJobId, waitMs);
10530
+ const d = await pollEvalResult(kickoff.data.evalJobId, reloadExpr !== void 0 ? waitMs * 2 : waitMs);
10502
10531
  const { result, noValue } = normalizeEvalResult(d.result);
10532
+ const reload = d.reloadResult !== void 0 ? normalizeEvalResult(d.reloadResult) : void 0;
10503
10533
  const execTimeout = evalExecTimeoutMessage(d.result);
10504
10534
  if (execTimeout) throw new Error(execTimeout);
10505
10535
  if (opts.json) {
10506
- console.log(JSON.stringify(noValue ? { ...d, result, hint: EVAL_NO_VALUE_HINT } : { ...d, result }));
10536
+ console.log(JSON.stringify({
10537
+ ...d,
10538
+ result,
10539
+ ...reload ? { reloadResult: reload.result } : {},
10540
+ ...noValue ? { hint: EVAL_NO_VALUE_HINT } : {}
10541
+ }));
10507
10542
  return;
10508
10543
  }
10509
10544
  console.log(`${brand("Eval")} ${bold(d.url || url)}`);
10510
10545
  if (d.navigationIncomplete) {
10511
10546
  console.log(`${warning("\u26A0 Navigation incomplete:")} ${d.note || "page did not reach full load"}`);
10512
10547
  }
10548
+ if (d.auth?.requested) {
10549
+ const who = getAuth()?.email;
10550
+ console.log(d.auth.established ? `${muted("Auth:")} ${success("session established")}${who ? muted(` as ${who}`) : ""} ${muted("(what the page renders with it is app-defined)")}` : `${warning("Auth: session NOT established")}${d.auth.detail ? ` \u2014 ${d.auth.detail}` : ""} ${muted("(this is the anonymous view)")}`);
10551
+ }
10513
10552
  if (hosted.length) console.log(`${muted("Fixtures:")} ${hosted.map((h) => h.name).join(", ")}`);
10514
- console.log(opts.file ? `${muted("Script:")} ${opts.file}` : `${muted("Expression:")} ${expr}`);
10553
+ console.log(opts.file ? `${muted("Script:")} ${opts.file}` : `${muted("Expression:")} ${summarizeExpr(expr)}`);
10515
10554
  console.log(`
10516
10555
  ${result.trim() ? result : muted("(empty result)")}`);
10517
10556
  if (noValue) console.log(muted(`
10518
10557
  ${EVAL_NO_VALUE_HINT}`));
10519
10558
  if (d.truncated) console.log(muted("\n(result truncated to fit context - narrow the expression for the full value)"));
10559
+ if (reload) {
10560
+ console.log(`
10561
+ ${bold("After reload")} ${muted("(page reloaded in place \u2014 storage preserved)")}`);
10562
+ console.log(reload.result.trim() ? reload.result : muted("(empty result)"));
10563
+ if (reload.noValue) console.log(muted(EVAL_NO_VALUE_HINT));
10564
+ if (d.reloadTruncated) console.log(muted("(reload result truncated to fit context - narrow the expression for the full value)"));
10565
+ }
10520
10566
  } finally {
10521
10567
  for (const h of hosted) {
10522
10568
  try {
@@ -10538,6 +10584,16 @@ Examples:
10538
10584
  # fetch-able 'fixtureUrl', runs the eval, then deletes the hosted copy:
10539
10585
  gipity page eval "https://dev.gipity.ai/me/app/" --fixture ./sample.mp3 \\
10540
10586
  "(async()=>{ const b = await fetch(fixtureUrl).then(r=>r.arrayBuffer()); return window.App.parseId3(b); })()"
10587
+ # Verify persisted state survives a reload (localStorage/sessionStorage kept):
10588
+ # run <expr>, reload the page in place, then run the --reload expression:
10589
+ gipity page eval "https://dev.gipity.ai/me/app/" \\
10590
+ "localStorage.setItem('todo','milk'); document.title" \\
10591
+ --reload "({ restored: localStorage.getItem('todo'), heading: document.querySelector('h1')?.textContent })"
10592
+
10593
+ Module resolution: dynamic import() specifiers starting with ./ or ../ resolve
10594
+ against the PAGE URL, so import('./packages/i18n/index.js') loads the app's own
10595
+ module without hand-building the deployed /account/project/ path. Absolute paths
10596
+ and full URLs pass through unchanged.
10541
10597
 
10542
10598
  The eval body runs under a ~20s in-page execution budget (its own await/setTimeout
10543
10599
  pauses count; --wait only sleeps BEFORE the eval and does not extend it). For a long
@@ -10553,6 +10609,7 @@ Testing realtime/shared state across clients?
10553
10609
  // src/commands/page-screenshot.ts
10554
10610
  init_api();
10555
10611
  init_config();
10612
+ init_auth();
10556
10613
  init_colors();
10557
10614
  init_utils();
10558
10615
  import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync7 } from "fs";
@@ -10572,6 +10629,15 @@ function label(text) {
10572
10629
  function fmtMs(ms) {
10573
10630
  return ms >= 1e3 ? `${(ms / 1e3).toFixed(1)}s` : `${ms}ms`;
10574
10631
  }
10632
+ function printAuthLine(auth) {
10633
+ if (!auth?.requested) return;
10634
+ const who = getAuth()?.email;
10635
+ console.log(auth.established ? `${label("Auth")} ${success("session established")}${who ? muted(` as ${who}`) : ""} ${muted("(what the page renders with it is app-defined)")}` : `${warning("Auth: session NOT established")}${auth.detail ? ` \u2014 ${auth.detail}` : ""} ${muted("(this is the anonymous view)")}`);
10636
+ }
10637
+ function printActionErrorLine(actionError) {
10638
+ if (!actionError) return;
10639
+ console.log(`${warning("\u26A0 --action failed:")} ${actionError} ${muted("(this image shows the page BEFORE the action ran)")}`);
10640
+ }
10575
10641
  function fmtPerformance(p) {
10576
10642
  const parts = [
10577
10643
  `TTFB ${fmtMs(p.ttfb)}`,
@@ -10653,7 +10719,7 @@ function splitCsv(values) {
10653
10719
  function appendOption(value, previous = []) {
10654
10720
  return [...previous, value];
10655
10721
  }
10656
- var pageScreenshotCommand = new Command("screenshot").description("Screenshot a web page").argument("<url>", "URL to screenshot").option("--post-load-delay <ms>", "Delay after DOMContentLoaded before capture, in ms (default: 1000)").option("--action <js>", `Run JS in the page before capturing \u2014 e.g. click a button to enter a state ("document.getElementById('play').click()"). Runs after the post-load delay, then settles again before the shot.`).option("--full", "Capture the full scrollable page (default: viewport only). Scrolls the page through first so scroll-reveal/fade-in-on-scroll (IntersectionObserver) sections render into the shot instead of capturing blank.").option("-o, --output <file>", "Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)").option("--device <names>", `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(", ")} (comma-separated or repeat flag). mobile/tablet emulate a real touch device \u2014 touch events, mobile user-agent, DPR \u2014 so touch-gated mobile UI actually renders.`, appendOption, []).option("--viewport <dims>", "Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)", appendOption, []).option("--no-reload-between", "Skip reload between viewports (faster, lower fidelity - only safe for static pages)").option("--fake-media", "Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly (audio is a built-in tone, not real speech)").option("--auth", "Capture the page signed in as you (your Gipity account), so UI behind a Sign-in-with-Gipity login is shown. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.").option("--json", "Output JSON metadata instead of a friendly summary").addOption(new Option("--wait <ms>", "Alias for --post-load-delay").hideHelp()).addOption(new Option("--full-page", "Alias for --full").hideHelp()).action((url, opts) => run("Page screenshot", async () => {
10722
+ var pageScreenshotCommand = new Command("screenshot").description("Screenshot a web page").argument("<url>", "URL to screenshot").option("--post-load-delay <ms>", "Delay after DOMContentLoaded before capture, in ms (default: 1000)").option("--action <js>", `Run JS in the page before capturing \u2014 e.g. click a button to enter a state ("document.getElementById('play').click()"). Runs as an async function body, so const/await and app-relative import('./\u2026') work. Runs after the post-load delay, then settles again before the shot. If it throws, the capture still happens and the failure is reported.`).option("--full", "Capture the full scrollable page (default: viewport only). Scrolls the page through first so scroll-reveal/fade-in-on-scroll (IntersectionObserver) sections render into the shot instead of capturing blank.").option("-o, --output <file>", "Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)").option("--device <names>", `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(", ")} (comma-separated or repeat flag). mobile/tablet emulate a real touch device \u2014 touch events, mobile user-agent, DPR \u2014 so touch-gated mobile UI actually renders.`, appendOption, []).option("--viewport <dims>", "Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)", appendOption, []).option("--no-reload-between", "Skip reload between viewports (faster, lower fidelity - only safe for static pages)").option("--fake-media", "Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly (audio is a built-in tone, not real speech)").option("--auth", "Capture the page signed in as you (your Gipity account), so UI behind a Sign-in-with-Gipity login is shown. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.").option("--json", "Output JSON metadata instead of a friendly summary").addOption(new Option("--wait <ms>", "Alias for --post-load-delay").hideHelp()).addOption(new Option("--full-page", "Alias for --full").hideHelp()).action((url, opts) => run("Page screenshot", async () => {
10657
10723
  const delayRaw = opts.postLoadDelay ?? opts.wait ?? "1000";
10658
10724
  const postLoadDelayMs = delayRaw !== void 0 ? parseInt(String(delayRaw), 10) : void 0;
10659
10725
  if (postLoadDelayMs !== void 0 && (!Number.isFinite(postLoadDelayMs) || postLoadDelayMs < 0)) {
@@ -10707,7 +10773,8 @@ var pageScreenshotCommand = new Command("screenshot").description("Screenshot a
10707
10773
  title: meta.title,
10708
10774
  final_url: meta.finalUrl,
10709
10775
  status: meta.status,
10710
- performance: meta.performance
10776
+ performance: meta.performance,
10777
+ ...meta.auth ? { auth: meta.auth } : {}
10711
10778
  },
10712
10779
  screenshots: meta.screenshots.map((s, i) => ({
10713
10780
  file: savedFiles[i],
@@ -10734,6 +10801,8 @@ var pageScreenshotCommand = new Command("screenshot").description("Screenshot a
10734
10801
  if (meta.finalUrl) console.log(`${label("Web page URL")} ${meta.finalUrl}`);
10735
10802
  if (meta.status != null) console.log(`${label("Web page status")} ${meta.status}`);
10736
10803
  if (meta.performance) console.log(`${label("Web page perf")} ${fmtPerformance(meta.performance)}`);
10804
+ printAuthLine(meta.auth);
10805
+ printActionErrorLine(meta.actionError);
10737
10806
  if (s.viewport.device) console.log(`${label("Emulated device")} ${s.viewport.device} ${muted("(touch events, mobile user-agent)")}`);
10738
10807
  const sizePart = formatSize(s.screenshotSizeBytes) + (meta.full ? " (full page)" : "");
10739
10808
  console.log(`${label("Screenshot size")} ${sizePart}`);
@@ -10746,6 +10815,8 @@ var pageScreenshotCommand = new Command("screenshot").description("Screenshot a
10746
10815
  if (meta.finalUrl) console.log(`${label("Web page URL")} ${meta.finalUrl}`);
10747
10816
  if (meta.status != null) console.log(`${label("Web page status")} ${meta.status}`);
10748
10817
  if (meta.performance) console.log(`${label("Web page perf")} ${fmtPerformance(meta.performance)}`);
10818
+ printAuthLine(meta.auth);
10819
+ printActionErrorLine(meta.actionError);
10749
10820
  for (let i = 0; i < meta.screenshots.length; i++) {
10750
10821
  const s = meta.screenshots[i];
10751
10822
  const dims = `${s.viewport.width}\xD7${s.viewport.height}${s.viewport.deviceScaleFactor > 1 ? ` @${s.viewport.deviceScaleFactor}x` : ""}`;
@@ -10794,7 +10865,7 @@ function shortUrl(url, truncate = true, maxLen = 100) {
10794
10865
  const tailLen = Math.floor(keep / 2);
10795
10866
  return result.slice(0, headLen) + "\u2026" + result.slice(-tailLen);
10796
10867
  }
10797
- var pageInspectCommand = new Command("inspect").description("Inspect a web page (console, failed resources, timing, layout overflow)").argument("<url>", "URL to inspect").option("--wait <ms>", "Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle; max 30000)", "500").option("--wait-for <selector>", "Wait until this CSS selector appears before capturing (deterministic; replaces --wait)").option("--wait-timeout <ms>", "Max ms to wait for --wait-for before giving up", "5000").option("--json", "Output as JSON").option("--no-reprobe", "Skip the automatic second probe that filters one-time transient console errors - roughly halves inspect time on a page with any console error, at the cost of possibly reporting a cold-load transient as real").option("--no-truncate", "Show full URLs instead of truncating long ones with middle-ellipsis").option("--all", "Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail").option("--fake-media", "Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps run headlessly (audio is a built-in tone, not real speech)").option("--device <name>", `Inspect as a real touch device: ${TOUCH_DEVICES.join(", ")}. Emulates touch events, mobile user-agent and DPR, so a touch-gated mobile layout is what gets inspected.`).option("--auth", "Load the page signed in as you (your Gipity account), so pages behind a Sign-in-with-Gipity login are reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.").addOption(new Option("--screenshot [path]", "Capture a screenshot").hideHelp()).action((url, opts) => {
10868
+ var pageInspectCommand = new Command("inspect").description("Inspect a web page (console, failed resources, timing, layout overflow). ONE passive load - to verify realtime/presence across concurrent clients use `page test --observe`").argument("<url>", "URL to inspect").option("--wait <ms>", "Sleep this many ms after DOMContentLoaded before capturing (lets late async/LCP work settle; max 30000)", "500").option("--wait-for <selector>", "Wait until this CSS selector appears before capturing (deterministic; replaces --wait)").option("--wait-timeout <ms>", "Max ms to wait for --wait-for before giving up", "5000").option("--json", "Output as JSON").option("--no-reprobe", "Skip the automatic second probe that filters one-time transient console errors - roughly halves inspect time on a page with any console error, at the cost of possibly reporting a cold-load transient as real").option("--no-truncate", "Show full URLs instead of truncating long ones with middle-ellipsis").option("--all", "Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail").option("--fake-media", "Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps run headlessly (audio is a built-in tone, not real speech)").option("--device <name>", `Inspect as a real touch device: ${TOUCH_DEVICES.join(", ")}. Emulates touch events, mobile user-agent and DPR, so a touch-gated mobile layout is what gets inspected.`).option("--auth", "Load the page signed in as you (your Gipity account), so pages behind a Sign-in-with-Gipity login are reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.").addOption(new Option("--screenshot [path]", "Capture a screenshot").hideHelp()).action((url, opts) => {
10798
10869
  if (opts.screenshot !== void 0) {
10799
10870
  console.error(error("page inspect does not capture screenshots. Use page screenshot:"));
10800
10871
  console.error(` gipity page screenshot ${url}${typeof opts.screenshot === "string" ? ` -o ${opts.screenshot}` : ""}`);
@@ -11345,6 +11416,11 @@ sandboxCommand.command("run [args...]").description("Run code").option("--langua
11345
11416
  "--input <path>",
11346
11417
  "Narrow to specific project files instead of auto-mirroring the whole tree (repeatable). Use this only for >1 GB projects or when you want surgical control.",
11347
11418
  (v, prev) => [...prev ?? [], v]
11419
+ ).option(
11420
+ "--no-sync-output <glob>",
11421
+ 'Do not persist run outputs matching this glob back to the project (repeatable). For byproducts you want to inspect once but never keep, e.g. --no-sync-output "docs/preview*". Supports *, **, and dir/ prefixes.',
11422
+ (v, prev) => [...prev, v],
11423
+ []
11348
11424
  ).option("--json", "Output as JSON").addHelpText("after", `
11349
11425
  By default the whole project is auto-mirrored into /work/ (up to 1 GB) -
11350
11426
  so your code can reference project files by their relative path, and any
@@ -11416,6 +11492,11 @@ GCC/Rust).
11416
11492
  }
11417
11493
  }
11418
11494
  const language = resolveLanguage({ langFromInterp, langOpt: opts.language, filePath, inlineCode });
11495
+ const noSyncOutput = opts.syncOutput ?? [];
11496
+ if (noSyncOutput.some((g) => !g.trim())) {
11497
+ console.error(error('--no-sync-output requires a non-empty glob (e.g. --no-sync-output "docs/preview*")'));
11498
+ process.exit(1);
11499
+ }
11419
11500
  const { config } = await resolveProjectContext();
11420
11501
  const timeout = parseInt(opts.timeout, 10);
11421
11502
  const cwd = resolveRelativeCwd();
@@ -11436,7 +11517,11 @@ GCC/Rust).
11436
11517
  language,
11437
11518
  timeout: isNaN(timeout) ? 30 : timeout,
11438
11519
  input_files: opts.input,
11439
- cwd
11520
+ cwd,
11521
+ // The filter must run SERVER-side (in the output extractor): skipping
11522
+ // only in the CLI would still write the files into project storage and
11523
+ // make every later sync propose deleting them.
11524
+ noSyncOutput: noSyncOutput.length ? noSyncOutput : void 0
11440
11525
  });
11441
11526
  const res = opts.json ? await doRun() : await withSpinner("Running in sandbox\u2026", doRun, { done: null });
11442
11527
  const pulledLocal = !!(res.data.outputFiles?.length && getConfigPath());
@@ -11461,6 +11546,10 @@ GCC/Rust).
11461
11546
  Output files ${pulledLocal ? "synced to this directory" : "saved to project"}:`);
11462
11547
  for (const f of res.data.outputFiles) console.log(`${f}`);
11463
11548
  }
11549
+ if (res.data.skippedOutputFiles && res.data.skippedOutputFiles.length > 0) {
11550
+ console.log(dim("\nNot persisted (--no-sync-output):"));
11551
+ for (const f of res.data.skippedOutputFiles) console.log(dim(`${f}`));
11552
+ }
11464
11553
  if (res.data.exitCode !== 0) {
11465
11554
  process.exit(res.data.exitCode);
11466
11555
  }
@@ -11858,6 +11947,21 @@ function formatRunLine(r) {
11858
11947
  const statusColor = r.status === "completed" ? success : r.status === "failed" ? error : muted;
11859
11948
  return `${muted(r.short_guid)} ${statusColor(r.status)} ${dur} ${runTokens(r)} tokens ${muted(fmtTime(r.started_at))}`;
11860
11949
  }
11950
+ function printStepRuns(steps, emptyNote) {
11951
+ if (steps.length === 0) {
11952
+ console.log(` ${muted(emptyNote)}`);
11953
+ return;
11954
+ }
11955
+ for (const s of steps) {
11956
+ const statusColor = s.status === "completed" ? success : s.status === "failed" ? error : muted;
11957
+ const model = s.model_used ? ` ${muted(`[${s.model_used}]`)}` : "";
11958
+ console.log(` ${s.step_order}. ${statusColor(s.status)} ${s.tokens_used ?? 0} tokens${model}`);
11959
+ if (s.error_message) console.log(` ${error(s.error_message)}`);
11960
+ if (s.output_json !== null && s.output_json !== void 0) {
11961
+ console.log(JSON.stringify(s.output_json, null, 2).split("\n").map((l) => ` ${l}`).join("\n"));
11962
+ }
11963
+ }
11964
+ }
11861
11965
  var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
11862
11966
  var sleep2 = (ms) => new Promise((resolve16) => setTimeout(resolve16, ms));
11863
11967
  async function waitForRun(wfGuid, prevGuid, timeoutSec) {
@@ -11942,6 +12046,7 @@ workflowCommand.command("run <name>").description("Trigger a workflow (add --wai
11942
12046
  } else {
11943
12047
  console.log(formatRunLine(r));
11944
12048
  if (r.error_message) console.log(` ${error(r.error_message)}`);
12049
+ printStepRuns(r.step_runs ?? [], "(no steps recorded)");
11945
12050
  }
11946
12051
  if (r.status !== "completed") process.exit(1);
11947
12052
  }));
@@ -11956,21 +12061,7 @@ workflowCommand.command("runs <name> [runGuid]").description("List recent runs,
11956
12061
  return;
11957
12062
  }
11958
12063
  console.log(formatRunLine(r));
11959
- const steps = r.step_runs ?? [];
11960
- if (steps.length === 0) {
11961
- console.log(" (no steps recorded)");
11962
- return;
11963
- }
11964
- for (const s of steps) {
11965
- const statusColor = s.status === "completed" ? success : s.status === "failed" ? error : muted;
11966
- const model = s.model_used ? ` ${muted(`[${s.model_used}]`)}` : "";
11967
- console.log(` ${s.step_order}. ${statusColor(s.status)} ${s.tokens_used ?? 0} tokens${model}`);
11968
- if (s.error_message) console.log(` ${error(s.error_message)}`);
11969
- if (s.output_json !== null && s.output_json !== void 0) {
11970
- const pretty = JSON.stringify(s.output_json, null, 2).split("\n").map((l) => ` ${l}`).join("\n");
11971
- console.log(pretty);
11972
- }
11973
- }
12064
+ printStepRuns(r.step_runs ?? [], "(no steps recorded)");
11974
12065
  return;
11975
12066
  }
11976
12067
  const res = await get(`/workflows/${wf.short_guid}/runs`);
@@ -14231,6 +14322,9 @@ var addCommand = new Command("add").description("Add a template or kit").argumen
14231
14322
  force: opts.force
14232
14323
  };
14233
14324
  }
14325
+ if (!opts.json && !process.stdout.isTTY) {
14326
+ console.error(muted("Installing (server writes files + generates favicons; first add for a title can take ~10s)..."));
14327
+ }
14234
14328
  const doAdd = () => post(`/projects/${config.projectGuid}/add`, body);
14235
14329
  const res = opts.json ? await doAdd() : await withSpinner("Installing\u2026", doAdd, { done: null });
14236
14330
  const syncResult = await sync({ interactive: false, progress: opts.json ? void 0 : createProgressReporter() });
@@ -15015,7 +15109,7 @@ ${error(`\u2717 ${failed.length} of ${results.length} file(s) missing or wrong-t
15015
15109
  }));
15016
15110
 
15017
15111
  // src/commands/page.ts
15018
- var pageCommand = new Command("page").description("Inspect web pages").addCommand(pageInspectCommand).addCommand(pageEvalCommand).addCommand(pageScreenshotCommand).addCommand(pageTestCommand).addCommand(pageFetchCommand);
15112
+ var pageCommand = new Command("page").description("Inspect, drive, and test web pages in a real browser (page test = N concurrent clients for realtime/presence verification)").addCommand(pageInspectCommand).addCommand(pageEvalCommand).addCommand(pageScreenshotCommand).addCommand(pageTestCommand).addCommand(pageFetchCommand);
15019
15113
  pageCommand.action(() => {
15020
15114
  pageCommand.help();
15021
15115
  });
@@ -15142,14 +15236,25 @@ ${error(`error: ${log3.error_message}`)}`;
15142
15236
  return line;
15143
15237
  });
15144
15238
  }));
15145
- fnCommand.command("call <name> [body]").description("Call a function").option("--data <json>", "JSON request body").option("--field <path>", "Print only this field of the result (dot path, e.g. items.0.short_guid)").option("--json", "Output as JSON").action((name, bodyArg, opts) => run("Call", async () => {
15239
+ async function callAnon(projectGuid, name, body) {
15240
+ let appToken;
15241
+ try {
15242
+ const minted = await publicPost("/api/token", { app: projectGuid });
15243
+ appToken = minted.data.token;
15244
+ } catch {
15245
+ }
15246
+ return publicPost(
15247
+ `/api/${projectGuid}/fn/${encodeURIComponent(name)}`,
15248
+ body,
15249
+ appToken ? { "X-App-Token": appToken } : void 0
15250
+ );
15251
+ }
15252
+ fnCommand.command("call <name> [body]").description("Call a function").option("--data <json>", "JSON request body").option("--anon", "Call as an anonymous visitor (the public path a signed-out user hits) instead of as your signed-in account").option("--field <path>", "Print only this field of the result (dot path, e.g. items.0.short_guid)").option("--json", "Output as JSON").action((name, bodyArg, opts) => run("Call", async () => {
15146
15253
  const config = requireConfig();
15147
15254
  const raw = bodyArg || opts.data || "{}";
15148
15255
  const body = JSON.parse(raw);
15149
- const res = await post(
15150
- `/api/${config.projectGuid}/fn/${encodeURIComponent(name)}`,
15151
- body
15152
- );
15256
+ const path4 = `/api/${config.projectGuid}/fn/${encodeURIComponent(name)}`;
15257
+ const res = opts.anon ? await callAnon(config.projectGuid, name, body) : await post(path4, body);
15153
15258
  if (opts.field) {
15154
15259
  emitField(res.data, opts.field);
15155
15260
  return;
@@ -15319,7 +15424,9 @@ so the team can triage it into a fix.
15319
15424
 
15320
15425
  Categories: ${CATEGORIES.join(", ")}
15321
15426
  Severity: ${SEVERITY_HINT}
15322
- Never include PII or user data \u2014 describe the platform problem in the abstract.`
15427
+ Never include PII or user data \u2014 describe the platform problem in the abstract.
15428
+ Filed one by mistake? Withdraw it yourself with \`gipity bug retract <id>\`
15429
+ (works until a human picks it up) \u2014 don't file a second report asking for a close.`
15323
15430
  );
15324
15431
  bugCommand.command("report").description("File a bug / friction report about the Gipity platform").requiredOption("--category <cat>", `One of: ${CATEGORIES.join(", ")}`).requiredOption("--severity <sev>", SEVERITY_HINT).requiredOption("--summary <text>", "One line, 7 words max. No PII/user data.").option("--detail <text>", "Succinct: what you did, what failed, the workaround. No PII/user data.").option("--project <guid-or-slug>", "Attribute to a specific project instead of cwd / Home").option("--json", "Output raw JSON").action((opts) => run("Bug report", async () => {
15325
15432
  const category = String(opts.category).toLowerCase();
@@ -15340,6 +15447,24 @@ bugCommand.command("report").description("File a bug / friction report about the
15340
15447
  }
15341
15448
  console.log(success(`\u2713 Bug report filed (${res.data.report_guid}) \u2014 queued for triage.`));
15342
15449
  }));
15450
+ bugCommand.command("retract <id>").description("Withdraw a bug report you filed (e.g. it was your own mistake)").option("--reason <text>", "Short note for triage on why you are retracting").option("--project <guid-or-slug>", "Resolve auth against a specific project instead of cwd / Home").option("--json", "Output raw JSON").addHelpText(
15451
+ "after",
15452
+ `
15453
+ Only works on your own reports, and only while they are still queued
15454
+ (status new/triaged). Once a report is filed/fixed/dismissed, a human
15455
+ owns closing it out. Find report ids with \`gipity bug list\`.`
15456
+ ).action((id, opts) => run("Bug report", async () => {
15457
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
15458
+ const res = await post(
15459
+ `/api/${config.projectGuid}/services/bug-report/retract`,
15460
+ { report_guid: id, reason: opts.reason }
15461
+ );
15462
+ if (opts.json) {
15463
+ console.log(JSON.stringify(res.data));
15464
+ return;
15465
+ }
15466
+ console.log(success(`\u2713 Bug report ${res.data.report_guid} retracted \u2014 it is out of the triage queue.`));
15467
+ }));
15343
15468
  bugCommand.command("list").description("List the bug / friction reports you have filed").option("--project <guid-or-slug>", "Resolve auth against a specific project instead of cwd / Home").option("--json", "Output raw JSON").action((opts) => run("Bug report", async () => {
15344
15469
  const { config } = await resolveProjectContext({ projectOverride: opts.project });
15345
15470
  const res = await get(
@@ -15597,7 +15722,7 @@ jobCommand.command("cancel <runGuid>").description("Cancel a queued or running j
15597
15722
  }));
15598
15723
  jobCommand.command("run-local <name>").description("Run the job in a local Docker container against your local filesystem (no platform round-trip)").option("--image <image>", "Docker image to run inside", "easyclaw/sandbox:latest").option("--no-deps", "Skip pip/npm install even if a deps file is present").action(async (name, opts) => {
15599
15724
  const { existsSync: existsSync20, statSync: statSync9 } = await import("node:fs");
15600
- const { resolve: resolve16, join: join21 } = await import("node:path");
15725
+ const { resolve: resolve16, join: join22 } = await import("node:path");
15601
15726
  const { spawnCommand: spawnCommand2, spawnSyncCommand: spawnSyncCommand2 } = await Promise.resolve().then(() => (init_platform(), platform_exports));
15602
15727
  const jobDir = resolve16(process.cwd(), "jobs", name);
15603
15728
  if (!existsSync20(jobDir) || !statSync9(jobDir).isDirectory()) {
@@ -15621,7 +15746,7 @@ jobCommand.command("run-local <name>").description("Run the job in a local Docke
15621
15746
  },
15622
15747
  { file: "main.sh", runtime: "bash", entry: "bash" }
15623
15748
  ];
15624
- const picked = variants.find((v) => existsSync20(join21(jobDir, v.file)));
15749
+ const picked = variants.find((v) => existsSync20(join22(jobDir, v.file)));
15625
15750
  if (!picked) {
15626
15751
  console.error(error(`No main.py / main.js / main.sh found under jobs/${name}/`));
15627
15752
  process.exit(1);
@@ -15646,7 +15771,7 @@ jobCommand.command("run-local <name>").description("Run the job in a local Docke
15646
15771
  "-v",
15647
15772
  `${jobDir}:/work`
15648
15773
  ];
15649
- const needsDeps = opts.deps !== false && picked.depsFile && picked.installCmd && existsSync20(join21(jobDir, picked.depsFile));
15774
+ const needsDeps = opts.deps !== false && picked.depsFile && picked.installCmd && existsSync20(join22(jobDir, picked.depsFile));
15650
15775
  const handlerCmd = `${picked.entry} /work/${picked.file}`;
15651
15776
  let shellCmd;
15652
15777
  if (needsDeps && picked.installCmd) {
@@ -16308,6 +16433,16 @@ function statusIcon2(status) {
16308
16433
  if (status === "skipped") return muted("\u2192");
16309
16434
  return muted("?");
16310
16435
  }
16436
+ function printSkippedCandidates(skipped) {
16437
+ if (skipped.length === 0) return;
16438
+ console.log("");
16439
+ console.log(muted(`Found ${skipped.length} test-looking file${skipped.length === 1 ? "" : "s"} that gipity test does not run:`));
16440
+ for (const s of skipped.slice(0, 10)) {
16441
+ console.log(muted(` ${s.path} (${s.reason})`));
16442
+ }
16443
+ if (skipped.length > 10) console.log(muted(` ... and ${skipped.length - 10} more`));
16444
+ console.log(muted('Run frontend/kit module tests in the sandbox: gipity sandbox run bash "node <file>"'));
16445
+ }
16311
16446
  var LONG_RUN_MS = 6e4;
16312
16447
  async function pollTestStatus(projectGuid, runGuid, opts) {
16313
16448
  const startTime = Date.now();
@@ -16419,9 +16554,17 @@ var testCommand = new Command("test").description("Run tests").argument("[path]"
16419
16554
  process.exit(1);
16420
16555
  }
16421
16556
  if (!filterPath && data.total === 0 && data.results.length === 0) {
16422
- console.log(muted("No tests ran - this app has no tests/*.test.js files."));
16557
+ const root = getProjectRoot();
16558
+ console.log(muted(`No tests ran - no tests/*.test.js files under the project root${root ? ` (${root})` : ""}.`));
16423
16559
  console.log(muted("gipity test runs server-function tests only; a frontend-only app (no functions/) has nothing here to run."));
16424
16560
  console.log(muted("Verify a frontend app by driving the live page: gipity page inspect <url> (or gipity page eval)."));
16561
+ try {
16562
+ const listRes = await get(
16563
+ `/projects/${config.projectGuid}/test/list`
16564
+ );
16565
+ printSkippedCandidates(listRes.data.skipped ?? []);
16566
+ } catch {
16567
+ }
16425
16568
  return;
16426
16569
  }
16427
16570
  if (data.status === "failed" && data.errorMessage) {
@@ -16489,7 +16632,9 @@ testCommand.command("list").description("List the test files that would run \u20
16489
16632
  return;
16490
16633
  }
16491
16634
  if (total === 0) {
16492
- console.log(muted(pathFilter ? `No test files matched filter: ${pathFilter}` : "No test files found. Add *.test.js files under tests/."));
16635
+ const root = getProjectRoot();
16636
+ console.log(muted(pathFilter ? `No test files matched filter: ${pathFilter}` : `No test files found under tests/ at the project root${root ? ` (${root})` : ""}. Add *.test.js files under tests/.`));
16637
+ if (!pathFilter) printSkippedCandidates(res.data.skipped ?? []);
16493
16638
  return;
16494
16639
  }
16495
16640
  console.log(bold(`Test files${pathFilter ? ` (filter: ${pathFilter})` : ""}: ${total}`));
@@ -19694,7 +19839,58 @@ function collectRealFlags(argv2, program3) {
19694
19839
  return flags;
19695
19840
  }
19696
19841
 
19842
+ // src/trace.ts
19843
+ import { openSync as openSync4, writeSync, mkdirSync as mkdirSync12 } from "fs";
19844
+ import { join as join21 } from "path";
19845
+ import { homedir as homedir15 } from "os";
19846
+ var TRACE_KEY = /* @__PURE__ */ Symbol.for("gipity.traceOutput");
19847
+ function installOutputTrace(label2) {
19848
+ if (process.env.GIPITY_TRACE_OUTPUT !== "1") return;
19849
+ try {
19850
+ const g = globalThis;
19851
+ const existing = g[TRACE_KEY];
19852
+ if (existing) {
19853
+ existing.emit({ event: "reenter", label: label2 });
19854
+ return;
19855
+ }
19856
+ const dir = join21(process.env.GIPITY_DIR || join21(homedir15(), ".gipity"), "trace");
19857
+ mkdirSync12(dir, { recursive: true });
19858
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace("T", "_").replace(/:/g, "-");
19859
+ const fd = openSync4(join21(dir, `${stamp}-pid${process.pid}.jsonl`), "a");
19860
+ const emit = (rec) => {
19861
+ try {
19862
+ writeSync(fd, JSON.stringify({ t: (/* @__PURE__ */ new Date()).toISOString(), ...rec }) + "\n");
19863
+ } catch {
19864
+ }
19865
+ };
19866
+ g[TRACE_KEY] = { emit };
19867
+ emit({
19868
+ event: "start",
19869
+ label: label2,
19870
+ pid: process.pid,
19871
+ ppid: process.ppid,
19872
+ argv: process.argv,
19873
+ cwd: process.cwd(),
19874
+ tty: { stdout: !!process.stdout.isTTY, stderr: !!process.stderr.isTTY }
19875
+ });
19876
+ for (const name of ["stdout", "stderr"]) {
19877
+ const stream = process[name];
19878
+ const original = stream.write.bind(stream);
19879
+ stream.write = ((chunk, encoding, cb) => {
19880
+ emit({
19881
+ stream: name,
19882
+ data: typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")
19883
+ });
19884
+ return original(chunk, encoding, cb);
19885
+ });
19886
+ }
19887
+ process.on("exit", (code) => emit({ event: "exit", code }));
19888
+ } catch {
19889
+ }
19890
+ }
19891
+
19697
19892
  // src/index.ts
19893
+ installOutputTrace("index");
19698
19894
  var __dirname = dirname13(fileURLToPath3(import.meta.url));
19699
19895
  var pkg = JSON.parse(readFileSync23(resolve15(__dirname, "../package.json"), "utf-8"));
19700
19896
  function versionLabel() {
package/dist/knowledge.js CHANGED
@@ -136,6 +136,7 @@ gipity bug report --category <cli|deploy|template|kit|db|docs|skill|service|sand
136
136
  - **Never include PII or user data** (emails, names, secrets, tokens, prompt/file contents) — describe the platform problem in the abstract.
137
137
  - File it for *platform* problems, not your own mistakes or the app's own bugs. One report per distinct problem.
138
138
  - Reports go to a review queue for the team to triage into fixes; see what you've filed with \`gipity bug list\`.
139
+ - Filed one by mistake? Withdraw it yourself: \`gipity bug retract <report-id> [--reason "<why>"]\` — works while it's still queued (status new/triaged). Never file a second report asking a human to close the first.
139
140
 
140
141
  ## Tool output is complete and synchronous
141
142
 
package/dist/sync.js CHANGED
@@ -29,7 +29,7 @@ import { get, del, downloadStream, ApiError } from './api.js';
29
29
  import { requireConfig, shouldIgnore, getConfigPath } from './config.js';
30
30
  import { formatSize, prompt, getAutoConfirm } from './utils.js';
31
31
  import { uploadOneFile, hashFile, guessMime, transferToS3, uploadInitBatch, uploadCompleteBatch, UploadConflictError, UPLOAD_CONCURRENCY, UPLOAD_INIT_BATCH_SIZE, UPLOAD_MAX_BYTES, UPLOAD_MAX_PATH_CHARS, } from './upload.js';
32
- import { DEFAULT_SYNC_IGNORE } from './setup.js';
32
+ import { DEFAULT_SYNC_IGNORE, SCRATCH_IGNORE } from './setup.js';
33
33
  const CONFIG_FILE = '.gipity.json';
34
34
  import * as tar from 'tar-stream';
35
35
  // ─── Tunables ──────────────────────────────────────────────────
@@ -659,10 +659,24 @@ export function readGipityIgnore(root) {
659
659
  /** The ignore list a sync/push actually runs with: the project config's
660
660
  * `ignore` (falling back to DEFAULT_SYNC_IGNORE when empty, so an empty list
661
661
  * never means "sync everything - node_modules, .git and all"), plus any
662
- * `.gipityignore` patterns. The ignore file itself never syncs. */
662
+ * `.gipityignore` patterns, plus - unconditionally - the scratch namespaces.
663
+ * The ignore file itself never syncs.
664
+ *
665
+ * SCRATCH_IGNORE is unioned in even when the config has its own `ignore`
666
+ * list, because scratch dirs are a PLATFORM CONVENTION, not a user
667
+ * preference: they must mirror the server's `isEphemeralSandboxPath`
668
+ * denylist (see the comment on SCRATCH_IGNORE in setup.ts) so the same
669
+ * paths are throwaway everywhere - the sandbox refuses to persist them and
670
+ * sync/deploy refuses to upload them. `config.ignore` is frozen into
671
+ * .gipity.json at link time, so any project linked before a scratch dir was
672
+ * added to the defaults would otherwise keep syncing it forever (tmp/ did
673
+ * exactly this). Appended LAST so a stray `!tmp/` negation earlier in the
674
+ * list can't re-include scratch; deduped so the matcher cache in
675
+ * config.ts keys on one canonical pattern set. */
663
676
  export function effectiveIgnore(root, configIgnore) {
664
677
  const base = configIgnore && configIgnore.length ? configIgnore : DEFAULT_SYNC_IGNORE;
665
- return [...base, GIPITY_IGNORE_FILE, ...readGipityIgnore(root)];
678
+ const merged = [...base, GIPITY_IGNORE_FILE, ...readGipityIgnore(root)];
679
+ return [...merged, ...SCRATCH_IGNORE.filter(p => !merged.includes(p))];
666
680
  }
667
681
  /** Fast, LOCAL-ONLY dirtiness probe: true when every local file matches its
668
682
  * baseline entry by size+mtime and nothing was added or deleted since the