@aayambansal/squint 0.2.8 → 0.3.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/README.md CHANGED
@@ -150,6 +150,9 @@ squint doctor --probe # run every engine end to end, verify auth act
150
150
  - **Problems**: findings from gates, the dev server, the runtime probe, and a11y sweeps
151
151
  collect into a list — `/problems` shows it, `/fix` sends everything as one turn,
152
152
  `/fix <n>` targets one. The footer counts what's open.
153
+ - **Sandbox**: `/sandbox on` and asks accumulate in a shadow worktree — the dev server,
154
+ gates, and probes all run there; `/sandbox diff` shows what is staged, `apply` lands it
155
+ as one diff, `discard` walks away with the real tree untouched.
153
156
  - **Variants without leaving**: `/variants 3 <ask>` runs parallel explorations with
154
157
  streaming per-family status; `/variants apply <id>` keeps the winner.
155
158
  - **Commands**: type `/` and matching commands appear with descriptions; tab completes.
@@ -228,6 +231,7 @@ All product behavior lives in the harness, so a new engine is ~80 lines.
228
231
 
229
232
  - [Engine setup guide](./docs/engines.md) — install + auth for all eight, and how to choose
230
233
  - [Configuration](./docs/configuration.md) — every key, every `.squint/` file
234
+ - [The loops](./docs/loops.md) — everything that runs automatically around each turn
231
235
  - [Architecture](./docs/design/2026-07-25-architecture.md)
232
236
  - [How Lovable works under the hood](./docs/research/lovable.md)
233
237
  - [Making agents produce excellent frontend work](./docs/research/frontend-quality.md)
@@ -3,7 +3,7 @@ import {
3
3
  cdpCapture,
4
4
  hasWebSocket,
5
5
  pixelDiffPct
6
- } from "./chunk-OC6RU6XH.js";
6
+ } from "./chunk-QEENJGCL.js";
7
7
  export {
8
8
  cdpCapture,
9
9
  hasWebSocket,
@@ -2,8 +2,8 @@
2
2
  import {
3
3
  findChrome,
4
4
  screenshot
5
- } from "./chunk-B7LOERSP.js";
6
- import "./chunk-BWZFACBT.js";
5
+ } from "./chunk-IMDRXXFU.js";
6
+ import "./chunk-KVYGPLWW.js";
7
7
  export {
8
8
  findChrome,
9
9
  screenshot
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runAgent
4
- } from "./chunk-C7WKNJG6.js";
4
+ } from "./chunk-VH7OOFQP.js";
5
5
  import {
6
6
  FAMILIES,
7
7
  FIRST_TURN_ADDENDUM,
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/session/commands.ts
4
+ var COMMANDS = [
5
+ { name: "dev", args: "[restart|logs]", group: "build", description: "start/stop the dev server; restart or show recent output" },
6
+ { name: "mode", args: "plan|safe|yolo", group: "build", description: "how much the engine may do (shift+tab cycles)" },
7
+ { name: "engine", args: "<id>", group: "build", description: "switch backend (new session)" },
8
+ { name: "engines", group: "build", description: "list installed engines with streaming/resume support" },
9
+ { name: "model", args: "[name]", group: "build", description: "model override for the engine" },
10
+ { name: "queue", args: "clear|drop <n>", group: "build", description: "clear the queue or drop one queued ask" },
11
+ { name: "check", group: "verify", description: "run all quality gates (typecheck, lint, format, test, build)" },
12
+ { name: "problems", group: "verify", description: "list open findings from gates, dev server, runtime, a11y" },
13
+ { name: "fix", args: "[n]", group: "verify", description: "send all open problems to the engine, or just problem n" },
14
+ { name: "shot", args: "[url]", group: "verify", description: "screenshot the app (or any url) at mobile/tablet/desktop" },
15
+ { name: "review", args: "[focus]", group: "verify", description: "screenshots + the engine critiques its own rendered work" },
16
+ { name: "variants", args: "<2-4> <ask>", group: "explore", description: "parallel design explorations; apply/list/clean" },
17
+ { name: "sandbox", args: "[on|diff|apply|discard]", group: "explore", description: "asks accumulate in a shadow worktree until you apply" },
18
+ { name: "undo", group: "explore", description: "revert the last ask (files only)" },
19
+ { name: "checkpoints", group: "explore", description: "list per-ask checkpoints" },
20
+ { name: "restore", args: "<n>", group: "explore", description: "rewind files to before ask n" },
21
+ { name: "theme", args: "[name]", group: "session", description: "switch the TUI theme", viewLevel: true },
22
+ { name: "copy", group: "session", description: "copy the last reply to the clipboard" },
23
+ { name: "save", group: "session", description: "export the transcript to .squint/transcripts/" },
24
+ { name: "resume", group: "session", description: "pick up the previous session for this repo" },
25
+ { name: "clear", group: "session", description: "new session (transcript, totals, persisted state)" },
26
+ { name: "help", group: "session", description: "list commands" },
27
+ { name: "quit", group: "session", description: "exit with a session summary" }
28
+ ];
29
+ function completeCommand(partial) {
30
+ const query = partial.toLowerCase();
31
+ return COMMANDS.filter((c) => c.name.startsWith(query));
32
+ }
33
+ var GROUP_TITLES = {
34
+ build: "build",
35
+ verify: "verify",
36
+ explore: "explore & rewind",
37
+ session: "session"
38
+ };
39
+ function commandHelp() {
40
+ const sections = [];
41
+ for (const group of ["build", "verify", "explore", "session"]) {
42
+ const rows = COMMANDS.filter((c) => c.group === group).map(
43
+ (c) => ` /${c.name}${c.args ? ` ${c.args}` : ""} \u2014 ${c.description}`
44
+ );
45
+ sections.push(`${GROUP_TITLES[group]}
46
+ ${rows.join("\n")}`);
47
+ }
48
+ return sections.join("\n");
49
+ }
50
+
51
+ export {
52
+ COMMANDS,
53
+ completeCommand,
54
+ commandHelp
55
+ };
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  variantsRoot
4
- } from "./chunk-X6MDBKED.js";
4
+ } from "./chunk-A64VVTPU.js";
5
5
  import {
6
6
  findChrome,
7
7
  screenshot
8
- } from "./chunk-B7LOERSP.js";
8
+ } from "./chunk-IMDRXXFU.js";
9
9
  import {
10
10
  lineSplitter
11
- } from "./chunk-BWZFACBT.js";
11
+ } from "./chunk-KVYGPLWW.js";
12
12
 
13
13
  // src/variants/shots.ts
14
14
  import path2 from "path";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  findBinary
4
- } from "./chunk-BWZFACBT.js";
4
+ } from "./chunk-KVYGPLWW.js";
5
5
 
6
6
  // src/preview/chrome.ts
7
7
  import { spawn } from "child_process";
@@ -98,8 +98,18 @@ function createClaudeStreamParser(readyLabel) {
98
98
  durationMs: data.duration_ms
99
99
  }
100
100
  ];
101
- case "user":
102
- return [];
101
+ case "user": {
102
+ if (data.parent_tool_use_id) return [];
103
+ const events = [];
104
+ for (const block of data.message?.content ?? []) {
105
+ if (block.type === "tool_result" && block.is_error === true) {
106
+ const raw = Array.isArray(block.content) ? block.content.map((c) => c?.text ?? "").join(" ") : String(block.content ?? "");
107
+ const text = raw.trim().split("\n").at(-1) ?? "";
108
+ if (text) events.push({ type: "status", text: `\u26A0 tool error \xB7 ${truncate(text, 100)}` });
109
+ }
110
+ }
111
+ return events;
112
+ }
103
113
  case "rate_limit_event":
104
114
  return [];
105
115
  default:
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/state/state.ts
4
+ import fs from "fs";
5
+ import path from "path";
6
+ var IGNORED = ["preview/", "state.json", "variants/", "sandbox/", "sandbox.patch", "transcripts/"];
7
+ function ensureSquintIgnore(cwd) {
8
+ const dir = path.join(cwd, ".squint");
9
+ fs.mkdirSync(dir, { recursive: true });
10
+ const file = path.join(dir, ".gitignore");
11
+ let existing = "";
12
+ try {
13
+ existing = fs.readFileSync(file, "utf8");
14
+ } catch {
15
+ }
16
+ const lines = existing.split("\n").filter((l) => l.trim().length > 0);
17
+ const missing = IGNORED.filter((entry) => !lines.includes(entry));
18
+ if (missing.length > 0) {
19
+ fs.writeFileSync(file, [...lines, ...missing].join("\n") + "\n");
20
+ }
21
+ }
22
+ function stateFile(cwd) {
23
+ return path.join(cwd, ".squint", "state.json");
24
+ }
25
+ function loadState(cwd) {
26
+ try {
27
+ const raw = JSON.parse(fs.readFileSync(stateFile(cwd), "utf8"));
28
+ if (typeof raw?.engine === "string" && typeof raw?.sessionId === "string" && typeof raw?.at === "number") {
29
+ return raw;
30
+ }
31
+ } catch {
32
+ }
33
+ return null;
34
+ }
35
+ function saveState(cwd, state) {
36
+ ensureSquintIgnore(cwd);
37
+ fs.writeFileSync(stateFile(cwd), JSON.stringify(state, null, 2) + "\n");
38
+ }
39
+ function clearState(cwd) {
40
+ try {
41
+ fs.rmSync(stateFile(cwd));
42
+ } catch {
43
+ }
44
+ }
45
+
46
+ export {
47
+ ensureSquintIgnore,
48
+ loadState,
49
+ saveState,
50
+ clearState
51
+ };
@@ -201,11 +201,56 @@ async function pixelDiffPct(chromePath, pngA, pngB) {
201
201
  setTimeout(() => fs.rmSync(profileDir, { recursive: true, force: true }), 500).unref?.();
202
202
  }
203
203
  }
204
+ var SLOP_AUDIT = `(() => {
205
+ const out = [];
206
+ const bodyFont = (getComputedStyle(document.body).fontFamily || '').toLowerCase();
207
+ const h1 = document.querySelector('h1,h2');
208
+ const displayFont = h1 ? (getComputedStyle(h1).fontFamily || '').toLowerCase() : bodyFont;
209
+ for (const tell of ['inter', 'roboto', 'arial', 'space grotesk']) {
210
+ if (displayFont.includes(tell) || bodyFont.split(',')[0].includes(tell)) {
211
+ out.push('generic font stack: ' + tell + ' (' + (displayFont.includes(tell) ? 'display' : 'body') + ')');
212
+ break;
213
+ }
214
+ }
215
+ const vw = innerWidth, vh = innerHeight;
216
+ for (const el of document.querySelectorAll('*')) {
217
+ const r = el.getBoundingClientRect();
218
+ if (r.top > vh || r.width * r.height < vw * vh * 0.2) continue;
219
+ const bg = getComputedStyle(el).backgroundImage || '';
220
+ if (bg.includes('gradient')) {
221
+ const purples = bg.match(/rgb\\((\\d+),\\s*(\\d+),\\s*(\\d+)/g) || [];
222
+ if (purples.some((c) => { const [r2, g2, b2] = c.slice(4).split(',').map(Number); return b2 > 150 && r2 > 80 && r2 < 200 && g2 < r2; })) {
223
+ out.push('purple/violet gradient on a hero-scale element');
224
+ break;
225
+ }
226
+ }
227
+ }
228
+ for (const container of document.querySelectorAll('section, div')) {
229
+ const kids = [...container.children];
230
+ if (kids.length < 3 || kids.length > 4) continue;
231
+ const rects = kids.map((k) => k.getBoundingClientRect());
232
+ if (rects[0].width < 150 || rects[0].top > vh * 2) continue;
233
+ const sameSize = rects.every((r) => Math.abs(r.width - rects[0].width) < 4 && Math.abs(r.height - rects[0].height) < 24);
234
+ const cardish = kids.every((k) => k.querySelector('svg, img') && k.querySelector('h2,h3,h4') && k.querySelector('p'));
235
+ if (sameSize && cardish) { out.push('identical icon-card grid (' + kids.length + ' cards)'); break; }
236
+ }
237
+ let emojiBullets = 0;
238
+ for (const li of document.querySelectorAll('li')) {
239
+ if (/^[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]/u.test((li.textContent || '').trim())) emojiBullets++;
240
+ }
241
+ if (emojiBullets >= 3) out.push(emojiBullets + ' emoji-bulleted list items');
242
+ const rootStyle = getComputedStyle(document.documentElement);
243
+ if (rootStyle.getPropertyValue('--radius').trim() === '0.5rem' && rootStyle.getPropertyValue('--primary').trim() === '222.2 47.4% 11.2%') {
244
+ out.push('untouched shadcn default theme tokens');
245
+ }
246
+ return out.slice(0, 8);
247
+ })()`;
204
248
  async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, audit = false) {
205
249
  const { child, wsUrl, profileDir } = await launchChrome(chromePath);
206
250
  const report = { consoleErrors: [], pageErrors: [], failedRequests: [] };
207
251
  const shots = [];
208
252
  let a11y = [];
253
+ let slop = [];
209
254
  const requests = /* @__PURE__ */ new Map();
210
255
  let connection = null;
211
256
  try {
@@ -260,6 +305,15 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
260
305
  if (Array.isArray(result?.value)) a11y = result.value.map(String);
261
306
  } catch {
262
307
  }
308
+ try {
309
+ const { result } = await connection.send(
310
+ "Runtime.evaluate",
311
+ { expression: SLOP_AUDIT, returnByValue: true },
312
+ sessionId
313
+ );
314
+ if (Array.isArray(result?.value)) slop = result.value.map(String);
315
+ } catch {
316
+ }
263
317
  }
264
318
  for (const viewport of viewports) {
265
319
  await connection.send(
@@ -283,7 +337,7 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
283
337
  child.kill("SIGKILL");
284
338
  setTimeout(() => fs.rmSync(profileDir, { recursive: true, force: true }), 500).unref?.();
285
339
  }
286
- return { report, shots, a11y };
340
+ return { report, shots, a11y, slop };
287
341
  }
288
342
 
289
343
  export {
@@ -2,61 +2,19 @@
2
2
  import {
3
3
  findChrome,
4
4
  screenshot
5
- } from "./chunk-B7LOERSP.js";
5
+ } from "./chunk-IMDRXXFU.js";
6
6
  import {
7
7
  cdpCapture,
8
8
  hasWebSocket
9
- } from "./chunk-OC6RU6XH.js";
9
+ } from "./chunk-QEENJGCL.js";
10
+ import {
11
+ ensureSquintIgnore
12
+ } from "./chunk-O2S6PAJE.js";
10
13
 
11
14
  // src/preview/preview.ts
12
- import fs2 from "fs";
13
- import os from "os";
14
- import path2 from "path";
15
-
16
- // src/state/state.ts
17
15
  import fs from "fs";
16
+ import os from "os";
18
17
  import path from "path";
19
- var IGNORED = ["preview/", "state.json", "variants/", "transcripts/"];
20
- function ensureSquintIgnore(cwd) {
21
- const dir = path.join(cwd, ".squint");
22
- fs.mkdirSync(dir, { recursive: true });
23
- const file = path.join(dir, ".gitignore");
24
- let existing = "";
25
- try {
26
- existing = fs.readFileSync(file, "utf8");
27
- } catch {
28
- }
29
- const lines = existing.split("\n").filter((l) => l.trim().length > 0);
30
- const missing = IGNORED.filter((entry) => !lines.includes(entry));
31
- if (missing.length > 0) {
32
- fs.writeFileSync(file, [...lines, ...missing].join("\n") + "\n");
33
- }
34
- }
35
- function stateFile(cwd) {
36
- return path.join(cwd, ".squint", "state.json");
37
- }
38
- function loadState(cwd) {
39
- try {
40
- const raw = JSON.parse(fs.readFileSync(stateFile(cwd), "utf8"));
41
- if (typeof raw?.engine === "string" && typeof raw?.sessionId === "string" && typeof raw?.at === "number") {
42
- return raw;
43
- }
44
- } catch {
45
- }
46
- return null;
47
- }
48
- function saveState(cwd, state) {
49
- ensureSquintIgnore(cwd);
50
- fs.writeFileSync(stateFile(cwd), JSON.stringify(state, null, 2) + "\n");
51
- }
52
- function clearState(cwd) {
53
- try {
54
- fs.rmSync(stateFile(cwd));
55
- } catch {
56
- }
57
- }
58
-
59
- // src/preview/preview.ts
60
18
  var VIEWPORTS = [
61
19
  { name: "mobile", width: 390, height: 844 },
62
20
  { name: "tablet", width: 768, height: 1024 },
@@ -65,7 +23,7 @@ var VIEWPORTS = [
65
23
  function loadRoutes(cwd) {
66
24
  let lines = [];
67
25
  try {
68
- lines = fs2.readFileSync(path2.join(cwd, ".squint", "routes"), "utf8").split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
26
+ lines = fs.readFileSync(path.join(cwd, ".squint", "routes"), "utf8").split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
69
27
  } catch {
70
28
  }
71
29
  const routes = ["/", ...lines.filter((l) => l !== "/")];
@@ -76,8 +34,8 @@ function routeShotName(route) {
76
34
  return clean.length > 0 ? clean : "root";
77
35
  }
78
36
  function previewDir(cwd) {
79
- const dir = path2.join(cwd, ".squint", "preview");
80
- fs2.mkdirSync(dir, { recursive: true });
37
+ const dir = path.join(cwd, ".squint", "preview");
38
+ fs.mkdirSync(dir, { recursive: true });
81
39
  ensureSquintIgnore(cwd);
82
40
  return dir;
83
41
  }
@@ -89,7 +47,7 @@ async function captureViewports(cwd, url) {
89
47
  const base = url.replace(/\/+$/, "");
90
48
  if (hasWebSocket()) {
91
49
  try {
92
- const { report, shots: shots2, a11y } = await cdpCapture(chrome, url, dir, VIEWPORTS, 2500, true);
50
+ const { report, shots: shots2, a11y, slop } = await cdpCapture(chrome, url, dir, VIEWPORTS, 2500, true);
93
51
  const errors2 = [];
94
52
  for (const route of routes.slice(1)) {
95
53
  try {
@@ -104,14 +62,14 @@ async function captureViewports(cwd, url) {
104
62
  errors2.push(`${route}: capture failed`);
105
63
  }
106
64
  }
107
- return { shots: shots2, errors: errors2, runtime: report, a11y };
65
+ return { shots: shots2, errors: errors2, runtime: report, a11y, slop };
108
66
  } catch {
109
67
  }
110
68
  }
111
69
  const shots = [];
112
70
  const errors = [];
113
71
  for (const viewport of VIEWPORTS) {
114
- const outPath = path2.join(dir, `${viewport.name}.png`);
72
+ const outPath = path.join(dir, `${viewport.name}.png`);
115
73
  const result = await screenshot(chrome, url, outPath, {
116
74
  width: viewport.width,
117
75
  height: viewport.height
@@ -175,7 +133,7 @@ async function probeRuntime(url, cwd) {
175
133
  async function comparePulse(previous, current) {
176
134
  const chrome = findChrome();
177
135
  if (!chrome || !hasWebSocket()) return null;
178
- const { pixelDiffPct } = await import("./cdp-SFWNP7OA.js");
136
+ const { pixelDiffPct } = await import("./cdp-4IZHB3W5.js");
179
137
  return pixelDiffPct(chrome, previous, current);
180
138
  }
181
139
  function buildRuntimeFixPrompt(report) {
@@ -193,19 +151,26 @@ ${findings.join("\n")}
193
151
 
194
152
  Fix these as part of the pass \u2014 they are objective defects, not style preferences.`;
195
153
  }
196
- function buildReviewPrompt(shots, extra, runtime, a11y) {
154
+ function slopSection(findings) {
155
+ if (!findings || findings.length === 0) return "";
156
+ return `
157
+
158
+ ## Distinctiveness debt (detected mechanically)
159
+
160
+ ${findings.join("\n")}
161
+
162
+ These patterns make the page read as template output. Rework them within the committed design direction \u2014 this is style debt, not a defect list.`;
163
+ }
164
+ function buildReviewPrompt(shots, extra, runtime, a11y, slop) {
197
165
  const list = shots.map((s) => `- ${s.name}: ${s.path}`).join("\n");
198
166
  return `Screenshots of the running app were just captured:
199
167
 
200
168
  ${list}
201
169
 
202
- Read each screenshot and review the rendered UI against the design standards you were given. Check: visual hierarchy and spacing rhythm, typography, color and contrast, alignment, empty-looking or broken regions, and whether the mobile capture shows horizontal overflow or cramped layout. List the concrete issues you can SEE (not hypothetical ones), ranked by visual impact${extra ? `, with special attention to: ${extra}` : ""}. Then fix them and verify the app still builds.${runtimeSection(runtime)}${a11ySection(a11y)}`;
170
+ Read each screenshot and review the rendered UI against the design standards you were given. Check: visual hierarchy and spacing rhythm, typography, color and contrast, alignment, empty-looking or broken regions, and whether the mobile capture shows horizontal overflow or cramped layout. List the concrete issues you can SEE (not hypothetical ones), ranked by visual impact${extra ? `, with special attention to: ${extra}` : ""}. Then fix them and verify the app still builds.${runtimeSection(runtime)}${a11ySection(a11y)}${slopSection(slop)}`;
203
171
  }
204
172
 
205
173
  export {
206
- loadState,
207
- saveState,
208
- clearState,
209
174
  VIEWPORTS,
210
175
  loadRoutes,
211
176
  routeShotName,
@@ -3,7 +3,7 @@ import {
3
3
  findEngineBinary,
4
4
  lineSplitter,
5
5
  truncate
6
- } from "./chunk-BWZFACBT.js";
6
+ } from "./chunk-KVYGPLWW.js";
7
7
 
8
8
  // src/runner/run.ts
9
9
  import { spawn } from "child_process";
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/vcs/sandbox.ts
4
+ import { execFileSync } from "child_process";
5
+ import fs from "fs";
6
+ import path from "path";
7
+ function git(cwd, args) {
8
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
9
+ }
10
+ function sandboxDir(cwd) {
11
+ return path.join(cwd, ".squint", "sandbox");
12
+ }
13
+ function sandboxExists(cwd) {
14
+ return fs.existsSync(path.join(sandboxDir(cwd), ".git"));
15
+ }
16
+ function openSandbox(cwd) {
17
+ const dir = sandboxDir(cwd);
18
+ void import("./state-QOS7WCZO.js").then(({ ensureSquintIgnore }) => ensureSquintIgnore(cwd)).catch(() => {
19
+ });
20
+ if (sandboxExists(cwd)) return { dir, reused: true };
21
+ fs.rmSync(dir, { recursive: true, force: true });
22
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
23
+ git(cwd, ["worktree", "add", "--force", "--detach", dir, "HEAD"]);
24
+ const mainModules = path.join(cwd, "node_modules");
25
+ const sandboxModules = path.join(dir, "node_modules");
26
+ if (fs.existsSync(mainModules) && !fs.existsSync(sandboxModules)) {
27
+ fs.symlinkSync(mainModules, sandboxModules, "dir");
28
+ }
29
+ return { dir, reused: false };
30
+ }
31
+ function sandboxDiffStat(cwd) {
32
+ if (!sandboxExists(cwd)) return null;
33
+ const dir = sandboxDir(cwd);
34
+ try {
35
+ git(dir, ["add", "-A"]);
36
+ const stat = git(dir, ["diff", "--cached", "--shortstat", "HEAD"]);
37
+ return stat.length > 0 ? stat : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+ function sandboxFiles(cwd) {
43
+ if (!sandboxExists(cwd)) return [];
44
+ const dir = sandboxDir(cwd);
45
+ try {
46
+ git(dir, ["add", "-A"]);
47
+ const out = git(dir, ["diff", "--cached", "--name-status", "HEAD"]);
48
+ return out.length > 0 ? out.split("\n") : [];
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+ function applySandbox(cwd) {
54
+ if (!sandboxExists(cwd)) return { ok: false, detail: "no sandbox open" };
55
+ const dir = sandboxDir(cwd);
56
+ try {
57
+ git(dir, ["add", "-A"]);
58
+ const patch = git(dir, ["diff", "--binary", "--cached", "HEAD"]);
59
+ if (patch.length === 0) return { ok: false, detail: "sandbox has no changes" };
60
+ const patchFile = path.join(cwd, ".squint", "sandbox.patch");
61
+ fs.writeFileSync(patchFile, patch + "\n");
62
+ git(cwd, ["apply", "--whitespace=nowarn", patchFile]);
63
+ fs.rmSync(patchFile, { force: true });
64
+ return { ok: true };
65
+ } catch (err) {
66
+ return { ok: false, detail: err instanceof Error ? err.message.split("\n")[0] : String(err) };
67
+ }
68
+ }
69
+ function discardSandbox(cwd) {
70
+ if (!sandboxExists(cwd)) return false;
71
+ const dir = sandboxDir(cwd);
72
+ try {
73
+ const link = path.join(dir, "node_modules");
74
+ if (fs.existsSync(link) && fs.lstatSync(link).isSymbolicLink()) fs.rmSync(link);
75
+ } catch {
76
+ }
77
+ try {
78
+ git(cwd, ["worktree", "remove", "--force", dir]);
79
+ } catch {
80
+ fs.rmSync(dir, { recursive: true, force: true });
81
+ }
82
+ try {
83
+ git(cwd, ["worktree", "prune"]);
84
+ } catch {
85
+ }
86
+ return true;
87
+ }
88
+
89
+ export {
90
+ sandboxDir,
91
+ sandboxExists,
92
+ openSandbox,
93
+ sandboxDiffStat,
94
+ sandboxFiles,
95
+ applySandbox,
96
+ discardSandbox
97
+ };