@aayambansal/squint 0.3.1 → 0.3.3

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
@@ -158,9 +158,15 @@ squint doctor --probe # run every engine end to end, verify auth act
158
158
  - **Commands**: type `/` and matching commands appear with descriptions; tab completes.
159
159
  `/dev` `/check` `/problems` `/fix [n]` `/shot` `/review [focus]` `/variants` `/undo`
160
160
  `/checkpoints` `/restore <n>` `/mode` `/theme` `/copy` `/save` `/resume` `/clear`.
161
- - **Visual pulse**: every clean turn is screenshotted and pixel-compared with the last —
162
- drift shows up as a number, not a surprise. `.squint/locks` lists paths the engine must
163
- never touch; `/save` exports the transcript as markdown.
161
+ - **The harness sees, in your terminal**: on kitty/Ghostty/WezTerm/iTerm2, pulse and
162
+ capture screenshots render as real pixels inside the transcript. Every clean turn is
163
+ pixel-compared with the last (drift as a number), load performance is tracked with
164
+ deltas (`perf: LCP 812ms (+420ms)`), hardcoded colors get pointed at the nearest
165
+ design token, and the mechanical anti-slop sweep flags generic-AI tells as
166
+ distinctiveness debt in `/review`.
167
+ - **`/btw <question>`** asks about the codebase read-only without touching the main
168
+ thread's context. `.squint/locks` lists paths the engine must never touch; `/save`
169
+ exports the transcript as markdown.
164
170
  - Assistant output renders as markdown; the done line measures real work via git
165
171
  (`3 files +42 −7`); the footer tracks session turns and cost; a bell rings when a
166
172
  turn finishes.
@@ -3,7 +3,7 @@ import {
3
3
  cdpCapture,
4
4
  hasWebSocket,
5
5
  pixelDiffPct
6
- } from "./chunk-DAETGO2M.js";
6
+ } from "./chunk-XOVOQJFL.js";
7
7
  export {
8
8
  cdpCapture,
9
9
  hasWebSocket,
@@ -1,8 +1,41 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/prompt/skills.ts
4
+ import fs2 from "fs";
5
+ import path2 from "path";
6
+
7
+ // src/prompt/registry.ts
4
8
  import fs from "fs";
5
9
  import path from "path";
10
+ function loadComponentInventory(cwd) {
11
+ let config;
12
+ try {
13
+ config = JSON.parse(fs.readFileSync(path.join(cwd, "components.json"), "utf8"));
14
+ } catch {
15
+ return null;
16
+ }
17
+ const alias = config.aliases?.ui ?? (config.aliases?.components ? `${config.aliases.components}/ui` : "@/components/ui");
18
+ const relative = alias.replace(/^@\//, "src/").replace(/^~\//, "");
19
+ const uiDir = path.join(cwd, relative);
20
+ let entries;
21
+ try {
22
+ entries = fs.readdirSync(uiDir);
23
+ } catch {
24
+ return null;
25
+ }
26
+ const components = entries.filter((f) => /\.(tsx|jsx|vue|svelte)$/.test(f)).map((f) => f.replace(/\.[^.]+$/, "")).sort();
27
+ if (components.length === 0) return null;
28
+ return { components, uiDir: relative };
29
+ }
30
+ function inventorySection(inventory) {
31
+ return `## Installed UI components (${inventory.uiDir})
32
+
33
+ ${inventory.components.join(" \xB7 ")}
34
+
35
+ Compose from these before writing new primitives. More are one command away: \`npx shadcn@latest add <name>\`. Never invent props for these components \u2014 read the file when unsure.`;
36
+ }
37
+
38
+ // src/prompt/skills.ts
6
39
  var FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
7
40
  function parseSkill(name, raw) {
8
41
  const match = FRONTMATTER_RE.exec(raw);
@@ -22,17 +55,17 @@ function parseSkill(name, raw) {
22
55
  return { name, triggers, body };
23
56
  }
24
57
  function loadSkills(cwd) {
25
- const dir = path.join(cwd, ".squint", "skills");
58
+ const dir = path2.join(cwd, ".squint", "skills");
26
59
  let entries;
27
60
  try {
28
- entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
61
+ entries = fs2.readdirSync(dir).filter((f) => f.endsWith(".md"));
29
62
  } catch {
30
63
  return [];
31
64
  }
32
65
  const skills = [];
33
66
  for (const entry of entries.sort()) {
34
67
  try {
35
- const skill = parseSkill(entry.replace(/\.md$/, ""), fs.readFileSync(path.join(dir, entry), "utf8"));
68
+ const skill = parseSkill(entry.replace(/\.md$/, ""), fs2.readFileSync(path2.join(dir, entry), "utf8"));
36
69
  if (skill) skills.push(skill);
37
70
  } catch {
38
71
  }
@@ -41,7 +74,7 @@ function loadSkills(cwd) {
41
74
  }
42
75
  function loadRules(cwd) {
43
76
  try {
44
- const text = fs.readFileSync(path.join(cwd, ".squint", "rules.md"), "utf8").trim();
77
+ const text = fs2.readFileSync(path2.join(cwd, ".squint", "rules.md"), "utf8").trim();
45
78
  return text.length > 0 ? text : null;
46
79
  } catch {
47
80
  return null;
@@ -53,7 +86,7 @@ function matchSkills(skills, ask) {
53
86
  }
54
87
  function loadLocks(cwd) {
55
88
  try {
56
- return fs.readFileSync(path.join(cwd, ".squint", "locks"), "utf8").split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
89
+ return fs2.readFileSync(path2.join(cwd, ".squint", "locks"), "utf8").split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
57
90
  } catch {
58
91
  return [];
59
92
  }
@@ -64,6 +97,8 @@ function enrich(cwd, ask) {
64
97
  if (rules) parts.push(`## Project rules (always apply)
65
98
 
66
99
  ${rules}`);
100
+ const inventory = loadComponentInventory(cwd);
101
+ if (inventory) parts.push(inventorySection(inventory));
67
102
  const locks = loadLocks(cwd);
68
103
  if (locks.length > 0) {
69
104
  parts.push(
@@ -13,6 +13,7 @@ var COMMANDS = [
13
13
  { name: "fix", args: "[n]", group: "verify", description: "send all open problems to the engine, or just problem n" },
14
14
  { name: "shot", args: "[url]", group: "verify", description: "screenshot the app (or any url) at mobile/tablet/desktop" },
15
15
  { name: "review", args: "[focus]", group: "verify", description: "screenshots + the engine critiques its own rendered work" },
16
+ { name: "polish", args: "[1-5]", group: "verify", description: "unattended rounds of review \u2192 fix (default 2)" },
16
17
  { name: "variants", args: "<2-4> <ask>", group: "explore", description: "parallel design explorations; apply/list/clean" },
17
18
  { name: "sandbox", args: "[on|diff|apply|discard]", group: "explore", description: "asks accumulate in a shadow worktree until you apply" },
18
19
  { name: "undo", group: "explore", description: "revert the last ask (files only)" },
@@ -6,7 +6,7 @@ import {
6
6
  import {
7
7
  cdpCapture,
8
8
  hasWebSocket
9
- } from "./chunk-DAETGO2M.js";
9
+ } from "./chunk-XOVOQJFL.js";
10
10
  import {
11
11
  ensureSquintIgnore
12
12
  } from "./chunk-O2S6PAJE.js";
@@ -47,7 +47,7 @@ async function captureViewports(cwd, url) {
47
47
  const base = url.replace(/\/+$/, "");
48
48
  if (hasWebSocket()) {
49
49
  try {
50
- const { report, shots: shots2, a11y, slop } = await cdpCapture(chrome, url, dir, VIEWPORTS, 2500, true);
50
+ const { report, shots: shots2, a11y, slop, narration } = await cdpCapture(chrome, url, dir, VIEWPORTS, 2500, true);
51
51
  const errors2 = [];
52
52
  for (const route of routes.slice(1)) {
53
53
  try {
@@ -62,7 +62,7 @@ async function captureViewports(cwd, url) {
62
62
  errors2.push(`${route}: capture failed`);
63
63
  }
64
64
  }
65
- return { shots: shots2, errors: errors2, runtime: report, a11y, slop };
65
+ return { shots: shots2, errors: errors2, runtime: report, a11y, slop, narration };
66
66
  } catch {
67
67
  }
68
68
  }
@@ -133,7 +133,7 @@ async function probeRuntime(url, cwd) {
133
133
  async function comparePulse(previous, current) {
134
134
  const chrome = findChrome();
135
135
  if (!chrome || !hasWebSocket()) return null;
136
- const { pixelDiffPct } = await import("./cdp-2XAU5MEA.js");
136
+ const { pixelDiffPct } = await import("./cdp-UOIP4ZQZ.js");
137
137
  return pixelDiffPct(chrome, previous, current);
138
138
  }
139
139
  function buildRuntimeFixPrompt(report) {
@@ -151,6 +151,16 @@ ${findings.join("\n")}
151
151
 
152
152
  Fix these as part of the pass \u2014 they are objective defects, not style preferences.`;
153
153
  }
154
+ function narrationSection(narration) {
155
+ if (!narration || narration.length === 0) return "";
156
+ return `
157
+
158
+ ## What a screen reader would say (accessibility tree, in order)
159
+
160
+ ${narration.join("\n")}
161
+
162
+ Judge this narration as an experience: does the reading order make sense? do names actually describe their targets? is anything announced as "(no accessible name)"? Fix real incoherence \u2014 this is how non-visual users meet the page.`;
163
+ }
154
164
  function slopSection(findings) {
155
165
  if (!findings || findings.length === 0) return "";
156
166
  return `
@@ -161,13 +171,13 @@ ${findings.join("\n")}
161
171
 
162
172
  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
173
  }
164
- function buildReviewPrompt(shots, extra, runtime, a11y, slop) {
174
+ function buildReviewPrompt(shots, extra, runtime, a11y, slop, narration) {
165
175
  const list = shots.map((s) => `- ${s.name}: ${s.path}`).join("\n");
166
176
  return `Screenshots of the running app were just captured:
167
177
 
168
178
  ${list}
169
179
 
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)}`;
180
+ 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)}${narrationSection(narration)}`;
171
181
  }
172
182
 
173
183
  export {
@@ -278,6 +278,7 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
278
278
  let a11y = [];
279
279
  let slop = [];
280
280
  let perf = {};
281
+ let narration = [];
281
282
  const requests = /* @__PURE__ */ new Map();
282
283
  let connection = null;
283
284
  try {
@@ -350,6 +351,47 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
350
351
  if (Array.isArray(result?.value)) slop = result.value.map(String);
351
352
  } catch {
352
353
  }
354
+ try {
355
+ await connection.send("Accessibility.enable", {}, sessionId);
356
+ const { nodes } = await connection.send("Accessibility.getFullAXTree", {}, sessionId);
357
+ const interesting = /* @__PURE__ */ new Set([
358
+ "heading",
359
+ "link",
360
+ "button",
361
+ "textbox",
362
+ "checkbox",
363
+ "radio",
364
+ "combobox",
365
+ "listbox",
366
+ "img",
367
+ "image",
368
+ "navigation",
369
+ "main",
370
+ "banner",
371
+ "contentinfo",
372
+ "form",
373
+ "search",
374
+ "tab",
375
+ "menuitem",
376
+ "switch",
377
+ "slider",
378
+ "alert",
379
+ "dialog",
380
+ "list"
381
+ ]);
382
+ if (Array.isArray(nodes)) {
383
+ for (const node of nodes) {
384
+ if (node.ignored) continue;
385
+ const role = node.role?.value;
386
+ if (!role || !interesting.has(role)) continue;
387
+ const name = (node.name?.value ?? "").toString().trim().replace(/\s+/g, " ").slice(0, 80);
388
+ const level = node.properties?.find((p) => p.name === "level")?.value?.value;
389
+ narration.push(`${role}${level ? ` ${level}` : ""}${name ? `: "${name}"` : " (no accessible name)"}`);
390
+ if (narration.length >= 80) break;
391
+ }
392
+ }
393
+ } catch {
394
+ }
353
395
  }
354
396
  for (const viewport of viewports) {
355
397
  await connection.send(
@@ -373,7 +415,7 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
373
415
  child.kill("SIGKILL");
374
416
  setTimeout(() => fs.rmSync(profileDir, { recursive: true, force: true }), 500).unref?.();
375
417
  }
376
- return { report, shots, a11y, slop, perf };
418
+ return { report, shots, a11y, slop, perf, narration };
377
419
  }
378
420
 
379
421
  export {
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  completeCommand
4
- } from "./chunk-IH4L2KR6.js";
4
+ } from "./chunk-S2ODU4MN.js";
5
5
  import {
6
6
  applySandbox,
7
7
  discardSandbox,
@@ -42,7 +42,7 @@ import {
42
42
  comparePulse,
43
43
  probeRuntime,
44
44
  runtimeSummary
45
- } from "./chunk-OTCH66M7.js";
45
+ } from "./chunk-SXF7CVRW.js";
46
46
  import {
47
47
  runAgent
48
48
  } from "./chunk-VH7OOFQP.js";
@@ -53,10 +53,10 @@ import {
53
53
  detectEngines,
54
54
  getEngine
55
55
  } from "./chunk-KVYGPLWW.js";
56
- import "./chunk-DAETGO2M.js";
56
+ import "./chunk-XOVOQJFL.js";
57
57
  import {
58
58
  enrich
59
- } from "./chunk-4LAU5TGK.js";
59
+ } from "./chunk-H5K55LXY.js";
60
60
  import {
61
61
  composePrompt
62
62
  } from "./chunk-P3H4N2EN.js";
@@ -236,7 +236,7 @@ function registerEnv(program2) {
236
236
  }
237
237
  }
238
238
  const { findChrome: findChrome2 } = await import("./chrome-SBV3H77F.js");
239
- const { hasWebSocket } = await import("./cdp-2XAU5MEA.js");
239
+ const { hasWebSocket } = await import("./cdp-UOIP4ZQZ.js");
240
240
  const chrome = findChrome2();
241
241
  console.log(
242
242
  chrome ? `${pc2.green("\u2713")} Chrome ${pc2.dim(chrome)}` : `${pc2.yellow("\u25CB")} Chrome ${pc2.dim("\u2014 screenshots and runtime probing disabled")}`
@@ -260,7 +260,7 @@ import pc3 from "picocolors";
260
260
  function registerProject(program2) {
261
261
  const skillsCommand = program2.command("skills").description("Project knowledge injected into asks (.squint/rules.md + .squint/skills/)");
262
262
  skillsCommand.command("list").description("Show always-on rules and trigger-matched skills").action(async () => {
263
- const { loadRules, loadSkills } = await import("./skills-DNBHZAAU.js");
263
+ const { loadRules, loadSkills } = await import("./skills-ODOVNE6E.js");
264
264
  const cwd = process.cwd();
265
265
  const rules = loadRules(cwd);
266
266
  console.log(
@@ -276,22 +276,22 @@ function registerProject(program2) {
276
276
  }
277
277
  });
278
278
  skillsCommand.command("init").description("Scaffold .squint/rules.md and an example skill").action(async () => {
279
- const fs2 = await import("fs");
279
+ const fs3 = await import("fs");
280
280
  const nodePath = await import("path");
281
281
  const cwd = process.cwd();
282
282
  const skillsDir = nodePath.join(cwd, ".squint", "skills");
283
- fs2.mkdirSync(skillsDir, { recursive: true });
283
+ fs3.mkdirSync(skillsDir, { recursive: true });
284
284
  const rules = nodePath.join(cwd, ".squint", "rules.md");
285
- if (!fs2.existsSync(rules)) {
286
- fs2.writeFileSync(
285
+ if (!fs3.existsSync(rules)) {
286
+ fs3.writeFileSync(
287
287
  rules,
288
288
  "# Project rules\n\nThese ride along on every squint ask. Keep them short \u2014 cut anything that would not cause a mistake if removed.\n"
289
289
  );
290
290
  console.log(pc3.green("\u2713 .squint/rules.md"));
291
291
  }
292
292
  const example = nodePath.join(skillsDir, "example.md");
293
- if (!fs2.existsSync(example)) {
294
- fs2.writeFileSync(
293
+ if (!fs3.existsSync(example)) {
294
+ fs3.writeFileSync(
295
295
  example,
296
296
  "---\ntriggers: example, sample\n---\n\nThis note is injected only when an ask mentions one of the triggers above.\nDocument the parts of this repo an agent would otherwise rediscover every time:\nwhere state lives, which helpers to reuse, what not to touch.\n"
297
297
  );
@@ -300,7 +300,7 @@ function registerProject(program2) {
300
300
  console.log(pc3.dim("rules are always-on; skills inject when an ask mentions a trigger"));
301
301
  });
302
302
  program2.command("brief").description("Set a committed design direction for this project (.squint/brief.md)").argument("[family]", "aesthetic family id (omit to list)").option("--force", "overwrite an existing project brief").action(async (familyId, options) => {
303
- const fs2 = await import("fs");
303
+ const fs3 = await import("fs");
304
304
  const nodePath = await import("path");
305
305
  const { FAMILIES, getFamily, renderFamilyBrief } = await import("./families-2G5SLLY7.js");
306
306
  if (!familyId) {
@@ -318,13 +318,13 @@ function registerProject(program2) {
318
318
  return;
319
319
  }
320
320
  const target = nodePath.join(process.cwd(), ".squint", "brief.md");
321
- if (fs2.existsSync(target) && !options.force) {
321
+ if (fs3.existsSync(target) && !options.force) {
322
322
  console.error(pc3.red(`\u2717 ${target} exists \u2014 use --force to overwrite`));
323
323
  process.exitCode = 1;
324
324
  return;
325
325
  }
326
- fs2.mkdirSync(nodePath.dirname(target), { recursive: true });
327
- fs2.writeFileSync(target, renderFamilyBrief(family) + "\n");
326
+ fs3.mkdirSync(nodePath.dirname(target), { recursive: true });
327
+ fs3.writeFileSync(target, renderFamilyBrief(family) + "\n");
328
328
  console.log(pc3.green(`\u2713 ${family.name} direction written to .squint/brief.md`));
329
329
  console.log(pc3.dim("every squint ask in this repo now holds this direction \u2014 edit the file to remix"));
330
330
  });
@@ -459,7 +459,7 @@ function registerQuality(program2) {
459
459
  if (results.some((r) => !r.ok)) process.exitCode = 1;
460
460
  });
461
461
  program2.command("shot").description("Screenshot a running app at mobile/tablet/desktop viewports (+ .squint/routes)").argument("<url>", "URL of the running app (e.g. http://localhost:5173)").action(async (url) => {
462
- const { captureViewports: captureViewports2 } = await import("./preview-PS7WJ2KO.js");
462
+ const { captureViewports: captureViewports2 } = await import("./preview-S6FOTRRR.js");
463
463
  const result = await captureViewports2(process.cwd(), url);
464
464
  if (!result) {
465
465
  console.error(pc4.red("\u2717 no Chrome/Chromium found"));
@@ -577,21 +577,21 @@ Next: ${pc6.bold(`${cd}${options.install ? "" : "npm install && "}squint`)}`);
577
577
  console.log(pc6.dim("then describe what to build \u2014 /dev starts the preview server"));
578
578
  });
579
579
  program2.command("tag").description("Add the element picker to this Vite app (Alt+S in the browser \u2192 click \u2192 file:line:col)").action(async () => {
580
- const fs2 = await import("fs");
580
+ const fs3 = await import("fs");
581
581
  const nodePath = await import("path");
582
582
  const { patchViteConfig, TAGGER_FILENAME, TAGGER_SOURCE } = await import("./source-ZZU245VN.js");
583
583
  const cwd = process.cwd();
584
584
  const taggerPath = nodePath.join(cwd, TAGGER_FILENAME);
585
- fs2.writeFileSync(taggerPath, TAGGER_SOURCE);
585
+ fs3.writeFileSync(taggerPath, TAGGER_SOURCE);
586
586
  console.log(pc6.green(`\u2713 ${TAGGER_FILENAME} written`));
587
- const configPath = ["vite.config.ts", "vite.config.js", "vite.config.mjs"].map((name) => nodePath.join(cwd, name)).find((candidate) => fs2.existsSync(candidate));
587
+ const configPath = ["vite.config.ts", "vite.config.js", "vite.config.mjs"].map((name) => nodePath.join(cwd, name)).find((candidate) => fs3.existsSync(candidate));
588
588
  if (!configPath) {
589
589
  console.log(pc6.yellow("\u25CB no vite config found \u2014 add the plugin manually:"));
590
590
  console.log(pc6.dim(` import squintTagger from './${TAGGER_FILENAME}'
591
591
  plugins: [squintTagger(), \u2026]`));
592
592
  return;
593
593
  }
594
- const source = fs2.readFileSync(configPath, "utf8");
594
+ const source = fs3.readFileSync(configPath, "utf8");
595
595
  const patched = patchViteConfig(source);
596
596
  if (patched === "already") {
597
597
  console.log(pc6.dim("vite config already wired"));
@@ -600,7 +600,7 @@ Next: ${pc6.bold(`${cd}${options.install ? "" : "npm install && "}squint`)}`);
600
600
  console.log(pc6.dim(` import squintTagger from './${TAGGER_FILENAME}'
601
601
  plugins: [squintTagger(), \u2026]`));
602
602
  } else {
603
- fs2.writeFileSync(configPath, patched);
603
+ fs3.writeFileSync(configPath, patched);
604
604
  console.log(pc6.green(`\u2713 ${nodePath.basename(configPath)} wired`));
605
605
  }
606
606
  console.log(
@@ -615,11 +615,47 @@ import { render } from "ink";
615
615
  // src/tui/App.tsx
616
616
  import { Box as Box3, Static, Text as Text3, useApp, useInput } from "ink";
617
617
  import { InkPictureProvider } from "ink-picture";
618
- import path3 from "path";
618
+ import path4 from "path";
619
619
  import { useMemo, useRef, useState as useState2, useSyncExternalStore } from "react";
620
620
 
621
621
  // src/session/engine.ts
622
+ import path3 from "path";
623
+
624
+ // src/session/hooks.ts
625
+ import { spawn } from "child_process";
626
+ import fs2 from "fs";
622
627
  import path2 from "path";
628
+ function runHook(cwd, event, payload) {
629
+ const script = path2.join(cwd, ".squint", "hooks", event);
630
+ try {
631
+ fs2.accessSync(script, fs2.constants.X_OK);
632
+ } catch {
633
+ return false;
634
+ }
635
+ try {
636
+ const child = spawn(script, [], {
637
+ cwd,
638
+ env: { ...process.env, SQUINT_EVENT: event, ...prefixed(payload) },
639
+ stdio: "ignore",
640
+ detached: false
641
+ });
642
+ const timer = setTimeout(() => child.kill("SIGKILL"), 1e4);
643
+ child.on("close", () => clearTimeout(timer));
644
+ child.on("error", () => clearTimeout(timer));
645
+ return true;
646
+ } catch {
647
+ return false;
648
+ }
649
+ }
650
+ function prefixed(payload) {
651
+ const out = {};
652
+ for (const [key, value] of Object.entries(payload)) {
653
+ out[`SQUINT_${key.toUpperCase()}`] = value;
654
+ }
655
+ return out;
656
+ }
657
+
658
+ // src/session/engine.ts
623
659
  var MAX_AUTO_FIX_ATTEMPTS = 2;
624
660
  var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
625
661
  var Session = class {
@@ -702,6 +738,7 @@ var Session = class {
702
738
  this.nextProblemId += 1;
703
739
  this.problemPrompts.set(this.nextProblemId, prompt);
704
740
  this.notify({ problems: [...kept, { id: this.nextProblemId, source, summary }] });
741
+ runHook(this.opts.cwd, "on-problem", { source, summary });
705
742
  }
706
743
  clearProblems(source) {
707
744
  const removed = this.state.problems.filter((p) => p.source === source);
@@ -769,6 +806,26 @@ ${question}`,
769
806
  this.notify({ running: false });
770
807
  this.drainQueue();
771
808
  }
809
+ /**
810
+ * Unattended polish: n rounds of screenshot-review-fix. Each round is
811
+ * a full review turn (its own cost); the per-ask auto-fix cap resets
812
+ * per round so fixes still flow. Fire it and step away.
813
+ */
814
+ async polish(rounds) {
815
+ for (let round = 1; round <= rounds; round++) {
816
+ this.fixAttempts = 0;
817
+ const result = await this.capture();
818
+ if (!result) {
819
+ this.push("status", `polish stopped at round ${round}: nothing to capture`);
820
+ return;
821
+ }
822
+ await this.runTurn(
823
+ buildReviewPrompt(result.shots, void 0, result.runtime, result.a11y, result.slop, result.narration),
824
+ `\u{1F441} polish round ${round}/${rounds}`
825
+ );
826
+ }
827
+ this.push("status", `polish complete \u2014 ${rounds} round${rounds === 1 ? "" : "s"} of review and fixes`);
828
+ }
772
829
  setMode(mode) {
773
830
  this.notify({ mode });
774
831
  const hint = mode === "plan" ? "read-only: the engine investigates and proposes, edits nothing" : mode === "yolo" ? "no approval friction \u2014 the engine can do anything" : "edits auto-approved inside the workspace";
@@ -991,6 +1048,11 @@ ${driftSummary(drift)}`);
991
1048
  } catch {
992
1049
  }
993
1050
  }
1051
+ runHook(this.opts.cwd, "on-turn-end", {
1052
+ cost: String(result.costUsd ?? 0),
1053
+ duration_ms: String(result.durationMs ?? 0),
1054
+ stat: stat ?? ""
1055
+ });
994
1056
  const before = this.state.totals.costUsd;
995
1057
  this.notify({
996
1058
  totals: {
@@ -1004,6 +1066,7 @@ ${driftSummary(drift)}`);
1004
1066
  "error",
1005
1067
  `session cost $${this.state.totals.costUsd.toFixed(2)} crossed your $${budget.toFixed(2)} budget \u2014 squint keeps working, this is just the flag you asked for`
1006
1068
  );
1069
+ runHook(this.opts.cwd, "on-budget", { total: this.state.totals.costUsd.toFixed(2), budget: budget.toFixed(2) });
1007
1070
  }
1008
1071
  if (this.sessionId) {
1009
1072
  saveState(this.opts.cwd, {
@@ -1075,7 +1138,7 @@ ${errors.slice(-5).join("\n")}`);
1075
1138
  const captureResult = await this.capture();
1076
1139
  if (captureResult) {
1077
1140
  await this.runTurn(
1078
- buildReviewPrompt(captureResult.shots, void 0, captureResult.runtime, captureResult.a11y, captureResult.slop),
1141
+ buildReviewPrompt(captureResult.shots, void 0, captureResult.runtime, captureResult.a11y, captureResult.slop, captureResult.narration),
1079
1142
  "\u{1F441} auto-review rendered UI"
1080
1143
  );
1081
1144
  }
@@ -1177,6 +1240,7 @@ ${errors.slice(-5).join("\n")}`);
1177
1240
  "status",
1178
1241
  pct < 0.5 ? "visual pulse: stable vs last turn" : `visual pulse: ${pct.toFixed(1)}% of the page changed vs last turn`
1179
1242
  );
1243
+ runHook(this.opts.cwd, "on-pulse-diff", { pct: pct.toFixed(1) });
1180
1244
  if (pct >= 0.5) this.push("image", pulsePath);
1181
1245
  return pct;
1182
1246
  }
@@ -1197,7 +1261,7 @@ ${errors.slice(-5).join("\n")}`);
1197
1261
  if (result.shots.length > 0) {
1198
1262
  this.push(
1199
1263
  "status",
1200
- `captured ${result.shots.map((s) => s.name).join(", ")} \u2192 ${path2.dirname(result.shots[0].path)}`
1264
+ `captured ${result.shots.map((s) => s.name).join(", ")} \u2192 ${path3.dirname(result.shots[0].path)}`
1201
1265
  );
1202
1266
  const desktop = result.shots.find((s) => s.name === "desktop") ?? result.shots[0];
1203
1267
  this.push("image", desktop.path);
@@ -1330,12 +1394,12 @@ They are objective defects, not style preferences.`
1330
1394
  }
1331
1395
  case "save": {
1332
1396
  void (async () => {
1333
- const fs2 = await import("fs");
1334
- const path4 = await import("path");
1335
- const dir = path4.join(this.opts.cwd, ".squint", "transcripts");
1336
- fs2.mkdirSync(dir, { recursive: true });
1397
+ const fs3 = await import("fs");
1398
+ const path5 = await import("path");
1399
+ const dir = path5.join(this.opts.cwd, ".squint", "transcripts");
1400
+ fs3.mkdirSync(dir, { recursive: true });
1337
1401
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
1338
- const file = path4.join(dir, `${stamp}.md`);
1402
+ const file = path5.join(dir, `${stamp}.md`);
1339
1403
  const lines = [`# squint session \u2014 ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 16)}`, ""];
1340
1404
  for (const item of this.state.items) {
1341
1405
  switch (item.role) {
@@ -1358,11 +1422,25 @@ They are objective defects, not style preferences.`
1358
1422
  }
1359
1423
  }
1360
1424
  lines.push("", `> ${this.summary()}`);
1361
- fs2.writeFileSync(file, lines.join("\n") + "\n");
1362
- this.push("status", `saved transcript \u2192 ${path4.relative(this.opts.cwd, file)}`);
1425
+ fs3.writeFileSync(file, lines.join("\n") + "\n");
1426
+ this.push("status", `saved transcript \u2192 ${path5.relative(this.opts.cwd, file)}`);
1363
1427
  })();
1364
1428
  break;
1365
1429
  }
1430
+ case "polish": {
1431
+ const rounds = arg ? Number.parseInt(arg, 10) : 2;
1432
+ if (!Number.isInteger(rounds) || rounds < 1 || rounds > 5) {
1433
+ this.push("status", "usage: /polish [1-5] \u2014 rounds of screenshot \u2192 critique \u2192 fix (each costs a turn)");
1434
+ break;
1435
+ }
1436
+ if (!this.state.devUrl) {
1437
+ this.push("error", "dev server not running \u2014 /dev first");
1438
+ break;
1439
+ }
1440
+ this.push("status", `polishing: ${rounds} round${rounds === 1 ? "" : "s"} of review \u2192 fix`);
1441
+ void this.polish(rounds);
1442
+ break;
1443
+ }
1366
1444
  case "btw":
1367
1445
  if (!arg) {
1368
1446
  this.push("status", "usage: /btw <question> \u2014 read-only side question, main thread untouched");
@@ -1376,8 +1454,8 @@ They are objective defects, not style preferences.`
1376
1454
  this.push("status", "nothing to copy yet");
1377
1455
  break;
1378
1456
  }
1379
- void import("child_process").then(({ spawn }) => {
1380
- const cmd = process.platform === "darwin" ? spawn("pbcopy") : process.platform === "win32" ? spawn("clip") : spawn("xclip", ["-selection", "clipboard"]);
1457
+ void import("child_process").then(({ spawn: spawn2 }) => {
1458
+ const cmd = process.platform === "darwin" ? spawn2("pbcopy") : process.platform === "win32" ? spawn2("clip") : spawn2("xclip", ["-selection", "clipboard"]);
1381
1459
  cmd.on("error", () => this.push("error", "no clipboard tool found"));
1382
1460
  cmd.on("close", (code) => {
1383
1461
  if (code === 0) this.push("status", `copied last reply (${last.text.length} chars)`);
@@ -1430,7 +1508,7 @@ They are objective defects, not style preferences.`
1430
1508
  const result = await this.capture();
1431
1509
  if (result) {
1432
1510
  await this.runTurn(
1433
- buildReviewPrompt(result.shots, arg || void 0, result.runtime, result.a11y, result.slop),
1511
+ buildReviewPrompt(result.shots, arg || void 0, result.runtime, result.a11y, result.slop, result.narration),
1434
1512
  `\u{1F441} review rendered UI${arg ? ` \xB7 ${arg}` : ""}`
1435
1513
  );
1436
1514
  }
@@ -1614,7 +1692,7 @@ ${sandboxFiles(this.opts.cwd).join("\n")}`);
1614
1692
  this.notify({ items: [], totals: { costUsd: 0, turns: 0 } });
1615
1693
  break;
1616
1694
  case "help": {
1617
- void import("./commands-E7JIVJDP.js").then(({ commandHelp }) => this.push("status", commandHelp()));
1695
+ void import("./commands-Y5RUKBPS.js").then(({ commandHelp }) => this.push("status", commandHelp()));
1618
1696
  break;
1619
1697
  }
1620
1698
  case "quit":
@@ -2180,7 +2258,7 @@ function App({
2180
2258
  state.engineId,
2181
2259
  state.model ? ` \xB7 ${state.model}` : "",
2182
2260
  " \xB7 ",
2183
- path3.basename(cwd),
2261
+ path4.basename(cwd),
2184
2262
  devBadge,
2185
2263
  totalsBadge,
2186
2264
  state.problems.length > 0 && /* @__PURE__ */ jsxs3(Text3, { color: theme2.error, children: [
@@ -2230,7 +2308,7 @@ function registerTui(program2) {
2230
2308
  }
2231
2309
 
2232
2310
  // src/cli.tsx
2233
- var VERSION = true ? "0.3.1" : "0.0.0-dev";
2311
+ var VERSION = true ? "0.3.3" : "0.0.0-dev";
2234
2312
  var program = new Command();
2235
2313
  program.name("squint").description("Lovable for your terminal \u2014 a frontend harness on top of Claude Code, Codex, and friends.").version(VERSION);
2236
2314
  registerRun(program);
@@ -3,7 +3,7 @@ import {
3
3
  COMMANDS,
4
4
  commandHelp,
5
5
  completeCommand
6
- } from "./chunk-IH4L2KR6.js";
6
+ } from "./chunk-S2ODU4MN.js";
7
7
  export {
8
8
  COMMANDS,
9
9
  commandHelp,
@@ -10,10 +10,10 @@ import {
10
10
  probeRuntime,
11
11
  routeShotName,
12
12
  runtimeSummary
13
- } from "./chunk-OTCH66M7.js";
13
+ } from "./chunk-SXF7CVRW.js";
14
14
  import "./chunk-IMDRXXFU.js";
15
15
  import "./chunk-KVYGPLWW.js";
16
- import "./chunk-DAETGO2M.js";
16
+ import "./chunk-XOVOQJFL.js";
17
17
  import "./chunk-O2S6PAJE.js";
18
18
  export {
19
19
  VIEWPORTS,
@@ -6,7 +6,7 @@ import {
6
6
  loadSkills,
7
7
  matchSkills,
8
8
  parseSkill
9
- } from "./chunk-4LAU5TGK.js";
9
+ } from "./chunk-H5K55LXY.js";
10
10
  export {
11
11
  enrich,
12
12
  loadLocks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aayambansal/squint",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Lovable for your terminal — a frontend harness on top of Claude Code, Codex, and other coding agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",