@ahmedalbarghouti/farq 0.0.2 → 0.1.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.
Files changed (51) hide show
  1. package/README.md +51 -13
  2. package/dist/concurrency.d.ts +2 -0
  3. package/dist/concurrency.js +16 -0
  4. package/dist/config.d.ts +16 -0
  5. package/dist/config.js +25 -0
  6. package/dist/git.d.ts +7 -3
  7. package/dist/git.js +36 -27
  8. package/dist/index.js +116 -33
  9. package/dist/open-pr.d.ts +6 -1
  10. package/dist/open-pr.js +42 -16
  11. package/dist/providers/claude.d.ts +1 -0
  12. package/dist/providers/claude.js +49 -32
  13. package/dist/providers/fake.d.ts +5 -2
  14. package/dist/providers/fake.js +26 -15
  15. package/dist/providers/index.d.ts +1 -1
  16. package/dist/providers/index.js +1 -1
  17. package/dist/render/pr.d.ts +6 -0
  18. package/dist/render/pr.js +29 -7
  19. package/dist/schema.d.ts +40 -7
  20. package/dist/schema.js +14 -1
  21. package/dist/summarize.js +7 -0
  22. package/dist/template.d.ts +6 -0
  23. package/dist/template.js +40 -19
  24. package/dist/ui/index.d.ts +1 -0
  25. package/dist/ui/index.js +1 -0
  26. package/dist/ui/lines.d.ts +19 -1
  27. package/dist/ui/lines.js +81 -39
  28. package/dist/ui/logger.d.ts +9 -2
  29. package/dist/ui/logger.js +15 -4
  30. package/dist/ui/spinner.d.ts +15 -2
  31. package/dist/ui/spinner.js +105 -22
  32. package/dist/ui/theme.d.ts +1 -0
  33. package/dist/ui/theme.js +3 -0
  34. package/dist/visual/chrome.js +21 -2
  35. package/dist/visual/cluster.d.ts +29 -0
  36. package/dist/visual/cluster.js +204 -0
  37. package/dist/visual/compose.d.ts +8 -1
  38. package/dist/visual/compose.js +17 -32
  39. package/dist/visual/css-scope.d.ts +6 -0
  40. package/dist/visual/css-scope.js +162 -0
  41. package/dist/visual/design.d.ts +103 -0
  42. package/dist/visual/design.js +327 -0
  43. package/dist/visual/diagram.d.ts +8 -0
  44. package/dist/visual/diagram.js +36 -14
  45. package/dist/visual/mockup.d.ts +16 -11
  46. package/dist/visual/mockup.js +45 -22
  47. package/dist/visual/pipeline.d.ts +35 -1
  48. package/dist/visual/pipeline.js +191 -69
  49. package/dist/visual/viewport.d.ts +16 -0
  50. package/dist/visual/viewport.js +19 -0
  51. package/package.json +1 -1
package/dist/open-pr.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
  import { execa } from "execa";
5
5
  import { fillPrTemplate, findPrTemplate } from "./template.js";
@@ -104,14 +104,19 @@ export async function openPullRequest(options) {
104
104
  reason: `Already on ${defaultBranch} — skipping PR create`,
105
105
  };
106
106
  }
107
- let imageUrl = null;
107
+ const paths = options.imagePaths && options.imagePaths.length > 0
108
+ ? options.imagePaths
109
+ : options.imagePath
110
+ ? [options.imagePath]
111
+ : [];
112
+ let imageUrls = [];
108
113
  let warning;
109
- if (options.imagePath) {
114
+ if (paths.length > 0) {
110
115
  try {
111
- imageUrl = await uploadPrImage(cwd, options.imagePath, branch);
116
+ imageUrls = await uploadPrImages(cwd, paths, branch);
112
117
  }
113
118
  catch (err) {
114
- warning = `Image upload failed — attach manually: ${options.imagePath} (${err instanceof Error ? err.message : String(err)})`;
119
+ warning = `Image upload failed — attach manually: ${paths.join(", ")} (${err instanceof Error ? err.message : String(err)})`;
115
120
  }
116
121
  }
117
122
  const template = findPrTemplate(cwd);
@@ -119,7 +124,11 @@ export async function openPullRequest(options) {
119
124
  template,
120
125
  summary: options.summary,
121
126
  bodyMarkdown: options.bodyMarkdown,
122
- imageUrl,
127
+ imageUrl: imageUrls[0] ?? null,
128
+ images: imageUrls.map((url, i) => ({
129
+ url,
130
+ title: options.imageTitles?.[i],
131
+ })),
123
132
  });
124
133
  const dir = mkdtempSync(join(tmpdir(), "farq-pr-"));
125
134
  const bodyFile = join(dir, "body.md");
@@ -149,7 +158,8 @@ export async function openPullRequest(options) {
149
158
  warning,
150
159
  title,
151
160
  body,
152
- imageUrl,
161
+ imageUrl: imageUrls[0] ?? null,
162
+ imageUrls,
153
163
  };
154
164
  }
155
165
  const created = await gh(cwd, [
@@ -173,7 +183,8 @@ export async function openPullRequest(options) {
173
183
  warning,
174
184
  title,
175
185
  body,
176
- imageUrl,
186
+ imageUrl: imageUrls[0] ?? null,
187
+ imageUrls,
177
188
  };
178
189
  }
179
190
  catch (err) {
@@ -225,8 +236,18 @@ export async function pruneOrphanedFarqAssets(cwd, keepTag) {
225
236
  }
226
237
  return deleted;
227
238
  }
228
- /** Best-effort: prerelease asset URL for the PNG. */
239
+ /** Best-effort: prerelease asset URL for one PNG. */
229
240
  export async function uploadPrImage(cwd, imagePath, branch) {
241
+ const urls = await uploadPrImages(cwd, [imagePath], branch);
242
+ const url = urls[0];
243
+ if (!url)
244
+ throw new Error("no asset URL returned");
245
+ return url;
246
+ }
247
+ /** Best-effort: upload many PNGs to one prerelease; URLs in input order. */
248
+ export async function uploadPrImages(cwd, imagePaths, branch) {
249
+ if (imagePaths.length === 0)
250
+ return [];
230
251
  const tag = assetTagForBranch(branch);
231
252
  try {
232
253
  await gh(cwd, ["release", "delete", tag, "--yes", "--cleanup-tag"], {
@@ -240,7 +261,7 @@ export async function uploadPrImage(cwd, imagePath, branch) {
240
261
  "release",
241
262
  "create",
242
263
  tag,
243
- imagePath,
264
+ ...imagePaths,
244
265
  "--title",
245
266
  `farq assets (${branch})`,
246
267
  "--notes",
@@ -253,12 +274,17 @@ export async function uploadPrImage(cwd, imagePath, branch) {
253
274
  tag,
254
275
  "--json",
255
276
  "assets",
256
- "--jq",
257
- ".assets[0].url",
258
277
  ]);
259
- const url = stdout.trim();
260
- if (!url)
261
- throw new Error("no asset URL returned");
278
+ const parsed = JSON.parse(stdout);
279
+ const assets = parsed.assets ?? [];
280
+ const urls = imagePaths.map((p) => {
281
+ const name = basename(p);
282
+ const hit = assets.find((a) => a.name === name);
283
+ if (!hit?.url) {
284
+ throw new Error(`no asset URL for ${name}`);
285
+ }
286
+ return hit.url;
287
+ });
262
288
  await pruneOrphanedFarqAssets(cwd, tag);
263
- return url;
289
+ return urls;
264
290
  }
@@ -1,3 +1,4 @@
1
1
  import type { CompleteOptions } from "./fake.js";
2
+ export declare const UNSUPPORTED_FLAG: RegExp;
2
3
  export declare function complete(prompt: string, options?: CompleteOptions): Promise<string>;
3
4
  export declare function isInstalled(): Promise<boolean>;
@@ -1,58 +1,75 @@
1
1
  import { execa } from "execa";
2
2
  const TIMEOUT_MS = 120_000;
3
+ /**
4
+ * farq only ever asks for JSON back, so the user's MCP servers, skills, hooks
5
+ * and project CLAUDE.md are pure startup cost — and a source of drift in the
6
+ * output. Skipping them roughly halves per-call latency.
7
+ */
8
+ const FAST_ARGS = ["--strict-mcp-config", "--setting-sources", ""];
9
+ /** Tried in order; each falls back to the next when the CLI rejects a flag. */
10
+ const ARG_VARIANTS = [
11
+ FAST_ARGS,
12
+ [],
13
+ // Empty allowedTools hangs on some versions, so it stays the last resort.
14
+ ["--allowedTools", ""],
15
+ ];
16
+ export const UNSUPPORTED_FLAG = /unknown option|unknown argument|unrecognized|allowedTools|setting-sources|strict-mcp-config/i;
3
17
  export async function complete(prompt, options = {}) {
4
18
  const baseArgs = ["-p", "--output-format", "json", "--max-turns", "1"];
5
19
  if (options.model)
6
20
  baseArgs.push("--model", options.model);
7
- // Prefer without allowedTools first — empty allowedTools hangs/fails on some versions.
8
- const attempts = [[...baseArgs], [...baseArgs, "--allowedTools", ""]];
9
21
  let lastErr;
10
- for (const args of attempts) {
22
+ for (let i = 0; i < ARG_VARIANTS.length; i++) {
23
+ const canRetry = i < ARG_VARIANTS.length - 1;
24
+ const args = [...baseArgs, ...ARG_VARIANTS[i]];
25
+ let result;
11
26
  try {
12
- const result = await execa("claude", args, {
27
+ result = await execa("claude", args, {
13
28
  input: prompt,
14
29
  timeout: TIMEOUT_MS,
15
30
  reject: false,
16
31
  stdout: "pipe",
17
32
  stderr: "pipe",
18
33
  });
19
- if (result.timedOut) {
20
- throw new Error("claude did not respond — check that it's authenticated (`claude` login)");
21
- }
22
- const text = (result.stdout || result.stderr || "").trim();
23
- if (!text && result.exitCode !== 0) {
24
- throw new Error(`claude failed (exit ${result.exitCode}): ${(result.stderr || "no output").trim()}`);
25
- }
26
- try {
27
- return parseClaudeOutput(text);
28
- }
29
- catch (parseErr) {
30
- // Retry next arg set only when allowedTools looks unsupported
31
- if (args.includes("--allowedTools") &&
32
- /allowedTools|unknown option|unexpected/i.test(`${result.stderr ?? ""} ${text}`)) {
33
- lastErr = parseErr;
34
- continue;
35
- }
36
- throw parseErr;
37
- }
38
34
  }
39
35
  catch (err) {
40
36
  const e = err;
41
- if (e.timedOut || /did not respond/i.test(e.message ?? "")) {
42
- throw new Error("claude did not respond — check that it's authenticated (`claude` login)");
37
+ if (e.timedOut)
38
+ throw notResponding();
39
+ if (canRetry && UNSUPPORTED_FLAG.test(e.message ?? "")) {
40
+ lastErr = err;
41
+ continue;
43
42
  }
44
- lastErr = err;
45
- if (!args.includes("--allowedTools")) {
46
- // try with allowedTools only if first attempt was a flag-related failure
47
- const msg = e.message ?? "";
48
- if (/unknown option|allowedTools/i.test(msg))
49
- continue;
50
- throw err instanceof Error ? err : new Error(String(err));
43
+ throw err instanceof Error ? err : new Error(String(err));
44
+ }
45
+ if (result.timedOut)
46
+ throw notResponding();
47
+ const text = (result.stdout || result.stderr || "").trim();
48
+ const diagnostics = `${result.stderr ?? ""} ${text}`;
49
+ if (!text && result.exitCode !== 0) {
50
+ const err = new Error(`claude failed (exit ${result.exitCode}): ${(result.stderr || "no output").trim()}`);
51
+ if (canRetry && UNSUPPORTED_FLAG.test(diagnostics)) {
52
+ lastErr = err;
53
+ continue;
51
54
  }
55
+ throw err;
56
+ }
57
+ try {
58
+ return parseClaudeOutput(text);
59
+ }
60
+ catch (parseErr) {
61
+ if (canRetry && UNSUPPORTED_FLAG.test(diagnostics)) {
62
+ lastErr = parseErr;
63
+ continue;
64
+ }
65
+ throw parseErr;
52
66
  }
53
67
  }
54
68
  throw lastErr instanceof Error ? lastErr : new Error("claude failed");
55
69
  }
70
+ function notResponding() {
71
+ return new Error("claude did not respond — check that it's authenticated (`claude` login)");
72
+ }
56
73
  function parseClaudeOutput(stdout) {
57
74
  try {
58
75
  const envelope = JSON.parse(stdout);
@@ -1,7 +1,10 @@
1
1
  import type { ChangeSummary } from "../schema.js";
2
2
  export declare const FAKE_SUMMARY: ChangeSummary;
3
- export declare const FAKE_BEFORE_HTML = "<!doctype html><html><body style=\"font-family:system-ui;padding:24px;background:#f6f6f6\">\n<main style=\"max-width:420px;margin:auto;background:#fff;padding:16px;border:1px solid #ddd\">\n <h1 style=\"font-size:18px;margin:0 0 8px\">Order #1042</h1>\n <p style=\"margin:0;color:#444\">Total: $48.00</p>\n</main></body></html>";
4
- export declare const FAKE_AFTER_HTML = "<!doctype html><html><body style=\"font-family:system-ui;padding:24px;background:#f6f6f6\">\n<main style=\"max-width:420px;margin:auto;background:#fff;padding:16px;border:1px solid #ddd\">\n <h1 style=\"font-size:18px;margin:0 0 8px\">Order #1042</h1>\n <p style=\"margin:0 0 8px;color:#444\">Total: $48.00</p>\n <p style=\"margin:0;color:#0a7\">Refund status: pending</p>\n</main></body></html>";
3
+ export declare const FAKE_MOCKUP_CSS = ".page{padding:32px;background:var(--fq-surface)}\n.card{max-width:340px;padding:24px;background:var(--fq-surface-alt);border:1px solid var(--fq-border);border-radius:var(--fq-radius)}\n.card h1{margin:0 0 8px;font-family:var(--fq-font-display);font-size:20px}\n.card p{margin:0 0 8px;color:var(--fq-text-muted)}\n.card p.status{margin:0;color:var(--fq-positive)}";
4
+ export declare const FAKE_BEFORE_BODY = "<div class=\"page\"><div class=\"card\"><h1>Order #1042</h1><p>Total: $48.00</p></div></div>";
5
+ export declare const FAKE_AFTER_BODY = "<div class=\"page\"><div class=\"card\"><h1>Order #1042</h1><p>Total: $48.00</p><p class=\"status\">Refund status: pending</p></div></div>";
6
+ export declare const FAKE_DIAGRAM_CSS = ".cols{display:flex;gap:24px}\n.col{flex:1;padding:16px;border:1px solid var(--fq-border);border-radius:var(--fq-radius)}\n.step{padding:8px 12px;margin-bottom:8px;background:var(--fq-surface-alt);border-radius:var(--fq-radius-sm)}\n.step--new{background:var(--fq-accent-soft);color:var(--fq-accent)}";
7
+ export declare const FAKE_DIAGRAM_BODY = "<div class=\"cols\"><div class=\"col\"><div class=\"step\">Fetch order</div><div class=\"step\">Return payload</div></div><div class=\"col\"><div class=\"step\">Fetch order</div><div class=\"step step--new\">Attach refund status</div><div class=\"step\">Return payload</div></div></div>";
5
8
  export type CompleteOptions = {
6
9
  model?: string;
7
10
  };
@@ -18,25 +18,36 @@ export const FAKE_SUMMARY = {
18
18
  ],
19
19
  breaking_changes: [],
20
20
  visual_notes: "Show a simple status badge on the order card.",
21
+ visual_topics: [{ title: "Refund status on orders", item_indices: [0, 1] }],
21
22
  };
22
- export const FAKE_BEFORE_HTML = `<!doctype html><html><body style="font-family:system-ui;padding:24px;background:#f6f6f6">
23
- <main style="max-width:420px;margin:auto;background:#fff;padding:16px;border:1px solid #ddd">
24
- <h1 style="font-size:18px;margin:0 0 8px">Order #1042</h1>
25
- <p style="margin:0;color:#444">Total: $48.00</p>
26
- </main></body></html>`;
27
- export const FAKE_AFTER_HTML = `<!doctype html><html><body style="font-family:system-ui;padding:24px;background:#f6f6f6">
28
- <main style="max-width:420px;margin:auto;background:#fff;padding:16px;border:1px solid #ddd">
29
- <h1 style="font-size:18px;margin:0 0 8px">Order #1042</h1>
30
- <p style="margin:0 0 8px;color:#444">Total: $48.00</p>
31
- <p style="margin:0;color:#0a7">Refund status: pending</p>
32
- </main></body></html>`;
23
+ export const FAKE_MOCKUP_CSS = `.page{padding:32px;background:var(--fq-surface)}
24
+ .card{max-width:340px;padding:24px;background:var(--fq-surface-alt);border:1px solid var(--fq-border);border-radius:var(--fq-radius)}
25
+ .card h1{margin:0 0 8px;font-family:var(--fq-font-display);font-size:20px}
26
+ .card p{margin:0 0 8px;color:var(--fq-text-muted)}
27
+ .card p.status{margin:0;color:var(--fq-positive)}`;
28
+ export const FAKE_BEFORE_BODY = `<div class="page"><div class="card"><h1>Order #1042</h1><p>Total: $48.00</p></div></div>`;
29
+ export const FAKE_AFTER_BODY = `<div class="page"><div class="card"><h1>Order #1042</h1><p>Total: $48.00</p><p class="status">Refund status: pending</p></div></div>`;
30
+ export const FAKE_DIAGRAM_CSS = `.cols{display:flex;gap:24px}
31
+ .col{flex:1;padding:16px;border:1px solid var(--fq-border);border-radius:var(--fq-radius)}
32
+ .step{padding:8px 12px;margin-bottom:8px;background:var(--fq-surface-alt);border-radius:var(--fq-radius-sm)}
33
+ .step--new{background:var(--fq-accent-soft);color:var(--fq-accent)}`;
34
+ export const FAKE_DIAGRAM_BODY = `<div class="cols"><div class="col"><div class="step">Fetch order</div><div class="step">Return payload</div></div><div class="col"><div class="step">Fetch order</div><div class="step step--new">Attach refund status</div><div class="step">Return payload</div></div></div>`;
33
35
  export async function complete(prompt, _options = {}) {
34
- if (prompt.includes("before_html") || prompt.includes("feasible")) {
36
+ if (prompt.includes("before_body")) {
35
37
  return JSON.stringify({
36
38
  feasible: true,
37
- before_html: FAKE_BEFORE_HTML,
38
- after_html: FAKE_AFTER_HTML,
39
- viewport: { width: 900, height: 700 },
39
+ css: FAKE_MOCKUP_CSS,
40
+ before_body: FAKE_BEFORE_BODY,
41
+ after_body: FAKE_AFTER_BODY,
42
+ stage_width: 620,
43
+ });
44
+ }
45
+ if (prompt.includes("flowchart")) {
46
+ return JSON.stringify({
47
+ feasible: true,
48
+ css: FAKE_DIAGRAM_CSS,
49
+ body: FAKE_DIAGRAM_BODY,
50
+ stage_width: 900,
40
51
  });
41
52
  }
42
53
  return JSON.stringify(FAKE_SUMMARY);
@@ -19,4 +19,4 @@ export declare function detectProviders(): Promise<{
19
19
  }>;
20
20
  export declare function resolveProvider(options?: ResolveProviderOptions): Promise<Provider>;
21
21
  export declare function getProvider(name: ProviderName): Provider;
22
- export { FAKE_SUMMARY, FAKE_BEFORE_HTML, FAKE_AFTER_HTML } from "./fake.js";
22
+ export { FAKE_SUMMARY, FAKE_MOCKUP_CSS, FAKE_BEFORE_BODY, FAKE_AFTER_BODY, FAKE_DIAGRAM_CSS, FAKE_DIAGRAM_BODY, } from "./fake.js";
@@ -37,4 +37,4 @@ export function getProvider(name) {
37
37
  return { name, complete: fake.complete };
38
38
  }
39
39
  }
40
- export { FAKE_SUMMARY, FAKE_BEFORE_HTML, FAKE_AFTER_HTML } from "./fake.js";
40
+ export { FAKE_SUMMARY, FAKE_MOCKUP_CSS, FAKE_BEFORE_BODY, FAKE_AFTER_BODY, FAKE_DIAGRAM_CSS, FAKE_DIAGRAM_BODY, } from "./fake.js";
@@ -1,7 +1,13 @@
1
1
  import type { ChangeSummary } from "../schema.js";
2
+ export type PrImage = {
3
+ path: string;
4
+ title?: string;
5
+ };
2
6
  export type RenderPrOptions = {
3
7
  summary: ChangeSummary;
8
+ /** @deprecated prefer images */
4
9
  imagePath?: string | null;
5
10
  imageAlt?: string;
11
+ images?: PrImage[];
6
12
  };
7
13
  export declare function renderPr(options: RenderPrOptions): string;
package/dist/render/pr.js CHANGED
@@ -18,13 +18,27 @@ export function renderPr(options) {
18
18
  out.push("");
19
19
  out.push(overflow);
20
20
  }
21
- if (options.imagePath) {
22
- out.push("");
23
- out.push("### Before / After");
24
- out.push("");
25
- const alt = options.imageAlt ?? "before / after";
26
- out.push(`![${alt}](${options.imagePath})`);
27
- if (!isHostedImageUrl(options.imagePath)) {
21
+ const images = normalizeImages(options);
22
+ if (images.length > 0) {
23
+ const anyLocal = images.some((img) => !isHostedImageUrl(img.path));
24
+ if (images.length === 1) {
25
+ out.push("");
26
+ out.push("### Before / After");
27
+ out.push("");
28
+ const alt = images[0].title ?? options.imageAlt ?? "before / after";
29
+ out.push(`![${alt}](${images[0].path})`);
30
+ }
31
+ else {
32
+ out.push("");
33
+ out.push("### Visuals");
34
+ for (const img of images) {
35
+ out.push("");
36
+ out.push(`#### ${img.title ?? "Before / After"}`);
37
+ out.push("");
38
+ out.push(`![${img.title ?? "before / after"}](${img.path})`);
39
+ }
40
+ }
41
+ if (anyLocal) {
28
42
  out.push("");
29
43
  out.push(LOCAL_IMAGE_NOTE);
30
44
  }
@@ -49,3 +63,11 @@ export function renderPr(options) {
49
63
  }
50
64
  return `${title}\n\n${out.join("\n").trim()}\n`;
51
65
  }
66
+ function normalizeImages(options) {
67
+ if (options.images && options.images.length > 0)
68
+ return options.images;
69
+ if (options.imagePath) {
70
+ return [{ path: options.imagePath, title: options.imageAlt }];
71
+ }
72
+ return [];
73
+ }
package/dist/schema.d.ts CHANGED
@@ -7,18 +7,32 @@ export declare const ChangeItemSchema: z.ZodObject<{
7
7
  why_it_matters: z.ZodOptional<z.ZodString>;
8
8
  files: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
9
9
  }, "strip", z.ZodTypeAny, {
10
+ description: string;
10
11
  files: string[];
11
12
  category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
12
13
  title: string;
13
- description: string;
14
14
  why_it_matters?: string | undefined;
15
15
  }, {
16
+ description: string;
16
17
  category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
17
18
  title: string;
18
- description: string;
19
19
  files?: string[] | undefined;
20
20
  why_it_matters?: string | undefined;
21
21
  }>;
22
+ /**
23
+ * Grouping of items into visual topics, returned by the same call that writes
24
+ * the summary so the visual pipeline does not need its own round-trip.
25
+ */
26
+ export declare const VisualTopicSchema: z.ZodObject<{
27
+ title: z.ZodString;
28
+ item_indices: z.ZodArray<z.ZodNumber, "many">;
29
+ }, "strip", z.ZodTypeAny, {
30
+ title: string;
31
+ item_indices: number[];
32
+ }, {
33
+ title: string;
34
+ item_indices: number[];
35
+ }>;
22
36
  export declare const ChangeSummarySchema: z.ZodObject<{
23
37
  headline: z.ZodString;
24
38
  overview: z.ZodString;
@@ -29,46 +43,65 @@ export declare const ChangeSummarySchema: z.ZodObject<{
29
43
  why_it_matters: z.ZodOptional<z.ZodString>;
30
44
  files: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
31
45
  }, "strip", z.ZodTypeAny, {
46
+ description: string;
32
47
  files: string[];
33
48
  category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
34
49
  title: string;
35
- description: string;
36
50
  why_it_matters?: string | undefined;
37
51
  }, {
52
+ description: string;
38
53
  category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
39
54
  title: string;
40
- description: string;
41
55
  files?: string[] | undefined;
42
56
  why_it_matters?: string | undefined;
43
57
  }>, "many">;
44
58
  breaking_changes: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
45
59
  visual_notes: z.ZodOptional<z.ZodString>;
60
+ visual_topics: z.ZodOptional<z.ZodArray<z.ZodObject<{
61
+ title: z.ZodString;
62
+ item_indices: z.ZodArray<z.ZodNumber, "many">;
63
+ }, "strip", z.ZodTypeAny, {
64
+ title: string;
65
+ item_indices: number[];
66
+ }, {
67
+ title: string;
68
+ item_indices: number[];
69
+ }>, "many">>;
46
70
  }, "strip", z.ZodTypeAny, {
47
71
  headline: string;
48
72
  overview: string;
49
73
  items: {
74
+ description: string;
50
75
  files: string[];
51
76
  category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
52
77
  title: string;
53
- description: string;
54
78
  why_it_matters?: string | undefined;
55
79
  }[];
56
80
  breaking_changes: string[];
57
81
  visual_notes?: string | undefined;
82
+ visual_topics?: {
83
+ title: string;
84
+ item_indices: number[];
85
+ }[] | undefined;
58
86
  }, {
59
87
  headline: string;
60
88
  overview: string;
61
89
  items: {
90
+ description: string;
62
91
  category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
63
92
  title: string;
64
- description: string;
65
93
  files?: string[] | undefined;
66
94
  why_it_matters?: string | undefined;
67
95
  }[];
68
96
  breaking_changes?: string[] | undefined;
69
97
  visual_notes?: string | undefined;
98
+ visual_topics?: {
99
+ title: string;
100
+ item_indices: number[];
101
+ }[] | undefined;
70
102
  }>;
71
103
  export type ChangeSummary = z.infer<typeof ChangeSummarySchema>;
72
104
  export type ChangeItem = z.infer<typeof ChangeItemSchema>;
105
+ export type VisualTopicHint = z.infer<typeof VisualTopicSchema>;
73
106
  /** Compact schema description embedded in AI prompts. */
74
- export declare const CHANGE_SUMMARY_SCHEMA_PROMPT = "{\n \"headline\": \"string <=140 \u2014 one-line summary (PR title)\",\n \"overview\": \"string \u2014 1-3 sentences in requested tone\",\n \"items\": [{\n \"category\": \"feature|fix|improvement|refactor|chore|docs|perf|security\",\n \"title\": \"string <=120\",\n \"description\": \"string\",\n \"why_it_matters\": \"string (optional; required for client tone)\",\n \"files\": [\"string\"]\n }],\n \"breaking_changes\": [\"string\"],\n \"visual_notes\": \"string (optional)\"\n}";
107
+ export declare const CHANGE_SUMMARY_SCHEMA_PROMPT = "{\n \"headline\": \"string <=140 \u2014 one-line summary (PR title)\",\n \"overview\": \"string \u2014 1-3 sentences in requested tone\",\n \"items\": [{\n \"category\": \"feature|fix|improvement|refactor|chore|docs|perf|security\",\n \"title\": \"string <=120\",\n \"description\": \"string\",\n \"why_it_matters\": \"string (optional; required for client tone)\",\n \"files\": [\"string\"]\n }],\n \"breaking_changes\": [\"string\"],\n \"visual_notes\": \"string (optional)\",\n \"visual_topics\": [{\n \"title\": \"string <=80 \u2014 human topic name\",\n \"item_indices\": [0]\n }]\n}";
package/dist/schema.js CHANGED
@@ -16,12 +16,21 @@ export const ChangeItemSchema = z.object({
16
16
  why_it_matters: z.string().min(1).optional(),
17
17
  files: z.array(z.string()).default([]),
18
18
  });
19
+ /**
20
+ * Grouping of items into visual topics, returned by the same call that writes
21
+ * the summary so the visual pipeline does not need its own round-trip.
22
+ */
23
+ export const VisualTopicSchema = z.object({
24
+ title: z.string().min(1).max(80),
25
+ item_indices: z.array(z.number().int().nonnegative()).min(1),
26
+ });
19
27
  export const ChangeSummarySchema = z.object({
20
28
  headline: z.string().min(1).max(140),
21
29
  overview: z.string().min(1),
22
30
  items: z.array(ChangeItemSchema).min(1),
23
31
  breaking_changes: z.array(z.string()).default([]),
24
32
  visual_notes: z.string().min(1).optional(),
33
+ visual_topics: z.array(VisualTopicSchema).optional(),
25
34
  });
26
35
  /** Compact schema description embedded in AI prompts. */
27
36
  export const CHANGE_SUMMARY_SCHEMA_PROMPT = `{
@@ -35,5 +44,9 @@ export const CHANGE_SUMMARY_SCHEMA_PROMPT = `{
35
44
  "files": ["string"]
36
45
  }],
37
46
  "breaking_changes": ["string"],
38
- "visual_notes": "string (optional)"
47
+ "visual_notes": "string (optional)",
48
+ "visual_topics": [{
49
+ "title": "string <=80 — human topic name",
50
+ "item_indices": [0]
51
+ }]
39
52
  }`;
package/dist/summarize.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { extractJson } from "./extract-json.js";
2
2
  import { CHANGE_SUMMARY_SCHEMA_PROMPT, ChangeSummarySchema, } from "./schema.js";
3
+ import { MAX_VISUAL_TOPICS } from "./visual/cluster.js";
3
4
  export class SummarizeError extends Error {
4
5
  exitCode = 2;
5
6
  constructor(message) {
@@ -59,6 +60,12 @@ Rules:
59
60
  - Report only what the diff shows. Do not invent work.
60
61
  - ${toneRules}
61
62
  ${convention}
63
+ visual_topics — how the items get grouped into before/after visuals:
64
+ - Every item index must appear in exactly one topic. Use 0-based indices into your own items array.
65
+ - If all items advance the SAME feature or story, return exactly ONE topic containing every index, even across many files, tests, docs and plumbing.
66
+ - Only split when a reviewer would call them separate product changes (say, a UI redesign AND an unrelated billing API).
67
+ - At most ${MAX_VISUAL_TOPICS} topics. Fewer is better; one is right when unsure.
68
+
62
69
  Schema:
63
70
  ${CHANGE_SUMMARY_SCHEMA_PROMPT}
64
71
 
@@ -1,10 +1,16 @@
1
1
  import type { ChangeSummary } from "./schema.js";
2
2
  export declare function findPrTemplate(cwd: string): string | null;
3
+ export type TemplateImage = {
4
+ url: string;
5
+ title?: string;
6
+ };
3
7
  export declare function fillPrTemplate(options: {
4
8
  template: string | null;
5
9
  summary: ChangeSummary;
6
10
  bodyMarkdown: string;
11
+ /** @deprecated prefer images */
7
12
  imageUrl?: string | null;
13
+ images?: TemplateImage[];
8
14
  }): {
9
15
  title: string;
10
16
  body: string;
package/dist/template.js CHANGED
@@ -19,23 +19,16 @@ export function findPrTemplate(cwd) {
19
19
  }
20
20
  export function fillPrTemplate(options) {
21
21
  const { title, overflow } = truncateTitle(options.summary.headline, GITHUB_TITLE_MAX);
22
+ const images = normalizeTemplateImages(options);
22
23
  let body = options.template?.trim() || "";
23
24
  if (!body) {
24
25
  body = options.bodyMarkdown;
25
26
  if (overflow)
26
27
  body = overflow + "\n\n" + body;
27
- if (options.imageUrl) {
28
- // Swap any non-http(s) image target (cache path, .farq/, etc.).
29
- const re = /!\[([^\]]*)\]\((?!https?:\/\/)[^)]+\)/;
30
- if (re.test(body)) {
31
- body = body.replace(re, "![$1](" + options.imageUrl + ")");
32
- }
33
- else {
34
- body =
35
- "### Before / After\n\n![before / after](" +
36
- options.imageUrl +
37
- ")\n\n" +
38
- body;
28
+ if (images.length > 0) {
29
+ body = replaceLocalImages(body, images);
30
+ if (!hasMarkdownImage(body)) {
31
+ body = renderImageMarkdown(images) + "\n\n" + body;
39
32
  }
40
33
  body = stripLocalImageNote(body);
41
34
  }
@@ -51,20 +44,48 @@ export function fillPrTemplate(options) {
51
44
  body = fillSection(body, /test\s*plan|testing|how to test/i, testPlan);
52
45
  if (overflow)
53
46
  body = overflow + "\n\n" + body;
54
- if (options.imageUrl) {
55
- if (/before\s*\/\s*after|screenshots?/i.test(body)) {
56
- body = fillSection(body, /before\s*\/\s*after|screenshots?/i, "![before / after](" + options.imageUrl + ")");
47
+ if (images.length > 0) {
48
+ const md = renderImageMarkdown(images);
49
+ if (/before\s*\/\s*after|screenshots?|visuals?/i.test(body)) {
50
+ body = fillSection(body, /before\s*\/\s*after|screenshots?|visuals?/i, md.replace(/^###[^\n]+\n+/, "").trim());
57
51
  }
58
52
  else {
59
- body +=
60
- "\n\n### Before / After\n\n![before / after](" +
61
- options.imageUrl +
62
- ")\n";
53
+ body += "\n\n" + md + "\n";
63
54
  }
64
55
  body = stripLocalImageNote(body);
65
56
  }
66
57
  return { title, body };
67
58
  }
59
+ function normalizeTemplateImages(options) {
60
+ if (options.images && options.images.length > 0)
61
+ return options.images;
62
+ if (options.imageUrl)
63
+ return [{ url: options.imageUrl }];
64
+ return [];
65
+ }
66
+ function hasMarkdownImage(body) {
67
+ return /!\[[^\]]*\]\([^)]+\)/.test(body);
68
+ }
69
+ function replaceLocalImages(body, images) {
70
+ let i = 0;
71
+ return body.replace(/!\[([^\]]*)\]\((?!https?:\/\/)[^)]+\)/g, (_m, alt) => {
72
+ const img = images[Math.min(i, images.length - 1)];
73
+ i += 1;
74
+ return `![${alt || img.title || "before / after"}](${img.url})`;
75
+ });
76
+ }
77
+ function renderImageMarkdown(images) {
78
+ if (images.length === 1) {
79
+ const alt = images[0].title ?? "before / after";
80
+ return `### Before / After\n\n![${alt}](${images[0].url})`;
81
+ }
82
+ const parts = ["### Visuals"];
83
+ for (const img of images) {
84
+ const alt = img.title ?? "before / after";
85
+ parts.push("", `#### ${alt}`, "", `![${alt}](${img.url})`);
86
+ }
87
+ return parts.join("\n");
88
+ }
68
89
  function fillSection(template, heading, content) {
69
90
  const lines = template.split("\n");
70
91
  let inSection = false;
@@ -1,2 +1,3 @@
1
1
  export { createUi, type Ui } from "./logger.js";
2
+ export { labelFor, linesFor, type LineBank } from "./lines.js";
2
3
  export { brand, isInteractive } from "./theme.js";
package/dist/ui/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { createUi } from "./logger.js";
2
+ export { labelFor, linesFor } from "./lines.js";
2
3
  export { brand, isInteractive } from "./theme.js";