@aayambansal/squint 0.2.0 → 0.2.2

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
@@ -86,10 +86,11 @@ From source: `git clone https://github.com/aayambansal/squint.git && cd squint &
86
86
  and catches what the server never prints: blank pages, exceptions, failed requests.
87
87
  5. **The agent looks at its work.** `/review` screenshots mobile/tablet/desktop and
88
88
  re-prompts the engine to critique what it can see — then fix it.
89
- 6. **Gates keep it honest.** `/check` runs typecheck, lint, test, build failures come
90
- back with orders not to weaken the checks.
91
- 7. **Everything is reversible.** Each ask is snapshotted via git plumbing; `/undo` reverts
92
- the whole turn while your own uncommitted work survives.
89
+ 6. **Gates keep it honest.** Typecheck + lint run automatically after *every* turn and
90
+ auto-fix (capped); `/check` adds tests and the build — failures come back with orders
91
+ not to weaken the checks.
92
+ 7. **Everything is reversible.** Every ask records a checkpoint; `/undo` pops the last,
93
+ `/restore <n>` rewinds files to any earlier point — your own uncommitted work survives.
93
94
  8. **Point at things.** Alt+S in the browser, click any element, and a self-locating
94
95
  reference lands on your clipboard to paste into squint.
95
96
  9. **Explore in parallel.** `squint variants gen 3 "<ask>"` builds the same ask three ways —
@@ -122,18 +123,44 @@ squint check # gates: typecheck -> lint -> test -> build
122
123
  squint shot http://localhost:5173 # screenshots at 390/768/1440
123
124
  squint brief # list design directions
124
125
  squint brief cinematic-dark # commit one for this repo
125
- squint tag # add the Alt+S element picker to any Vite app
126
+ squint tag # Alt+S element picker: pin elements + notes, alt+enter copies all
126
127
  squint variants gen 3 "<ask>" # 3 parallel design explorations
127
128
  squint variants apply terminal # keep the winner
129
+ squint skills init # scaffold .squint/rules.md + a trigger-matched skill
128
130
  squint config set engine claude
129
131
  squint config set models.claude claude-sonnet-5
130
132
  squint config set autoDev true # dev server starts with the TUI
131
133
  squint config set autoFix true # errors auto-route back (max 2 tries)
134
+ squint config set autoCheck false # skip the per-turn typecheck+lint pass
135
+ squint config set theme ocean # amber · ocean · moss · rose · mono
136
+ squint config set bell false # no bell on turn completion
132
137
  squint doctor # engines + Chrome + WebSocket check
138
+ squint doctor --probe # run every engine end to end, verify auth actually works
133
139
  ```
134
140
 
135
- Inside the TUI: `/dev` `/check` `/fix` `/shot` `/review [focus]` `/undo` `/resume`
136
- `/engine <id>` `/model <name>` `/clear` — `Esc` interrupts, arrows recall history.
141
+ **Inside the TUI:**
142
+
143
+ - **Modes**: `shift+tab` cycles `safe` (edits auto-approved) → `plan` (read-only
144
+ investigation) → `yolo` (no friction), mapped to each engine's native permission flags.
145
+ - **Type ahead**: keep typing while the agent works — Enter queues asks that dispatch in
146
+ order; `/queue clear` drops them. `Esc` interrupts the current turn.
147
+ - **Editing**: a real line editor — arrows move, `alt+←/→` jump words, `ctrl+a/e/k/u/w`,
148
+ `↑/↓` history. `ctrl+c` twice exits with a session summary.
149
+ - **Problems**: findings from gates, the dev server, the runtime probe, and a11y sweeps
150
+ collect into a list — `/problems` shows it, `/fix` sends everything as one turn,
151
+ `/fix <n>` targets one. The footer counts what's open.
152
+ - **Variants without leaving**: `/variants 3 <ask>` runs parallel explorations with
153
+ streaming per-family status; `/variants apply <id>` keeps the winner.
154
+ - **Commands**: `/dev` `/check` `/problems` `/fix [n]` `/shot` `/review [focus]`
155
+ `/variants` `/undo` `/checkpoints` `/restore <n>` `/mode` `/theme` `/resume`
156
+ `/engine <id>` `/model <name>` `/clear`.
157
+ - Assistant output renders as markdown; the done line measures real work via git
158
+ (`3 files +42 −7`); the footer tracks session turns and cost; a bell rings when a
159
+ turn finishes.
160
+
161
+ **Project knowledge** rides along automatically: `.squint/rules.md` on every ask, and
162
+ `.squint/skills/*.md` (frontmatter `triggers: auth, login`) only when an ask mentions a
163
+ trigger — deterministic context routing, no embeddings.
137
164
 
138
165
  ## Design directions
139
166
 
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ cdpCapture,
4
+ hasWebSocket,
5
+ pixelDiffPct
6
+ } from "./chunk-OC6RU6XH.js";
7
+ export {
8
+ cdpCapture,
9
+ hasWebSocket,
10
+ pixelDiffPct
11
+ };
@@ -2,8 +2,8 @@
2
2
  import {
3
3
  findChrome,
4
4
  screenshot
5
- } from "./chunk-Z46MXU64.js";
6
- import "./chunk-MCYQXOE7.js";
5
+ } from "./chunk-P3V5CWH5.js";
6
+ import "./chunk-2LRIKWBU.js";
7
7
  export {
8
8
  findChrome,
9
9
  screenshot
@@ -1,7 +1,17 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ variantsRoot
4
+ } from "./chunk-LCS47EAG.js";
5
+ import {
6
+ findChrome,
7
+ screenshot
8
+ } from "./chunk-P3V5CWH5.js";
2
9
  import {
3
10
  lineSplitter
4
- } from "./chunk-MCYQXOE7.js";
11
+ } from "./chunk-2LRIKWBU.js";
12
+
13
+ // src/variants/shots.ts
14
+ import path2 from "path";
5
15
 
6
16
  // src/devserver/devserver.ts
7
17
  import { spawn } from "child_process";
@@ -149,8 +159,55 @@ ${errorBlock}
149
159
  ${context}`;
150
160
  }
151
161
 
162
+ // src/variants/shots.ts
163
+ var URL_TIMEOUT_MS = 45e3;
164
+ function waitForUrl(server) {
165
+ return new Promise((resolve) => {
166
+ const startedAt = Date.now();
167
+ const poll = setInterval(() => {
168
+ if (server.url) {
169
+ clearInterval(poll);
170
+ resolve(server.url);
171
+ } else if (Date.now() - startedAt > URL_TIMEOUT_MS || server.state === "crashed") {
172
+ clearInterval(poll);
173
+ resolve(null);
174
+ }
175
+ }, 200);
176
+ });
177
+ }
178
+ async function screenshotVariants(cwd, variants) {
179
+ const chrome = findChrome();
180
+ if (!chrome) {
181
+ return variants.map((v) => ({ familyId: v.family.id, error: "no Chrome found" }));
182
+ }
183
+ const shots = [];
184
+ for (const variant of variants) {
185
+ const command = detectDevCommand(variant.dir);
186
+ if (!command) {
187
+ shots.push({ familyId: variant.family.id, error: "no dev script" });
188
+ continue;
189
+ }
190
+ const server = new DevServer(variant.dir);
191
+ server.start(command);
192
+ const url = await waitForUrl(server);
193
+ if (!url) {
194
+ server.stop();
195
+ shots.push({ familyId: variant.family.id, error: "dev server did not announce a URL" });
196
+ continue;
197
+ }
198
+ const outPath = path2.join(variantsRoot(cwd), `${variant.family.id}.png`);
199
+ const result = await screenshot(chrome, url, outPath, { width: 1440, height: 900 });
200
+ server.stop();
201
+ shots.push(
202
+ result.ok ? { familyId: variant.family.id, path: outPath } : { familyId: variant.family.id, error: result.error }
203
+ );
204
+ }
205
+ return shots;
206
+ }
207
+
152
208
  export {
153
209
  detectDevCommand,
154
210
  DevServer,
155
- buildFixPrompt
211
+ buildFixPrompt,
212
+ screenshotVariants
156
213
  };
@@ -2,11 +2,11 @@
2
2
  import {
3
3
  cdpCapture,
4
4
  hasWebSocket
5
- } from "./chunk-VJGE7HSP.js";
5
+ } from "./chunk-OC6RU6XH.js";
6
6
  import {
7
7
  findChrome,
8
8
  screenshot
9
- } from "./chunk-Z46MXU64.js";
9
+ } from "./chunk-P3V5CWH5.js";
10
10
 
11
11
  // src/preview/preview.ts
12
12
  import fs2 from "fs";
@@ -126,16 +126,29 @@ ${blocks.join("\n\n")}
126
126
 
127
127
  Fix these first \u2014 a page that errors is broken regardless of how it looks.`;
128
128
  }
129
- async function probeRuntime(url) {
129
+ async function probeRuntime(url, cwd) {
130
130
  const chrome = findChrome();
131
131
  if (!chrome || !hasWebSocket()) return null;
132
132
  try {
133
- const { report } = await cdpCapture(chrome, url, os.tmpdir(), [], 1500);
134
- return report;
133
+ const dir = cwd ? previewDir(cwd) : os.tmpdir();
134
+ const { report, shots } = await cdpCapture(
135
+ chrome,
136
+ url,
137
+ dir,
138
+ cwd ? [{ name: "pulse", width: 1280, height: 800 }] : [],
139
+ 1500
140
+ );
141
+ return { report, pulsePath: shots[0]?.path };
135
142
  } catch {
136
143
  return null;
137
144
  }
138
145
  }
146
+ async function comparePulse(previous, current) {
147
+ const chrome = findChrome();
148
+ if (!chrome || !hasWebSocket()) return null;
149
+ const { pixelDiffPct } = await import("./cdp-SFWNP7OA.js");
150
+ return pixelDiffPct(chrome, previous, current);
151
+ }
139
152
  function buildRuntimeFixPrompt(report) {
140
153
  return `The running app has runtime problems.${runtimeSection(report)}
141
154
 
@@ -169,6 +182,7 @@ export {
169
182
  captureViewports,
170
183
  runtimeSummary,
171
184
  probeRuntime,
185
+ comparePulse,
172
186
  buildRuntimeFixPrompt,
173
187
  buildReviewPrompt
174
188
  };
@@ -1,14 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  FAMILIES,
4
+ FIRST_TURN_ADDENDUM,
4
5
  renderFamilyBrief
5
- } from "./chunk-5IR2AZVK.js";
6
+ } from "./chunk-P3H4N2EN.js";
6
7
  import {
7
8
  runAgent
8
- } from "./chunk-FPTBBHOH.js";
9
- import {
10
- FIRST_TURN_ADDENDUM
11
- } from "./chunk-NT2HR4RD.js";
9
+ } from "./chunk-OTG4ZB4R.js";
12
10
 
13
11
  // src/variants/variants.ts
14
12
  import { execFileSync } from "child_process";
@@ -152,6 +152,55 @@ var describe = (value) => {
152
152
  }
153
153
  return String(value);
154
154
  };
155
+ async function pixelDiffPct(chromePath, pngA, pngB) {
156
+ const { child, wsUrl, profileDir } = await launchChrome(chromePath);
157
+ let connection = null;
158
+ try {
159
+ connection = await CdpConnection.connect(wsUrl, 1e4);
160
+ const { targetId } = await connection.send("Target.createTarget", { url: "about:blank" });
161
+ const { sessionId } = await connection.send("Target.attachToTarget", { targetId, flatten: true });
162
+ await connection.send("Runtime.enable", {}, sessionId);
163
+ const expression = `(async () => {
164
+ const load = (src) => new Promise((resolve, reject) => {
165
+ const img = new Image();
166
+ img.onload = () => resolve(img);
167
+ img.onerror = () => reject(new Error('decode'));
168
+ img.src = src;
169
+ });
170
+ const a = await load('data:image/png;base64,${pngA.toString("base64")}');
171
+ const b = await load('data:image/png;base64,${pngB.toString("base64")}');
172
+ const w = Math.min(a.width, b.width), h = Math.min(a.height, b.height);
173
+ if (w === 0 || h === 0) return null;
174
+ const draw = (img) => {
175
+ const c = document.createElement('canvas');
176
+ c.width = w; c.height = h;
177
+ const ctx = c.getContext('2d');
178
+ ctx.drawImage(img, 0, 0);
179
+ return ctx.getImageData(0, 0, w, h).data;
180
+ };
181
+ const da = draw(a), db = draw(b);
182
+ let differ = 0, total = 0;
183
+ for (let i = 0; i < da.length; i += 8) {
184
+ total++;
185
+ if (Math.abs(da[i] - db[i]) > 8 || Math.abs(da[i + 1] - db[i + 1]) > 8 || Math.abs(da[i + 2] - db[i + 2]) > 8) differ++;
186
+ }
187
+ const sizePenalty = (a.width !== b.width || a.height !== b.height) ? 1 : 0;
188
+ return Math.min(100, (differ / total) * 100 + sizePenalty);
189
+ })()`;
190
+ const { result } = await connection.send(
191
+ "Runtime.evaluate",
192
+ { expression, awaitPromise: true, returnByValue: true },
193
+ sessionId
194
+ );
195
+ return typeof result?.value === "number" ? result.value : null;
196
+ } catch {
197
+ return null;
198
+ } finally {
199
+ connection?.close();
200
+ child.kill("SIGKILL");
201
+ setTimeout(() => fs.rmSync(profileDir, { recursive: true, force: true }), 500).unref?.();
202
+ }
203
+ }
155
204
  async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, audit = false) {
156
205
  const { child, wsUrl, profileDir } = await launchChrome(chromePath);
157
206
  const report = { consoleErrors: [], pageErrors: [], failedRequests: [] };
@@ -239,5 +288,6 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
239
288
 
240
289
  export {
241
290
  hasWebSocket,
291
+ pixelDiffPct,
242
292
  cdpCapture
243
293
  };
@@ -3,7 +3,7 @@ import {
3
3
  findEngineBinary,
4
4
  lineSplitter,
5
5
  truncate
6
- } from "./chunk-MCYQXOE7.js";
6
+ } from "./chunk-2LRIKWBU.js";
7
7
 
8
8
  // src/runner/run.ts
9
9
  import { spawn } from "child_process";
@@ -1,7 +1,60 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- DEFAULT_BRIEF
4
- } from "./chunk-NT2HR4RD.js";
2
+
3
+ // src/prompt/brief.ts
4
+ import fs from "fs";
5
+ import path from "path";
6
+ var DEFAULT_BRIEF = `You are acting as a senior product designer and frontend engineer. Treat every change as production work, not a demo.
7
+
8
+ Direction before code:
9
+ - Commit to one specific visual direction derived from what this product is for, and state it in one sentence before implementing. If the direction could be guessed from the product category alone, sharpen it until it couldn't.
10
+ - Choose a color strategy deliberately: restrained (neutrals + one accent), committed (one dominant color owning 30\u201360% of the surface), or a small palette of 3\u20135 named roles. Never purple/violet gradients unless asked. Body text contrast stays at least 4.5:1 \u2014 no light gray body copy "for elegance".
11
+ - Typography: at most two families, paired on contrast (display serif + geometric sans, or sans + mono). Never Inter/Roboto/Open Sans/system defaults for display type. Pair weight extremes (300 against 700\u2013900); display sizes jump 3x over body, not 1.5x.
12
+
13
+ Tokens are the system:
14
+ - Define or extend design tokens (CSS variables / theme config) first, then compose the UI from them. Never scatter literal colors or one-off spacing values through components.
15
+ - One spacing rhythm on a 4/8px grid; one type scale with a ratio of at least 1.25 between steps.
16
+
17
+ Banned tells \u2014 these read instantly as machine-generated:
18
+ - Purple gradient on white; glassmorphism cards; cream/beige page background as a reflex "warmth" move
19
+ - Emoji as icons; identical icon-topped card grids; stat banner rows; numbered 01/02/03 section markers
20
+ - Tiny all-caps tracked eyebrow labels over every section; gradient text; colored left-border strips on cards
21
+ - Centered hero + badge + three feature cards; unmodified component-library defaults
22
+ If someone could look at the result and say "AI made that" without doubt, it has failed.
23
+
24
+ Craft details:
25
+ - Every interactive element gets hover, focus-visible, and active treatment; keyboard focus stays visible.
26
+ - Anything that loads data gets loading, empty, and error states.
27
+ - Motion: 150\u2013250ms ease-out; one orchestrated entrance with staggered reveals beats scattered micro-interactions; honor prefers-reduced-motion; never leave content invisible until a scroll observer fires.
28
+ - Body line length 65\u201375ch; text-wrap: balance on headings.
29
+
30
+ Engineering:
31
+ - Follow the repo's existing conventions and extend its patterns; new components in new files, small and focused; semantic HTML.
32
+ - Responsive from 360px up with no horizontal overflow \u2014 check intermediate widths, not just phone and desktop.
33
+ - Let errors surface instead of swallowing them in try/catch; log clearly so failures can be traced.
34
+ - Not done until the app builds cleanly, typechecks, and renders without console errors.`;
35
+ var FIRST_TURN_ADDENDUM = `This is the opening move on this task: establish the design foundation before building. State the visual direction, set up the tokens/theme first, then build components from them. The first render should feel like a designed product, not a scaffold \u2014 impressive on sight.`;
36
+ function loadBrief(cwd) {
37
+ const custom = path.join(cwd, ".squint", "brief.md");
38
+ try {
39
+ const text = fs.readFileSync(custom, "utf8").trim();
40
+ if (text.length > 0) return text;
41
+ } catch {
42
+ }
43
+ return DEFAULT_BRIEF;
44
+ }
45
+ function composePrompt(ask, opts) {
46
+ if (opts.noBrief) return ask;
47
+ const brief = loadBrief(opts.cwd);
48
+ const firstTurn = opts.firstTurn ?? true;
49
+ const addendum = firstTurn ? `
50
+
51
+ ${FIRST_TURN_ADDENDUM}` : "";
52
+ return `${brief}${addendum}
53
+
54
+ ## Task
55
+
56
+ ${ask}`;
57
+ }
5
58
 
6
59
  // src/prompt/families.ts
7
60
  var FAMILIES = [
@@ -121,6 +174,8 @@ ${coreStandards()}`;
121
174
  }
122
175
 
123
176
  export {
177
+ FIRST_TURN_ADDENDUM,
178
+ composePrompt,
124
179
  FAMILIES,
125
180
  getFamily,
126
181
  renderFamilyBrief
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  findBinary
4
- } from "./chunk-MCYQXOE7.js";
4
+ } from "./chunk-2LRIKWBU.js";
5
5
 
6
6
  // src/preview/chrome.ts
7
7
  import { spawn } from "child_process";
@@ -79,10 +79,11 @@ const PICKER = \`(() => {
79
79
  addEventListener('keydown', (e) => {
80
80
  if (e.altKey && (e.key === 's' || e.key === 'S' || e.code === 'KeyS')) {
81
81
  active = !active;
82
- if (!active) { box.style.display = 'none'; tip.style.display = 'none'; }
83
- else toast('squint picker on \u2014 click an element (esc to cancel)');
82
+ if (!active) { window.__squintFinishNote?.(true); window.__squintCompile?.(); box.style.display = 'none'; tip.style.display = 'none'; }
83
+ else toast('squint picker on \u2014 click elements to pin, alt+enter copies all (esc cancels)');
84
84
  } else if (e.key === 'Escape' && active) {
85
- active = false; box.style.display = 'none'; tip.style.display = 'none';
85
+ active = false; window.__squintFinishNote?.(false); window.__squintClearPins?.();
86
+ box.style.display = 'none'; tip.style.display = 'none';
86
87
  }
87
88
  });
88
89
  addEventListener('mousemove', (e) => {
@@ -97,16 +98,65 @@ const PICKER = \`(() => {
97
98
  tip.style.top = Math.max(r.top - 26, 2) + 'px';
98
99
  tip.textContent = '<' + s.tag + '> ' + rel(s.file);
99
100
  }, true);
101
+ // Multi-pin annotations: each click drops a numbered pin with a note;
102
+ // alt+enter (or toggling the picker off) compiles them into one blob.
103
+ const pins = [];
104
+ const badges = [];
105
+ const noteBox = document.createElement('input');
106
+ noteBox.style.cssText = 'position:fixed;z-index:2147483647;background:#1b1b1b;color:#fff;border:2px solid #e8a33d;border-radius:3px;padding:4px 8px;font:12px ui-monospace,monospace;display:none;width:280px;outline:none;';
107
+ noteBox.placeholder = 'note for this pin \u2014 enter to keep, esc to drop';
108
+ document.documentElement.append(noteBox);
109
+ let pending = null;
110
+ const addBadge = (el, n) => {
111
+ const r = el.getBoundingClientRect();
112
+ const b = document.createElement('div');
113
+ b.textContent = String(n);
114
+ b.style.cssText = 'position:fixed;z-index:2147483647;background:#e8a33d;color:#1b1b1b;font:bold 11px ui-monospace,monospace;border-radius:8px;min-width:16px;height:16px;text-align:center;line-height:16px;pointer-events:none;';
115
+ b.style.left = Math.max(r.left - 6, 2) + 'px';
116
+ b.style.top = Math.max(r.top - 6, 2) + 'px';
117
+ document.documentElement.append(b);
118
+ badges.push(b);
119
+ };
120
+ const clearPins = () => { pins.length = 0; badges.forEach((b) => b.remove()); badges.length = 0; };
121
+ const compile = () => {
122
+ if (pins.length === 0) return;
123
+ const blob = pins.length === 1 && !pins[0].note
124
+ ? pins[0].ref
125
+ : pins.map((p, i) => (i + 1) + '. ' + p.ref + (p.note ? ' \u2014 ' + p.note : '')).join('\\\\n');
126
+ window.__squintLastPick = blob;
127
+ const count = pins.length;
128
+ clearPins();
129
+ const done = () => toast('copied ' + count + ' annotation' + (count === 1 ? '' : 's') + ' \u2014 paste into squint');
130
+ if (navigator.clipboard?.writeText) navigator.clipboard.writeText(blob).then(done, done);
131
+ else done();
132
+ };
133
+ const finishNote = (keep) => {
134
+ if (!pending) return;
135
+ if (keep) { pins.push({ ref: pending.ref, note: noteBox.value.trim() }); addBadge(pending.el, pins.length); }
136
+ pending = null; noteBox.value = ''; noteBox.style.display = 'none';
137
+ };
138
+ noteBox.addEventListener('keydown', (e) => {
139
+ e.stopPropagation();
140
+ if (e.key === 'Enter') { finishNote(true); if (e.altKey) { compile(); active = false; box.style.display = 'none'; } }
141
+ else if (e.key === 'Escape') finishNote(false);
142
+ });
143
+ window.__squintFinishNote = finishNote;
144
+ window.__squintCompile = compile;
145
+ window.__squintClearPins = clearPins;
146
+ addEventListener('keydown', (e) => {
147
+ if (active && e.altKey && e.key === 'Enter') { finishNote(true); compile(); active = false; box.style.display = 'none'; tip.style.display = 'none'; }
148
+ });
100
149
  addEventListener('click', (e) => {
101
- if (!active) return;
150
+ if (!active || e.target === noteBox) return;
102
151
  e.preventDefault(); e.stopPropagation();
152
+ finishNote(true);
103
153
  const el = find(e.target); if (!el) return;
104
- const ref = describe(el);
105
- window.__squintLastPick = ref;
106
- const done = () => toast('copied \u2014 paste into squint: ' + ref);
107
- if (navigator.clipboard?.writeText) navigator.clipboard.writeText(ref).then(done, done);
108
- else done();
109
- active = false; box.style.display = 'none';
154
+ pending = { el, ref: describe(el) };
155
+ const r = el.getBoundingClientRect();
156
+ noteBox.style.display = 'block';
157
+ noteBox.style.left = Math.min(r.left, innerWidth - 310) + 'px';
158
+ noteBox.style.top = Math.min(r.bottom + 4, innerHeight - 34) + 'px';
159
+ noteBox.focus();
110
160
  }, true);
111
161
  })();\`
112
162
  `;
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/prompt/skills.ts
4
+ import fs from "fs";
5
+ import path from "path";
6
+ var FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
7
+ function parseSkill(name, raw) {
8
+ const match = FRONTMATTER_RE.exec(raw);
9
+ if (!match) return null;
10
+ const body = raw.slice(match[0].length).trim();
11
+ const triggersLine = /(^|\n)triggers:[ \t]*(.*)/.exec(match[1]);
12
+ if (!triggersLine) return null;
13
+ let triggers;
14
+ const inline = triggersLine[2].trim();
15
+ if (inline.length > 0) {
16
+ triggers = inline.split(",").map((t) => t.trim().toLowerCase());
17
+ } else {
18
+ triggers = [...match[1].matchAll(/\n\s*-\s+(.+)/g)].map((m) => m[1].trim().toLowerCase());
19
+ }
20
+ triggers = triggers.filter((t) => t.length > 0);
21
+ if (triggers.length === 0 || body.length === 0) return null;
22
+ return { name, triggers, body };
23
+ }
24
+ function loadSkills(cwd) {
25
+ const dir = path.join(cwd, ".squint", "skills");
26
+ let entries;
27
+ try {
28
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
29
+ } catch {
30
+ return [];
31
+ }
32
+ const skills = [];
33
+ for (const entry of entries.sort()) {
34
+ try {
35
+ const skill = parseSkill(entry.replace(/\.md$/, ""), fs.readFileSync(path.join(dir, entry), "utf8"));
36
+ if (skill) skills.push(skill);
37
+ } catch {
38
+ }
39
+ }
40
+ return skills;
41
+ }
42
+ function loadRules(cwd) {
43
+ try {
44
+ const text = fs.readFileSync(path.join(cwd, ".squint", "rules.md"), "utf8").trim();
45
+ return text.length > 0 ? text : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+ function matchSkills(skills, ask) {
51
+ const haystack = ask.toLowerCase();
52
+ return skills.filter((skill) => skill.triggers.some((t) => haystack.includes(t)));
53
+ }
54
+ function enrich(cwd, ask) {
55
+ const parts = [];
56
+ const rules = loadRules(cwd);
57
+ if (rules) parts.push(`## Project rules (always apply)
58
+
59
+ ${rules}`);
60
+ const matched = matchSkills(loadSkills(cwd), ask);
61
+ for (const skill of matched) {
62
+ parts.push(`## Project notes: ${skill.name}
63
+
64
+ ${skill.body}`);
65
+ }
66
+ return {
67
+ sections: parts.length > 0 ? `
68
+
69
+ ${parts.join("\n\n")}` : "",
70
+ matchedSkills: matched.map((s) => s.name)
71
+ };
72
+ }
73
+
74
+ export {
75
+ parseSkill,
76
+ loadSkills,
77
+ loadRules,
78
+ matchSkills,
79
+ enrich
80
+ };
@@ -28,6 +28,22 @@ function takeSnapshot(cwd) {
28
28
  return null;
29
29
  }
30
30
  }
31
+ function diffStatSince(cwd, snapshot) {
32
+ try {
33
+ const source = snapshot.stashHash ?? "HEAD";
34
+ const stat = git(cwd, ["diff", "--shortstat", source]);
35
+ const files = /(\d+) files? changed/.exec(stat);
36
+ if (!files) return null;
37
+ const insertions = /(\d+) insertions?/.exec(stat);
38
+ const deletions = /(\d+) deletions?/.exec(stat);
39
+ let out = `${files[1]} file${files[1] === "1" ? "" : "s"}`;
40
+ if (insertions) out += ` +${insertions[1]}`;
41
+ if (deletions) out += ` \u2212${deletions[1]}`;
42
+ return out;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
31
47
  function restoreSnapshot(cwd, snapshot) {
32
48
  try {
33
49
  const before = new Set(snapshot.untracked);
@@ -50,5 +66,6 @@ function restoreSnapshot(cwd, snapshot) {
50
66
  export {
51
67
  isGitRepo,
52
68
  takeSnapshot,
69
+ diffStatSince,
53
70
  restoreSnapshot
54
71
  };