@cam5/baby-bird 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.
@@ -0,0 +1,1753 @@
1
+ // src/core/schema.ts
2
+ import { z } from "zod";
3
+ var LlmExcerptRefSchema = z.object({
4
+ hunk: z.string().min(1),
5
+ /** Optional [start, end] range of new-file line numbers to narrow the hunk. */
6
+ lines: z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative()]).optional(),
7
+ note: z.string().optional()
8
+ });
9
+ var LlmSectionSchema = z.object({
10
+ title: z.string().min(1),
11
+ description: z.string(),
12
+ files: z.array(z.string()).optional(),
13
+ excerpts: z.array(LlmExcerptRefSchema).optional()
14
+ });
15
+ var LlmTourOutputSchema = z.object({
16
+ title: z.string().min(1),
17
+ summary: z.string(),
18
+ sections: z.array(LlmSectionSchema).min(1)
19
+ });
20
+ var HunkLineSchema = z.object({
21
+ type: z.enum(["add", "del", "ctx"]),
22
+ oldNo: z.number().int().optional(),
23
+ newNo: z.number().int().optional(),
24
+ text: z.string()
25
+ });
26
+ var StatsSchema = z.object({
27
+ files: z.number().int(),
28
+ additions: z.number().int(),
29
+ deletions: z.number().int()
30
+ });
31
+ var TourSourceSchema = z.discriminatedUnion("kind", [
32
+ z.object({
33
+ kind: z.literal("range"),
34
+ base: z.string(),
35
+ head: z.string(),
36
+ baseSha: z.string(),
37
+ headSha: z.string(),
38
+ mergeBase: z.string().optional(),
39
+ resolvedBy: z.enum(["explicit", "pull-request", "ancestor-branch", "default-branch"])
40
+ }),
41
+ z.object({
42
+ kind: z.literal("working"),
43
+ headSha: z.string(),
44
+ staged: z.boolean(),
45
+ resolvedBy: z.enum(["explicit", "dirty-tree"])
46
+ })
47
+ ]);
48
+ var TourSchema = z.object({
49
+ version: z.literal(1),
50
+ generatedAt: z.string(),
51
+ source: TourSourceSchema,
52
+ generator: z.object({ preset: z.string().nullable(), command: z.array(z.string()) }),
53
+ pullRequest: z.object({ number: z.number().int(), title: z.string(), url: z.string() }).optional(),
54
+ title: z.string(),
55
+ summary: z.string(),
56
+ stats: StatsSchema,
57
+ sections: z.array(
58
+ z.object({
59
+ id: z.string(),
60
+ title: z.string(),
61
+ description: z.string(),
62
+ files: z.array(z.string()),
63
+ stats: StatsSchema,
64
+ excerpts: z.array(
65
+ z.object({
66
+ file: z.string(),
67
+ hunkId: z.string(),
68
+ note: z.string().optional(),
69
+ oldStart: z.number().int(),
70
+ newStart: z.number().int(),
71
+ lines: z.array(HunkLineSchema)
72
+ })
73
+ )
74
+ })
75
+ )
76
+ });
77
+ function formatIssues(error) {
78
+ return error.issues.map((issue) => {
79
+ const path = issue.path.length ? issue.path.map(String).join(".") : "(root)";
80
+ return `${path}: ${issue.message}`;
81
+ }).join("; ");
82
+ }
83
+
84
+ // src/core/errors.ts
85
+ var BbError = class extends Error {
86
+ exitCode;
87
+ hint;
88
+ constructor(message, opts = {}) {
89
+ super(message, opts.cause === void 0 ? void 0 : { cause: opts.cause });
90
+ this.name = new.target.name;
91
+ this.exitCode = opts.exitCode ?? 1;
92
+ this.hint = opts.hint;
93
+ }
94
+ };
95
+ var UsageError = class extends BbError {
96
+ constructor(message, hint) {
97
+ super(message, { exitCode: 2, hint });
98
+ }
99
+ };
100
+ var ConfigError = class extends BbError {
101
+ constructor(message, hint) {
102
+ super(message, { exitCode: 2, hint });
103
+ }
104
+ };
105
+ var NotARepoError = class extends BbError {
106
+ constructor(cwd) {
107
+ super(`Not a git repository: ${cwd}`, { exitCode: 3, hint: "Run bb inside a git repository, or pass --cwd <dir>." });
108
+ }
109
+ };
110
+ var GitError = class extends BbError {
111
+ constructor(message, opts = {}) {
112
+ super(message, { exitCode: 3, ...opts });
113
+ }
114
+ };
115
+ var NoChangesError = class extends BbError {
116
+ constructor(message, hint) {
117
+ super(message, { exitCode: 3, hint });
118
+ }
119
+ };
120
+ var LlmFailedError = class extends BbError {
121
+ constructor(message, opts = {}) {
122
+ super(message, { exitCode: 4, ...opts });
123
+ }
124
+ };
125
+ var BadLlmOutputError = class extends BbError {
126
+ raw;
127
+ constructor(message, raw, hint) {
128
+ super(message, { exitCode: 5, hint });
129
+ this.raw = raw;
130
+ }
131
+ };
132
+
133
+ // src/core/config.ts
134
+ import { readFile } from "fs/promises";
135
+ import { homedir } from "os";
136
+ import { join } from "path";
137
+ import { z as z2 } from "zod";
138
+ var CLAUDE_BASE = ["claude", "-p", "--no-session-persistence", "--setting-sources", "", "--tools", ""];
139
+ var BUILTIN_PRESETS = Object.freeze({
140
+ claude: {
141
+ command: [...CLAUDE_BASE],
142
+ description: "Claude Code CLI with its default model"
143
+ },
144
+ "claude-sonnet": {
145
+ command: [...CLAUDE_BASE, "--model", "sonnet", "--effort", "high"],
146
+ description: "Claude Code CLI, Sonnet at high effort"
147
+ },
148
+ "claude-opus": {
149
+ command: [...CLAUDE_BASE, "--model", "opus", "--effort", "high"],
150
+ description: "Claude Code CLI, Opus at high effort"
151
+ },
152
+ "claude-fable": {
153
+ command: [...CLAUDE_BASE, "--model", "fable", "--effort", "high"],
154
+ description: "Claude Code CLI, Fable at high effort"
155
+ },
156
+ "claude-haiku": {
157
+ command: [...CLAUDE_BASE, "--model", "haiku"],
158
+ description: "Claude Code CLI, Haiku (fast and cheap)"
159
+ },
160
+ llm: {
161
+ command: ["llm"],
162
+ description: "Simon Willison's llm CLI with its default model"
163
+ }
164
+ });
165
+ var PromptViaSchema = z2.enum(["stdin", "arg"]);
166
+ var LlmPresetSchema = z2.object({
167
+ command: z2.array(z2.string()).min(1),
168
+ promptVia: PromptViaSchema.optional(),
169
+ description: z2.string().optional()
170
+ });
171
+ var ConfigSchema = z2.object({
172
+ llm: z2.object({
173
+ preset: z2.string().min(1),
174
+ presets: z2.record(z2.string(), LlmPresetSchema),
175
+ args: z2.array(z2.string()),
176
+ command: z2.array(z2.string()).min(1).nullable(),
177
+ promptVia: PromptViaSchema.nullable(),
178
+ timeoutMs: z2.number().int().positive(),
179
+ maxPromptBytes: z2.number().int().positive(),
180
+ env: z2.record(z2.string(), z2.string())
181
+ }),
182
+ codehost: z2.object({
183
+ provider: z2.enum(["gh", "none"])
184
+ }),
185
+ git: z2.object({
186
+ defaultBranch: z2.string().nullable(),
187
+ exclude: z2.array(z2.string())
188
+ }),
189
+ render: z2.object({
190
+ color: z2.enum(["auto", "always", "never"]),
191
+ pager: z2.enum(["auto", "always", "never"]),
192
+ maxExcerptLines: z2.number().int().positive(),
193
+ width: z2.number().int().positive().nullable()
194
+ }),
195
+ cache: z2.object({
196
+ enabled: z2.boolean(),
197
+ dir: z2.string().nullable()
198
+ })
199
+ });
200
+ var PartialConfigSchema = z2.object({
201
+ llm: ConfigSchema.shape.llm.partial().optional(),
202
+ codehost: ConfigSchema.shape.codehost.partial().optional(),
203
+ git: ConfigSchema.shape.git.partial().optional(),
204
+ render: ConfigSchema.shape.render.partial().optional(),
205
+ cache: ConfigSchema.shape.cache.partial().optional()
206
+ });
207
+ var DEFAULT_CONFIG = {
208
+ llm: {
209
+ preset: "claude",
210
+ presets: {},
211
+ args: [],
212
+ command: null,
213
+ promptVia: null,
214
+ timeoutMs: 18e4,
215
+ maxPromptBytes: 2e5,
216
+ env: {}
217
+ },
218
+ codehost: { provider: "gh" },
219
+ git: {
220
+ defaultBranch: null,
221
+ exclude: [
222
+ "**/pnpm-lock.yaml",
223
+ "**/package-lock.json",
224
+ "**/yarn.lock",
225
+ "**/Cargo.lock",
226
+ "**/*.min.*",
227
+ "**/dist/**",
228
+ "**/*.snap",
229
+ "**/*.map"
230
+ ]
231
+ },
232
+ render: { color: "auto", pager: "auto", maxExcerptLines: 60, width: null },
233
+ cache: { enabled: true, dir: null }
234
+ };
235
+ function userConfigPath(env = process.env) {
236
+ const base = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.trim() !== "" ? env.XDG_CONFIG_HOME : join(homedir(), ".config");
237
+ return join(base, "baby-bird", "config.json");
238
+ }
239
+ function defaultCacheDir(env = process.env) {
240
+ const base = env.XDG_CACHE_HOME && env.XDG_CACHE_HOME.trim() !== "" ? env.XDG_CACHE_HOME : join(homedir(), ".cache");
241
+ return join(base, "baby-bird");
242
+ }
243
+ function projectConfigPath(gitRoot2) {
244
+ return join(gitRoot2, ".baby-bird", "config.json");
245
+ }
246
+ function isPlainObject(value) {
247
+ return typeof value === "object" && value !== null && !Array.isArray(value);
248
+ }
249
+ function deepMerge(base, patch) {
250
+ if (!isPlainObject(base) || !isPlainObject(patch)) {
251
+ return patch === void 0 ? base : patch;
252
+ }
253
+ const out = { ...base };
254
+ for (const [key, value] of Object.entries(patch)) {
255
+ if (value === void 0) continue;
256
+ const existing = out[key];
257
+ out[key] = isPlainObject(existing) && isPlainObject(value) ? deepMerge(existing, value) : value;
258
+ }
259
+ return out;
260
+ }
261
+ async function readFileLayer(name, path) {
262
+ let text;
263
+ try {
264
+ text = await readFile(path, "utf8");
265
+ } catch (err) {
266
+ if (err.code === "ENOENT") {
267
+ return { name, path, found: false, data: {} };
268
+ }
269
+ throw new ConfigError(`Could not read ${path}: ${err.message}`);
270
+ }
271
+ let json;
272
+ try {
273
+ json = JSON.parse(text);
274
+ } catch (err) {
275
+ throw new ConfigError(`Invalid JSON in ${path}: ${err.message}`);
276
+ }
277
+ const parsed = PartialConfigSchema.safeParse(json);
278
+ if (!parsed.success) {
279
+ throw new ConfigError(`Invalid config in ${path}: ${formatIssues(parsed.error)}`);
280
+ }
281
+ return { name, path, found: true, data: parsed.data };
282
+ }
283
+ var TRUTHY = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
284
+ function envLayer(env) {
285
+ const data = {};
286
+ const applied = [];
287
+ const llm = {};
288
+ if (env.BB_PRESET) {
289
+ llm.preset = env.BB_PRESET;
290
+ applied.push("BB_PRESET");
291
+ }
292
+ if (env.BB_LLM_COMMAND) {
293
+ const argv = shellSplit(env.BB_LLM_COMMAND);
294
+ if (argv.length === 0) throw new ConfigError("BB_LLM_COMMAND is set but empty");
295
+ llm.command = argv;
296
+ applied.push("BB_LLM_COMMAND");
297
+ }
298
+ if (Object.keys(llm).length) data.llm = llm;
299
+ if (env.BB_CODEHOST) {
300
+ const provider = env.BB_CODEHOST;
301
+ data.codehost = { provider };
302
+ applied.push("BB_CODEHOST");
303
+ }
304
+ const cache = {};
305
+ if (env.BB_CACHE_DIR) {
306
+ cache.dir = env.BB_CACHE_DIR;
307
+ applied.push("BB_CACHE_DIR");
308
+ }
309
+ if (env.BB_NO_CACHE !== void 0 && TRUTHY.has(env.BB_NO_CACHE.toLowerCase())) {
310
+ cache.enabled = false;
311
+ applied.push("BB_NO_CACHE");
312
+ }
313
+ if (Object.keys(cache).length) data.cache = cache;
314
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") {
315
+ data.render = { color: "never" };
316
+ applied.push("NO_COLOR");
317
+ }
318
+ return { name: "env", found: applied.length > 0, detail: applied.join(", "), data };
319
+ }
320
+ async function loadConfig(opts = {}) {
321
+ const env = opts.env ?? process.env;
322
+ const layers = [{ name: "defaults", found: true, data: DEFAULT_CONFIG }];
323
+ layers.push(await readFileLayer("user", userConfigPath(env)));
324
+ if (opts.gitRoot) {
325
+ layers.push(await readFileLayer("project", projectConfigPath(opts.gitRoot)));
326
+ }
327
+ layers.push(envLayer(env));
328
+ if (opts.overrides) {
329
+ const found = Object.keys(opts.overrides).length > 0;
330
+ layers.push({ name: "flags", found, data: opts.overrides });
331
+ }
332
+ let merged = {};
333
+ for (const layer of layers) merged = deepMerge(merged, layer.data);
334
+ const parsed = ConfigSchema.safeParse(merged);
335
+ if (!parsed.success) {
336
+ throw new ConfigError(`Invalid configuration: ${formatIssues(parsed.error)}`);
337
+ }
338
+ const config = parsed.data;
339
+ return { config, layers, cacheDir: config.cache.dir ?? defaultCacheDir(env) };
340
+ }
341
+ function allPresets(config) {
342
+ return { ...BUILTIN_PRESETS, ...config.llm.presets };
343
+ }
344
+ function resolveLlm(config) {
345
+ const { llm } = config;
346
+ const common = { timeoutMs: llm.timeoutMs, maxPromptBytes: llm.maxPromptBytes, env: llm.env };
347
+ if (llm.command) {
348
+ return { command: [...llm.command, ...llm.args], promptVia: llm.promptVia ?? "stdin", preset: null, ...common };
349
+ }
350
+ const presets = allPresets(config);
351
+ const preset = presets[llm.preset];
352
+ if (!preset) {
353
+ const names = Object.keys(presets).sort().join(", ");
354
+ throw new ConfigError(`Unknown LLM preset "${llm.preset}"`, `Available presets: ${names}. Define your own under llm.presets, or set llm.command.`);
355
+ }
356
+ return {
357
+ command: [...preset.command, ...llm.args],
358
+ promptVia: llm.promptVia ?? preset.promptVia ?? "stdin",
359
+ preset: llm.preset,
360
+ ...common
361
+ };
362
+ }
363
+ function shellSplit(input) {
364
+ const out = [];
365
+ let cur = "";
366
+ let inToken = false;
367
+ let quote = null;
368
+ for (let i = 0; i < input.length; i++) {
369
+ const ch = input[i];
370
+ if (quote === "'") {
371
+ if (ch === "'") quote = null;
372
+ else cur += ch;
373
+ continue;
374
+ }
375
+ if (quote === '"') {
376
+ if (ch === '"') quote = null;
377
+ else if (ch === "\\" && i + 1 < input.length && '"\\$`'.includes(input[i + 1])) cur += input[++i];
378
+ else cur += ch;
379
+ continue;
380
+ }
381
+ if (ch === "'" || ch === '"') {
382
+ quote = ch;
383
+ inToken = true;
384
+ } else if (ch === "\\" && i + 1 < input.length) {
385
+ cur += input[++i];
386
+ inToken = true;
387
+ } else if (/\s/.test(ch)) {
388
+ if (inToken) {
389
+ out.push(cur);
390
+ cur = "";
391
+ inToken = false;
392
+ }
393
+ } else {
394
+ cur += ch;
395
+ inToken = true;
396
+ }
397
+ }
398
+ if (quote) throw new ConfigError(`Unterminated quote in command: ${input}`);
399
+ if (inToken) out.push(cur);
400
+ return out;
401
+ }
402
+
403
+ // src/core/cache.ts
404
+ import { createHash } from "crypto";
405
+ import { mkdir, readdir, readFile as readFile2, rename, rm, writeFile } from "fs/promises";
406
+ import { join as join2 } from "path";
407
+ var TourCache = class {
408
+ constructor(dir) {
409
+ this.dir = dir;
410
+ this.toursDir = join2(dir, "tours");
411
+ }
412
+ dir;
413
+ toursDir;
414
+ static keyFor(prompt, command) {
415
+ return createHash("sha256").update(createHash("sha256").update(prompt).digest("hex")).update("\0").update(JSON.stringify(command)).digest("hex");
416
+ }
417
+ pathFor(key) {
418
+ return join2(this.toursDir, `${key}.json`);
419
+ }
420
+ async get(key) {
421
+ let text;
422
+ try {
423
+ text = await readFile2(this.pathFor(key), "utf8");
424
+ } catch (err) {
425
+ if (err.code === "ENOENT") return null;
426
+ throw err;
427
+ }
428
+ try {
429
+ const parsed = TourSchema.safeParse(JSON.parse(text));
430
+ return parsed.success ? parsed.data : null;
431
+ } catch {
432
+ return null;
433
+ }
434
+ }
435
+ async put(key, tour) {
436
+ await mkdir(this.toursDir, { recursive: true });
437
+ const final = this.pathFor(key);
438
+ const tmp = `${final}.${process.pid}.${Date.now()}.tmp`;
439
+ await writeFile(tmp, JSON.stringify(tour, null, 2) + "\n", "utf8");
440
+ await rename(tmp, final);
441
+ return final;
442
+ }
443
+ async list() {
444
+ let names;
445
+ try {
446
+ names = await readdir(this.toursDir);
447
+ } catch (err) {
448
+ if (err.code === "ENOENT") return [];
449
+ throw err;
450
+ }
451
+ const entries = [];
452
+ for (const name of names) {
453
+ if (!name.endsWith(".json")) continue;
454
+ const key = name.slice(0, -".json".length);
455
+ const tour = await this.get(key);
456
+ if (tour) entries.push({ key, path: this.pathFor(key), tour });
457
+ }
458
+ entries.sort((a, b) => b.tour.generatedAt.localeCompare(a.tour.generatedAt));
459
+ return entries;
460
+ }
461
+ async clear() {
462
+ const entries = await this.list();
463
+ await rm(this.toursDir, { recursive: true, force: true });
464
+ return entries.length;
465
+ }
466
+ };
467
+
468
+ // src/core/json.ts
469
+ function extractJson(text) {
470
+ const trimmed = text.trim();
471
+ if (!trimmed) throw new BadLlmOutputError("The model returned empty output.", text);
472
+ const candidates = [trimmed];
473
+ for (const m of trimmed.matchAll(/```(?:json|JSON)?\s*\n?([\s\S]*?)```/g)) candidates.push(m[1].trim());
474
+ const balanced = firstBalancedObject(trimmed);
475
+ if (balanced) candidates.push(balanced);
476
+ const first = trimmed.indexOf("{");
477
+ const last = trimmed.lastIndexOf("}");
478
+ if (first !== -1 && last > first) candidates.push(trimmed.slice(first, last + 1));
479
+ for (const c of candidates) {
480
+ const parsed = tryParse(c) ?? tryParse(repairUnescapedQuotes(c));
481
+ if (parsed === void 0) continue;
482
+ return unwrapEnvelope(parsed);
483
+ }
484
+ throw new BadLlmOutputError("Could not find a JSON object in the model output.", text, "Run with --debug to see the raw output.");
485
+ }
486
+ function repairUnescapedQuotes(s) {
487
+ let out = "";
488
+ let inString = false;
489
+ for (let i = 0; i < s.length; i++) {
490
+ const ch = s[i];
491
+ if (!inString) {
492
+ if (ch === '"') inString = true;
493
+ out += ch;
494
+ continue;
495
+ }
496
+ if (ch === "\\") {
497
+ out += ch + (s[i + 1] ?? "");
498
+ i++;
499
+ continue;
500
+ }
501
+ if (ch === '"') {
502
+ let j = i + 1;
503
+ while (j < s.length && (s[j] === " " || s[j] === " " || s[j] === "\r" || s[j] === "\n")) j++;
504
+ const next = s[j];
505
+ if (next === void 0 || next === "," || next === "}" || next === "]" || next === ":") {
506
+ inString = false;
507
+ out += ch;
508
+ } else {
509
+ out += '\\"';
510
+ }
511
+ continue;
512
+ }
513
+ out += ch;
514
+ }
515
+ return out;
516
+ }
517
+ function tryParse(s) {
518
+ try {
519
+ return JSON.parse(s);
520
+ } catch {
521
+ return void 0;
522
+ }
523
+ }
524
+ function unwrapEnvelope(value) {
525
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
526
+ const obj = value;
527
+ if ("sections" in obj) return obj;
528
+ for (const key of ["result", "response", "content", "text", "output"]) {
529
+ const inner = obj[key];
530
+ if (typeof inner === "string" && inner.includes("{")) {
531
+ try {
532
+ return extractJson(inner);
533
+ } catch {
534
+ }
535
+ }
536
+ if (typeof inner === "object" && inner !== null) return unwrapEnvelope(inner);
537
+ }
538
+ return obj;
539
+ }
540
+ function firstBalancedObject(s) {
541
+ const start = s.indexOf("{");
542
+ if (start === -1) return null;
543
+ let depth = 0;
544
+ let inString = false;
545
+ for (let i = start; i < s.length; i++) {
546
+ const ch = s[i];
547
+ if (inString) {
548
+ if (ch === "\\") i++;
549
+ else if (ch === '"') inString = false;
550
+ continue;
551
+ }
552
+ if (ch === '"') inString = true;
553
+ else if (ch === "{") depth++;
554
+ else if (ch === "}") {
555
+ depth--;
556
+ if (depth === 0) return s.slice(start, i + 1);
557
+ }
558
+ }
559
+ return null;
560
+ }
561
+
562
+ // src/git/parse.ts
563
+ var HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/;
564
+ function unquote(path) {
565
+ if (!path.startsWith('"') || !path.endsWith('"')) return path;
566
+ const inner = path.slice(1, -1);
567
+ return inner.replace(/\\([abfnrtv\\"]|[0-7]{3})/g, (_, esc) => {
568
+ switch (esc) {
569
+ case "a":
570
+ return "\x07";
571
+ case "b":
572
+ return "\b";
573
+ case "f":
574
+ return "\f";
575
+ case "n":
576
+ return "\n";
577
+ case "r":
578
+ return "\r";
579
+ case "t":
580
+ return " ";
581
+ case "v":
582
+ return "\v";
583
+ case "\\":
584
+ return "\\";
585
+ case '"':
586
+ return '"';
587
+ default:
588
+ return String.fromCharCode(parseInt(esc, 8));
589
+ }
590
+ });
591
+ }
592
+ function stripPrefix(path, prefix) {
593
+ const p = unquote(path);
594
+ return p.startsWith(prefix) ? p.slice(2) : p;
595
+ }
596
+ function splitGitHeader(rest) {
597
+ if (rest.startsWith('"')) {
598
+ const m = /^("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*")$/.exec(rest);
599
+ if (m) return { oldPath: stripPrefix(m[1], "a/"), newPath: stripPrefix(m[2], "b/") };
600
+ }
601
+ let idx = rest.indexOf(" b/");
602
+ while (idx !== -1) {
603
+ const left = rest.slice(0, idx);
604
+ const right = rest.slice(idx + 1);
605
+ if (left.startsWith("a/") && right.startsWith("b/") && left.slice(2) === right.slice(2)) {
606
+ return { oldPath: left.slice(2), newPath: right.slice(2) };
607
+ }
608
+ idx = rest.indexOf(" b/", idx + 1);
609
+ }
610
+ const first = rest.indexOf(" b/");
611
+ if (first === -1) return { oldPath: rest, newPath: rest };
612
+ return { oldPath: stripPrefix(rest.slice(0, first), "a/"), newPath: stripPrefix(rest.slice(first + 1), "b/") };
613
+ }
614
+ function parseDiff(raw) {
615
+ const lines = raw.split("\n");
616
+ const files = [];
617
+ let pending = null;
618
+ let hunk = null;
619
+ let oldNo = 0;
620
+ let newNo = 0;
621
+ const flush = () => {
622
+ if (!pending) return;
623
+ const id = `F${files.length + 1}`;
624
+ let additions = 0;
625
+ let deletions = 0;
626
+ pending.hunks.forEach((h, i) => {
627
+ h.id = `${id}.H${i + 1}`;
628
+ for (const l of h.lines) {
629
+ if (l.type === "add") additions++;
630
+ else if (l.type === "del") deletions++;
631
+ }
632
+ });
633
+ const status = pending.status;
634
+ const path = status === "deleted" ? pending.oldPath : pending.newPath;
635
+ const file = {
636
+ id,
637
+ path,
638
+ status,
639
+ binary: pending.binary,
640
+ additions,
641
+ deletions,
642
+ hunks: pending.hunks
643
+ };
644
+ if (status === "renamed") file.oldPath = pending.oldPath;
645
+ files.push(file);
646
+ pending = null;
647
+ hunk = null;
648
+ };
649
+ for (let i = 0; i < lines.length; i++) {
650
+ const line = lines[i];
651
+ if (line.startsWith("diff --git ")) {
652
+ flush();
653
+ const { oldPath, newPath } = splitGitHeader(line.slice("diff --git ".length));
654
+ pending = { oldPath, newPath, status: "modified", binary: false, hunks: [] };
655
+ continue;
656
+ }
657
+ if (!pending) continue;
658
+ if (hunk) {
659
+ const c = line[0];
660
+ if (c === " " || c === "+" || c === "-" || line === "" && i < lines.length - 1 && hunkHasRoom(hunk)) {
661
+ const text = line.slice(1);
662
+ const entry = c === "+" ? { type: "add", newNo: newNo++, text } : c === "-" ? { type: "del", oldNo: oldNo++, text } : { type: "ctx", oldNo: oldNo++, newNo: newNo++, text: c === void 0 ? "" : text };
663
+ hunk.lines.push(entry);
664
+ continue;
665
+ }
666
+ if (line.startsWith("\\")) continue;
667
+ hunk = null;
668
+ }
669
+ const hm = HUNK_RE.exec(line);
670
+ if (hm) {
671
+ hunk = {
672
+ id: "",
673
+ oldStart: Number(hm[1]),
674
+ oldLines: hm[2] === void 0 ? 1 : Number(hm[2]),
675
+ newStart: Number(hm[3]),
676
+ newLines: hm[4] === void 0 ? 1 : Number(hm[4]),
677
+ header: (hm[5] ?? "").trim(),
678
+ lines: []
679
+ };
680
+ oldNo = hunk.oldStart;
681
+ newNo = hunk.newStart;
682
+ pending.hunks.push(hunk);
683
+ continue;
684
+ }
685
+ if (line.startsWith("--- ")) {
686
+ const p = line.slice(4);
687
+ if (p === "/dev/null") pending.status = "added";
688
+ else pending.oldPath = stripPrefix(p, "a/");
689
+ continue;
690
+ }
691
+ if (line.startsWith("+++ ")) {
692
+ const p = line.slice(4);
693
+ if (p === "/dev/null") pending.status = "deleted";
694
+ else pending.newPath = stripPrefix(p, "b/");
695
+ continue;
696
+ }
697
+ if (line.startsWith("rename from ")) {
698
+ pending.oldPath = unquote(line.slice("rename from ".length));
699
+ pending.status = "renamed";
700
+ continue;
701
+ }
702
+ if (line.startsWith("rename to ")) {
703
+ pending.newPath = unquote(line.slice("rename to ".length));
704
+ pending.status = "renamed";
705
+ continue;
706
+ }
707
+ if (line.startsWith("new file mode")) {
708
+ pending.status = "added";
709
+ continue;
710
+ }
711
+ if (line.startsWith("deleted file mode")) {
712
+ pending.status = "deleted";
713
+ continue;
714
+ }
715
+ if (line.startsWith("Binary files ") || line.startsWith("GIT binary patch")) {
716
+ pending.binary = true;
717
+ continue;
718
+ }
719
+ }
720
+ flush();
721
+ return { files };
722
+ }
723
+ function hunkHasRoom(h) {
724
+ let o = 0;
725
+ let n = 0;
726
+ for (const l of h.lines) {
727
+ if (l.type !== "add") o++;
728
+ if (l.type !== "del") n++;
729
+ }
730
+ return o < h.oldLines || n < h.newLines;
731
+ }
732
+ function diffStats(diff) {
733
+ return diff.files.reduce(
734
+ (acc, f) => ({ files: acc.files + 1, additions: acc.additions + f.additions, deletions: acc.deletions + f.deletions }),
735
+ { files: 0, additions: 0, deletions: 0 }
736
+ );
737
+ }
738
+
739
+ // src/core/materialize.ts
740
+ var OTHER_CHANGES_TITLE = "Other changes";
741
+ function materializeTour(input) {
742
+ const warn = input.warn ?? (() => {
743
+ });
744
+ const byPath = /* @__PURE__ */ new Map();
745
+ const byOldPath = /* @__PURE__ */ new Map();
746
+ const hunks = /* @__PURE__ */ new Map();
747
+ for (const f of input.diff.files) {
748
+ byPath.set(f.path, f);
749
+ if (f.oldPath) byOldPath.set(f.oldPath, f);
750
+ for (const h of f.hunks) hunks.set(h.id, { file: f, hunk: h });
751
+ }
752
+ const lookupFile = (p) => byPath.get(p) ?? byPath.get(p.replace(/^\.\//, "")) ?? byOldPath.get(p);
753
+ const claimed = /* @__PURE__ */ new Set();
754
+ const sections = [];
755
+ for (const s of input.output.sections) {
756
+ const files = /* @__PURE__ */ new Set();
757
+ for (const p of s.files ?? []) {
758
+ const f = lookupFile(p);
759
+ if (f) files.add(f.path);
760
+ else warn(`Section "${s.title}" references unknown file ${p}; ignoring`);
761
+ }
762
+ const excerpts = [];
763
+ for (const ref of s.excerpts ?? []) {
764
+ const hit = hunks.get(ref.hunk.trim());
765
+ if (!hit) {
766
+ warn(`Section "${s.title}" references unknown hunk ${ref.hunk}; ignoring`);
767
+ continue;
768
+ }
769
+ files.add(hit.file.path);
770
+ const lines = sliceHunk(hit.hunk, ref.lines, input.maxExcerptLines);
771
+ const excerpt = {
772
+ file: hit.file.path,
773
+ hunkId: hit.hunk.id,
774
+ oldStart: lines[0]?.oldNo ?? firstOld(lines) ?? hit.hunk.oldStart,
775
+ newStart: lines[0]?.newNo ?? firstNew(lines) ?? hit.hunk.newStart,
776
+ lines
777
+ };
778
+ if (ref.note?.trim()) excerpt.note = ref.note.trim();
779
+ excerpts.push(excerpt);
780
+ }
781
+ const fileList = [...files];
782
+ for (const p of fileList) claimed.add(p);
783
+ sections.push({
784
+ id: `s${sections.length + 1}`,
785
+ title: s.title.trim(),
786
+ description: s.description.trim(),
787
+ files: fileList,
788
+ stats: statsFor(fileList, byPath),
789
+ excerpts
790
+ });
791
+ }
792
+ const unclaimed = input.diff.files.map((f) => f.path).filter((p) => !claimed.has(p));
793
+ if (unclaimed.length) {
794
+ sections.push({
795
+ id: `s${sections.length + 1}`,
796
+ title: OTHER_CHANGES_TITLE,
797
+ description: "Files in this change that the sections above do not cover.",
798
+ files: unclaimed,
799
+ stats: statsFor(unclaimed, byPath),
800
+ excerpts: []
801
+ });
802
+ }
803
+ const tour = {
804
+ version: 1,
805
+ generatedAt: (input.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
806
+ source: input.source,
807
+ generator: input.generator,
808
+ title: input.output.title.trim(),
809
+ summary: input.output.summary.trim(),
810
+ stats: diffStats(input.diff),
811
+ sections
812
+ };
813
+ if (input.pullRequest) {
814
+ tour.pullRequest = { number: input.pullRequest.number, title: input.pullRequest.title, url: input.pullRequest.url };
815
+ }
816
+ return tour;
817
+ }
818
+ function statsFor(paths, byPath) {
819
+ let additions = 0;
820
+ let deletions = 0;
821
+ for (const p of paths) {
822
+ const f = byPath.get(p);
823
+ if (!f) continue;
824
+ additions += f.additions;
825
+ deletions += f.deletions;
826
+ }
827
+ return { files: paths.length, additions, deletions };
828
+ }
829
+ function firstOld(lines) {
830
+ return lines.find((l) => l.oldNo !== void 0)?.oldNo;
831
+ }
832
+ function firstNew(lines) {
833
+ return lines.find((l) => l.newNo !== void 0)?.newNo;
834
+ }
835
+ function sliceHunk(hunk, range, maxLines) {
836
+ let lines = hunk.lines;
837
+ if (range) {
838
+ const [a, b] = range;
839
+ const start = Math.min(a, b);
840
+ const end = Math.max(a, b);
841
+ let cursor = hunk.newStart;
842
+ const picked = [];
843
+ for (const l of lines) {
844
+ const pos = l.type === "del" ? cursor : l.newNo ?? cursor;
845
+ if (l.type !== "del") cursor = (l.newNo ?? cursor) + 1;
846
+ if (pos >= start && pos <= end) picked.push(l);
847
+ }
848
+ if (picked.length > 0) lines = picked;
849
+ }
850
+ return lines.slice(0, maxLines);
851
+ }
852
+
853
+ // src/core/prompt/template.ts
854
+ var PROMPT_VERSION = 2;
855
+ var PROMPT_HEADER = `You are writing a guided code tour of a change for a reviewer who has not seen it before.
856
+
857
+ A code tour is an ordered list of sections. Each section explains one coherent part of the change: what it does, why it is there, and how it connects to the rest. Sections are ordered the way a reader should encounter them: start with the change that makes everything else make sense (a new type, an interface, a data model, a configuration knob), then the code that builds on it, then wiring and plumbing, then tests and housekeeping.
858
+
859
+ ## Rules
860
+
861
+ - Produce between 2 and 8 sections. Fewer is better when the change is small; never pad.
862
+ - Group by concept, not by file. A section may span many files, and a file may appear in several sections.
863
+ - Every file in the change must be claimed by at least one section. Put unrelated housekeeping (formatting, generated code, renames, dependency bumps) in one short final section rather than sprinkling it around.
864
+ - A section's description is 2 to 5 sentences of plain prose written for a colleague. Lead with the purpose (why), then what changed, then anything a reviewer should look at carefully: behavior changes, edge cases, risk. Do not narrate line by line and do not restate the diff.
865
+ - Choose 1 to 3 excerpts per section: the hunks that best show the idea. Reference hunks by their id exactly as given (for example "F2.H1"). Optionally narrow a hunk with "lines": [start, end] using NEW-file line numbers as they appear in the diff. Never quote code in the JSON; the real diff is rendered from your references.
866
+ - Give each excerpt a short "note" (under 15 words) saying what to look at.
867
+ - The tour "title" is a short imperative phrase naming the change, like a good commit subject. The "summary" is 2 to 4 sentences describing the whole change and its motivation.
868
+ - Use the pull request description and commit messages as evidence of intent, but trust the diff over them when they disagree.
869
+ - If parts of the diff were truncated or omitted, still assign those files to sections based on their names and stats, and only reference hunks that were shown.
870
+ - The JSON must be strictly valid. Inside a string, escape double quotes as \\" or use single quotes when mentioning flags, code, or file names.
871
+
872
+ ## Output
873
+
874
+ Respond with ONLY a JSON object: no prose before or after it, and no code fences.
875
+
876
+ {
877
+ "title": "Short imperative title",
878
+ "summary": "What this change does and why.",
879
+ "sections": [
880
+ {
881
+ "title": "Section title",
882
+ "description": "Why, then what, then what to watch.",
883
+ "files": ["path/one.ts", "path/two.ts"],
884
+ "excerpts": [
885
+ { "hunk": "F1.H2", "note": "The new interface every provider implements" },
886
+ { "hunk": "F3.H1", "lines": [40, 58], "note": "Where the fallback kicks in" }
887
+ ]
888
+ }
889
+ ]
890
+ }
891
+ `;
892
+ var REPAIR_SUFFIX = (reason) => `
893
+
894
+ ---
895
+
896
+ Your previous response could not be used: ${reason}
897
+
898
+ Respond again with ONLY the JSON object described above. No prose, no code fences, no comments.`;
899
+
900
+ // src/core/prompt/build.ts
901
+ var KEEP_LINES = 40;
902
+ var MAX_PR_BODY_BYTES = 12e3;
903
+ function buildPrompt(input) {
904
+ const context = renderContext(input);
905
+ const truncation = { truncated: [], omitted: [] };
906
+ const blocks = input.diff.files.map((file) => {
907
+ const full = renderFile(file);
908
+ const truncated = file.hunks.length ? renderTruncated(file) : null;
909
+ return {
910
+ file,
911
+ full,
912
+ // Only worth truncating when it actually saves space.
913
+ truncated: truncated && bytes(truncated) < bytes(full) ? truncated : null,
914
+ omitted: renderOmitted(file),
915
+ mode: "full"
916
+ };
917
+ });
918
+ const text = (b) => b.mode === "full" ? b.full : b.mode === "truncated" ? b.truncated : b.omitted;
919
+ const total = () => bytes(diffPreamble(truncation)) + blocks.reduce((n, b) => n + bytes(text(b)), 0);
920
+ const budget = input.maxBytes - bytes(PROMPT_HEADER) - bytes(context) - 8;
921
+ if (total() > budget) {
922
+ const bySize = [...blocks].sort((a, b) => bytes(b.full) - bytes(a.full));
923
+ for (const block of bySize) {
924
+ if (total() <= budget) break;
925
+ if (!block.truncated) continue;
926
+ block.mode = "truncated";
927
+ truncation.truncated.push(block.file.path);
928
+ }
929
+ for (const block of bySize) {
930
+ if (total() <= budget) break;
931
+ if (block.file.hunks.length === 0) continue;
932
+ if (block.mode === "truncated") truncation.truncated = truncation.truncated.filter((p) => p !== block.file.path);
933
+ block.mode = "omitted";
934
+ truncation.omitted.push(block.file.path);
935
+ }
936
+ }
937
+ const prompt = [PROMPT_HEADER, context, diffPreamble(truncation), ...blocks.map(text)].join("\n");
938
+ return { prompt, truncation };
939
+ }
940
+ function bytes(s) {
941
+ return Buffer.byteLength(s, "utf8");
942
+ }
943
+ function renderContext(input) {
944
+ const parts = ["# The change", ""];
945
+ parts.push("## Source");
946
+ parts.push(describeSource(input.source, input.branch));
947
+ parts.push("");
948
+ if (input.pullRequest) {
949
+ const pr = input.pullRequest;
950
+ parts.push(`## Pull request #${pr.number}: ${pr.title.trim() || "(untitled)"}`);
951
+ const body = pr.body.trim();
952
+ parts.push(body ? clip(body, MAX_PR_BODY_BYTES) : "(no description)");
953
+ parts.push("");
954
+ }
955
+ if (input.commits.length) {
956
+ parts.push("## Commits (oldest first)");
957
+ for (const c of [...input.commits].reverse()) parts.push(`- ${c.subject}`);
958
+ parts.push("");
959
+ }
960
+ const stats = diffStats(input.diff);
961
+ parts.push(`## Files (${stats.files} ${stats.files === 1 ? "file" : "files"}, +${stats.additions} -${stats.deletions})`);
962
+ const idWidth = Math.max(...input.diff.files.map((f) => f.id.length), 2);
963
+ for (const f of input.diff.files) {
964
+ const status = f.binary ? "binary" : f.status;
965
+ const name = f.status === "renamed" && f.oldPath ? `${f.oldPath} -> ${f.path}` : f.path;
966
+ const counts = f.binary ? "" : ` +${f.additions} -${f.deletions}`;
967
+ parts.push(`${f.id.padEnd(idWidth)} ${status.padEnd(8)} ${name}${counts}`);
968
+ }
969
+ parts.push("");
970
+ return parts.join("\n");
971
+ }
972
+ function describeSource(source, branch) {
973
+ if (source.kind === "working") {
974
+ const what = source.staged ? "Staged (uncommitted) changes" : "Uncommitted changes in the working tree";
975
+ return branch ? `${what} on branch ${branch}.` : `${what} (detached HEAD).`;
976
+ }
977
+ const mb = source.mergeBase ? ` (at merge-base ${source.mergeBase.slice(0, 7)})` : "";
978
+ return `${source.head} compared against ${source.base}${mb}.`;
979
+ }
980
+ function diffPreamble(t) {
981
+ const lines = ["## Diff", ""];
982
+ if (t.truncated.length || t.omitted.length) {
983
+ lines.push("Note: to fit the size budget, some diffs below were shortened.");
984
+ if (t.truncated.length) lines.push(`- Truncated (only the shown hunk may be referenced): ${t.truncated.join(", ")}`);
985
+ if (t.omitted.length) lines.push(`- Omitted entirely (assign by name and stats): ${t.omitted.join(", ")}`);
986
+ lines.push("");
987
+ }
988
+ return lines.join("\n");
989
+ }
990
+ function fileHeading(file) {
991
+ const status = file.binary ? "binary" : file.status;
992
+ const rename2 = file.status === "renamed" && file.oldPath ? `, from ${file.oldPath}` : "";
993
+ const counts = file.binary ? "" : `, +${file.additions} -${file.deletions}`;
994
+ return `### ${file.id} ${file.path} (${status}${rename2}${counts})`;
995
+ }
996
+ function renderHunk(hunk, limit) {
997
+ const out = [`[${hunk.id}] @@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@${hunk.header ? " " + hunk.header : ""}`];
998
+ const lines = limit === void 0 ? hunk.lines : hunk.lines.slice(0, limit);
999
+ for (const l of lines) out.push((l.type === "add" ? "+" : l.type === "del" ? "-" : " ") + l.text);
1000
+ return out;
1001
+ }
1002
+ function renderFile(file) {
1003
+ const out = [fileHeading(file)];
1004
+ if (file.binary) out.push("(binary file, no textual diff)");
1005
+ else if (file.hunks.length === 0) out.push("(no content changes)");
1006
+ else for (const h of file.hunks) out.push(...renderHunk(h));
1007
+ out.push("");
1008
+ return out.join("\n");
1009
+ }
1010
+ function renderTruncated(file) {
1011
+ const first = file.hunks[0];
1012
+ const shown = Math.min(KEEP_LINES, first.lines.length);
1013
+ const totalLines = file.hunks.reduce((n, h) => n + h.lines.length, 0);
1014
+ const out = [fileHeading(file), ...renderHunk(first, shown)];
1015
+ out.push(`... [truncated: ${totalLines - shown} more lines across ${file.hunks.length} hunk${file.hunks.length === 1 ? "" : "s"}]`);
1016
+ out.push("");
1017
+ return out.join("\n");
1018
+ }
1019
+ function renderOmitted(file) {
1020
+ return [fileHeading(file), "(diff omitted for length)", ""].join("\n");
1021
+ }
1022
+ function clip(text, maxBytes) {
1023
+ if (bytes(text) <= maxBytes) return text;
1024
+ let cut = text.slice(0, maxBytes);
1025
+ while (bytes(cut) > maxBytes) cut = cut.slice(0, -100);
1026
+ return cut + "\n... [description truncated]";
1027
+ }
1028
+
1029
+ // src/codehost/gh.ts
1030
+ import { execFile } from "child_process";
1031
+ var FIELDS = "number,title,body,url,baseRefName,headRefName,state";
1032
+ var GhCodeHost = class {
1033
+ name = "gh";
1034
+ timeoutMs;
1035
+ debug;
1036
+ constructor(opts = {}) {
1037
+ this.timeoutMs = opts.timeoutMs ?? 15e3;
1038
+ this.debug = opts.debug ?? (() => {
1039
+ });
1040
+ }
1041
+ currentPullRequest(cwd) {
1042
+ return new Promise((resolve) => {
1043
+ execFile(
1044
+ "gh",
1045
+ ["pr", "view", "--json", FIELDS],
1046
+ { cwd, timeout: this.timeoutMs, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 },
1047
+ (err, stdout, stderr) => {
1048
+ if (err) {
1049
+ const code = err.code;
1050
+ this.debug(
1051
+ code === "ENOENT" ? "gh is not installed; skipping pull request lookup" : `gh pr view failed: ${(stderr || err.message).trim()}`
1052
+ );
1053
+ resolve(null);
1054
+ return;
1055
+ }
1056
+ try {
1057
+ const j = JSON.parse(stdout);
1058
+ resolve({
1059
+ number: Number(j.number),
1060
+ title: String(j.title ?? ""),
1061
+ body: String(j.body ?? ""),
1062
+ url: String(j.url ?? ""),
1063
+ baseRefName: String(j.baseRefName ?? ""),
1064
+ headRefName: String(j.headRefName ?? "")
1065
+ });
1066
+ } catch (parseErr) {
1067
+ this.debug(`gh returned unparseable JSON: ${parseErr.message}`);
1068
+ resolve(null);
1069
+ }
1070
+ }
1071
+ );
1072
+ });
1073
+ }
1074
+ };
1075
+
1076
+ // src/codehost/none.ts
1077
+ var NoCodeHost = class {
1078
+ name = "none";
1079
+ async currentPullRequest() {
1080
+ return null;
1081
+ }
1082
+ };
1083
+
1084
+ // src/codehost/index.ts
1085
+ function createCodeHost(config, opts = {}) {
1086
+ switch (config.codehost.provider) {
1087
+ case "gh":
1088
+ return new GhCodeHost({ debug: opts.debug });
1089
+ case "none":
1090
+ return new NoCodeHost();
1091
+ }
1092
+ }
1093
+
1094
+ // src/git/exec.ts
1095
+ import { execFile as execFile2 } from "child_process";
1096
+ var MAX_BUFFER = 512 * 1024 * 1024;
1097
+ function git(args, opts) {
1098
+ return new Promise((resolve, reject) => {
1099
+ execFile2("git", args, { cwd: opts.cwd, maxBuffer: MAX_BUFFER, encoding: "utf8" }, (err, stdout, stderr) => {
1100
+ const e = err;
1101
+ if (e && e.code === "ENOENT") {
1102
+ reject(new GitError("git executable not found", { hint: "Install git and make sure it is on your PATH." }));
1103
+ return;
1104
+ }
1105
+ const code = e ? typeof e.code === "number" ? e.code : 1 : 0;
1106
+ if (code !== 0 && !opts.allowFailure) {
1107
+ const detail = stderr.trim() || stdout.trim() || `exit code ${code}`;
1108
+ reject(new GitError(`git ${args.slice(0, 2).join(" ")} failed: ${detail}`));
1109
+ return;
1110
+ }
1111
+ resolve({ stdout, stderr, code });
1112
+ });
1113
+ });
1114
+ }
1115
+ async function gitRoot(cwd) {
1116
+ const res = await git(["rev-parse", "--show-toplevel"], { cwd, allowFailure: true });
1117
+ if (res.code !== 0) throw new NotARepoError(cwd);
1118
+ return res.stdout.trim();
1119
+ }
1120
+ async function revParse(ref, cwd) {
1121
+ const res = await git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd, allowFailure: true });
1122
+ return res.code === 0 ? res.stdout.trim() : null;
1123
+ }
1124
+ async function currentBranch(cwd) {
1125
+ const res = await git(["symbolic-ref", "--short", "--quiet", "HEAD"], { cwd, allowFailure: true });
1126
+ return res.code === 0 ? res.stdout.trim() : null;
1127
+ }
1128
+ async function mergeBase(a, b, cwd) {
1129
+ const res = await git(["merge-base", a, b], { cwd, allowFailure: true });
1130
+ return res.code === 0 ? res.stdout.trim() : null;
1131
+ }
1132
+ async function localBranches(cwd) {
1133
+ const res = await git(["for-each-ref", "--format=%(refname:short)", "refs/heads/"], { cwd });
1134
+ return res.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
1135
+ }
1136
+ function excludePathspecs(exclude) {
1137
+ return exclude.map((glob) => `:(exclude,glob)${glob}`);
1138
+ }
1139
+ async function isDirty(cwd, exclude) {
1140
+ const res = await git(["status", "--porcelain", "--untracked-files=all", "--", ".", ...excludePathspecs(exclude)], { cwd });
1141
+ return res.stdout.trim().length > 0;
1142
+ }
1143
+
1144
+ // src/git/diff.ts
1145
+ import { stat } from "fs/promises";
1146
+ import { join as join3 } from "path";
1147
+ var DIFF_BASE_ARGS = ["diff", "--no-color", "--no-ext-diff", "--src-prefix=a/", "--dst-prefix=b/", "-M", "-U3"];
1148
+ var MAX_UNTRACKED_BYTES = 2 * 1024 * 1024;
1149
+ async function collectDiff(source, opts) {
1150
+ const pathspec = ["--", ".", ...excludePathspecs(opts.exclude)];
1151
+ let raw;
1152
+ if (source.kind === "range") {
1153
+ raw = (await git([...DIFF_BASE_ARGS, source.baseSha, source.headSha, ...pathspec], { cwd: opts.cwd })).stdout;
1154
+ } else if (source.staged) {
1155
+ raw = (await git([...DIFF_BASE_ARGS, "--cached", ...pathspec], { cwd: opts.cwd })).stdout;
1156
+ } else {
1157
+ raw = (await git([...DIFF_BASE_ARGS, "HEAD", ...pathspec], { cwd: opts.cwd })).stdout;
1158
+ raw += await untrackedDiff(opts, pathspec);
1159
+ }
1160
+ return parseDiff(raw);
1161
+ }
1162
+ async function untrackedDiff(opts, pathspec) {
1163
+ const list = await git(["ls-files", "--others", "--exclude-standard", "-z", ...pathspec], { cwd: opts.cwd });
1164
+ const paths = list.stdout.split("\0").filter(Boolean);
1165
+ let out = "";
1166
+ for (const rel of paths) {
1167
+ const abs = join3(opts.cwd, rel);
1168
+ try {
1169
+ const s = await stat(abs);
1170
+ if (!s.isFile()) continue;
1171
+ if (s.size > MAX_UNTRACKED_BYTES) {
1172
+ opts.warn?.(`Skipping large untracked file ${rel} (${Math.round(s.size / 1024)} KB)`);
1173
+ continue;
1174
+ }
1175
+ } catch {
1176
+ continue;
1177
+ }
1178
+ const res = await git(
1179
+ [...DIFF_BASE_ARGS, "--no-index", "--", "/dev/null", rel],
1180
+ { cwd: opts.cwd, allowFailure: true }
1181
+ );
1182
+ if (res.code > 1) {
1183
+ opts.warn?.(`Could not diff untracked file ${rel}: ${res.stderr.trim()}`);
1184
+ continue;
1185
+ }
1186
+ out += res.stdout;
1187
+ }
1188
+ return out;
1189
+ }
1190
+ async function collectCommits(source, cwd, limit = 50) {
1191
+ if (source.kind !== "range") return [];
1192
+ const res = await git(
1193
+ ["log", "--no-merges", `--max-count=${limit}`, "--format=%h%x09%s", `${source.baseSha}..${source.headSha}`],
1194
+ { cwd, allowFailure: true }
1195
+ );
1196
+ if (res.code !== 0) return [];
1197
+ return res.stdout.split("\n").filter(Boolean).map((line) => {
1198
+ const tab = line.indexOf(" ");
1199
+ return { sha: line.slice(0, tab), subject: line.slice(tab + 1) };
1200
+ });
1201
+ }
1202
+
1203
+ // src/git/range.ts
1204
+ var MAX_ANCESTOR_CANDIDATES = 100;
1205
+ async function resolveRange(req, opts) {
1206
+ const debug = opts.debug ?? (() => {
1207
+ });
1208
+ const { cwd } = opts;
1209
+ const branch = await currentBranch(cwd);
1210
+ const headSha = await revParse("HEAD", cwd);
1211
+ if (!headSha) {
1212
+ throw new NoChangesError("This repository has no commits yet.", "Make an initial commit first.");
1213
+ }
1214
+ debug(`repository ${cwd} on ${branch ?? "detached HEAD"} at ${headSha.slice(0, 12)}`);
1215
+ if (req.working && req.staged) throw new UsageError("--working and --staged are mutually exclusive.");
1216
+ if ((req.working || req.staged) && req.arg) throw new UsageError(`A range argument cannot be combined with --${req.working ? "working" : "staged"}.`);
1217
+ if (req.staged) return { source: { kind: "working", headSha, staged: true, resolvedBy: "explicit" }, branch };
1218
+ if (req.working) return { source: { kind: "working", headSha, staged: false, resolvedBy: "explicit" }, branch };
1219
+ if (req.arg) {
1220
+ return { source: await explicitRange(req.arg, headSha, cwd), branch };
1221
+ }
1222
+ if (await isDirty(cwd, opts.exclude)) {
1223
+ debug("working tree is dirty; touring uncommitted changes");
1224
+ return { source: { kind: "working", headSha, staged: false, resolvedBy: "dirty-tree" }, branch };
1225
+ }
1226
+ const defaultBranch = await detectDefaultBranch(opts.defaultBranch, cwd);
1227
+ debug(`default branch: ${defaultBranch ?? "(none)"}`);
1228
+ const pr = await opts.codehost.currentPullRequest(cwd);
1229
+ if (pr?.baseRefName) {
1230
+ const baseRef = await firstExistingRef([`origin/${pr.baseRefName}`, pr.baseRefName], cwd);
1231
+ if (baseRef) {
1232
+ debug(`pull request #${pr.number} targets ${pr.baseRefName}; using ${baseRef}`);
1233
+ const source = await rangeAtMergeBase(baseRef, headSha, branch ?? "HEAD", "pull-request", cwd);
1234
+ return { source, branch, pullRequest: pr };
1235
+ }
1236
+ debug(`pull request #${pr.number} targets ${pr.baseRefName}, but that ref is not available locally`);
1237
+ }
1238
+ const onDefault = defaultBranch !== null && branch !== null && stripRemote(defaultBranch) === branch;
1239
+ if (!onDefault) {
1240
+ const nearest = await nearestAncestorBranch(branch, headSha, defaultBranch, cwd, debug);
1241
+ if (nearest) {
1242
+ debug(`nearest ancestor branch: ${nearest}`);
1243
+ const source = await rangeAtMergeBase(nearest, headSha, branch ?? "HEAD", "ancestor-branch", cwd);
1244
+ return { source, branch };
1245
+ }
1246
+ }
1247
+ if (defaultBranch && !onDefault) {
1248
+ const source = await rangeAtMergeBase(defaultBranch, headSha, branch ?? "HEAD", "default-branch", cwd);
1249
+ return { source, branch };
1250
+ }
1251
+ throw new NoChangesError(
1252
+ onDefault ? `You're on ${branch} in ${cwd} with a clean working tree; nothing to tour.` : "Could not infer what to compare against.",
1253
+ "Pass a range explicitly, e.g. `bb HEAD~3`, `bb main..feature` or `bb --working`."
1254
+ );
1255
+ }
1256
+ async function explicitRange(arg, headSha, cwd) {
1257
+ const three = arg.indexOf("...");
1258
+ const two = three === -1 ? arg.indexOf("..") : -1;
1259
+ if (three !== -1) {
1260
+ const base = arg.slice(0, three) || "HEAD";
1261
+ const head = arg.slice(three + 3) || "HEAD";
1262
+ const headResolved = await requireRef(head, cwd);
1263
+ return rangeAtMergeBase(base, headResolved, head, "explicit", cwd);
1264
+ }
1265
+ if (two !== -1) {
1266
+ const base = arg.slice(0, two) || "HEAD";
1267
+ const head = arg.slice(two + 2) || "HEAD";
1268
+ const baseSha = await requireRef(base, cwd);
1269
+ const headResolved = await requireRef(head, cwd);
1270
+ if (baseSha === headResolved) throw new NoChangesError(`${base} and ${head} point at the same commit.`);
1271
+ return { kind: "range", base, head, baseSha, headSha: headResolved, resolvedBy: "explicit" };
1272
+ }
1273
+ return rangeAtMergeBase(arg, headSha, "HEAD", "explicit", cwd);
1274
+ }
1275
+ async function requireRef(ref, cwd) {
1276
+ const sha = await revParse(ref, cwd);
1277
+ if (!sha) throw new UsageError(`Unknown git ref: ${ref}`);
1278
+ return sha;
1279
+ }
1280
+ async function rangeAtMergeBase(base, headSha, headLabel, resolvedBy, cwd) {
1281
+ const baseTip = await requireRef(base, cwd);
1282
+ const mb = await mergeBase(baseTip, headSha, cwd) ?? baseTip;
1283
+ if (mb === headSha) {
1284
+ throw new NoChangesError(
1285
+ `${headLabel} has no commits on top of ${base}.`,
1286
+ base === headLabel ? void 0 : `Did you mean \`bb ${base}..${headLabel}\`?`
1287
+ );
1288
+ }
1289
+ const source = { kind: "range", base, head: headLabel, baseSha: mb, headSha, resolvedBy };
1290
+ if (mb !== baseTip) source.mergeBase = mb;
1291
+ return source;
1292
+ }
1293
+ async function firstExistingRef(candidates, cwd) {
1294
+ for (const ref of candidates) {
1295
+ if (await revParse(ref, cwd)) return ref;
1296
+ }
1297
+ return null;
1298
+ }
1299
+ function stripRemote(ref) {
1300
+ return ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
1301
+ }
1302
+ async function detectDefaultBranch(configured, cwd) {
1303
+ if (configured) return await revParse(configured, cwd) ? configured : null;
1304
+ const sym = await git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], { cwd, allowFailure: true });
1305
+ if (sym.code === 0 && sym.stdout.trim()) return sym.stdout.trim();
1306
+ return firstExistingRef(["main", "master", "trunk", "origin/main", "origin/master"], cwd);
1307
+ }
1308
+ async function nearestAncestorBranch(branch, headSha, defaultBranch, cwd, debug) {
1309
+ const branches = (await localBranches(cwd)).filter((b) => b !== branch);
1310
+ if (branches.length === 0) return null;
1311
+ if (branches.length > MAX_ANCESTOR_CANDIDATES) {
1312
+ debug(`skipping ancestor search: ${branches.length} local branches`);
1313
+ return null;
1314
+ }
1315
+ let best = null;
1316
+ for (const name of branches) {
1317
+ const mb = await mergeBase(name, headSha, cwd);
1318
+ if (!mb || mb === headSha) continue;
1319
+ const count = await git(["rev-list", "--count", `${mb}..${headSha}`], { cwd, allowFailure: true });
1320
+ const distance = count.code === 0 ? Number(count.stdout.trim()) : Number.POSITIVE_INFINITY;
1321
+ const isDefault = defaultBranch !== null && stripRemote(defaultBranch) === name;
1322
+ if (!best || distance < best.distance || distance === best.distance && isDefault) {
1323
+ best = { name, distance };
1324
+ }
1325
+ }
1326
+ return best?.name ?? null;
1327
+ }
1328
+
1329
+ // src/llm/command.ts
1330
+ import { spawn } from "child_process";
1331
+ var STDERR_TAIL_LINES = 20;
1332
+ var CommandProvider = class {
1333
+ constructor(opts) {
1334
+ this.opts = opts;
1335
+ if (opts.command.length === 0) throw new LlmFailedError("LLM command is empty.");
1336
+ }
1337
+ opts;
1338
+ describe() {
1339
+ return this.opts.command.map(shellQuote).join(" ");
1340
+ }
1341
+ complete(prompt) {
1342
+ const { promptVia, timeoutMs } = this.opts;
1343
+ const argv = promptVia === "arg" ? this.opts.command.map((a) => a.replaceAll("{prompt}", prompt)) : this.opts.command;
1344
+ const [bin, ...args] = argv;
1345
+ const debug = this.opts.debug ?? (() => {
1346
+ });
1347
+ const started = Date.now();
1348
+ debug(`running ${this.describe()} (prompt ${Buffer.byteLength(prompt)} bytes via ${promptVia})`);
1349
+ return new Promise((resolve, reject) => {
1350
+ const env = { ...process.env, ...this.opts.env };
1351
+ delete env.CLAUDECODE;
1352
+ const child = spawn(bin, args, { cwd: this.opts.cwd, env, stdio: ["pipe", "pipe", "pipe"] });
1353
+ const out = [];
1354
+ const err = [];
1355
+ let settled = false;
1356
+ const finish = (fn) => {
1357
+ if (settled) return;
1358
+ settled = true;
1359
+ clearTimeout(timer);
1360
+ fn();
1361
+ };
1362
+ const timer = setTimeout(() => {
1363
+ child.kill("SIGTERM");
1364
+ finish(() => reject(new LlmFailedError(`LLM command timed out after ${Math.round(timeoutMs / 1e3)}s: ${this.describe()}`, {
1365
+ hint: "Raise llm.timeoutMs in your config, or pick a faster preset."
1366
+ })));
1367
+ }, timeoutMs);
1368
+ child.stdout.on("data", (b) => out.push(b));
1369
+ child.stderr.on("data", (b) => err.push(b));
1370
+ child.on("error", (e) => {
1371
+ finish(() => {
1372
+ if (e.code === "ENOENT") {
1373
+ reject(new LlmFailedError(`LLM command not found: ${bin}`, { hint: "Install it, or choose another preset with --preset / llm.preset.", cause: e }));
1374
+ } else {
1375
+ reject(new LlmFailedError(`Could not run ${this.describe()}: ${e.message}`, { cause: e }));
1376
+ }
1377
+ });
1378
+ });
1379
+ child.on("close", (code, signal) => {
1380
+ finish(() => {
1381
+ const stdout = Buffer.concat(out).toString("utf8");
1382
+ const stderr = Buffer.concat(err).toString("utf8");
1383
+ debug(`command exited with ${signal ? `signal ${signal}` : `code ${code}`} after ${Date.now() - started}ms; ${stdout.length} bytes of stdout`);
1384
+ if (code !== 0) {
1385
+ const tail = stderr.trim().split("\n").slice(-STDERR_TAIL_LINES).join("\n");
1386
+ reject(new LlmFailedError(`LLM command failed (${signal ? `signal ${signal}` : `exit ${code}`}): ${this.describe()}${tail ? "\n" + tail : ""}`));
1387
+ return;
1388
+ }
1389
+ resolve(stdout);
1390
+ });
1391
+ });
1392
+ if (promptVia === "stdin") {
1393
+ child.stdin.on("error", () => {
1394
+ });
1395
+ child.stdin.end(prompt);
1396
+ } else {
1397
+ child.stdin.end();
1398
+ }
1399
+ });
1400
+ }
1401
+ };
1402
+ function shellQuote(arg) {
1403
+ if (arg === "") return '""';
1404
+ return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
1405
+ }
1406
+
1407
+ // src/llm/index.ts
1408
+ function createProvider(llm, opts = {}) {
1409
+ return new CommandProvider({
1410
+ command: llm.command,
1411
+ promptVia: llm.promptVia,
1412
+ timeoutMs: llm.timeoutMs,
1413
+ env: llm.env,
1414
+ cwd: opts.cwd,
1415
+ debug: opts.debug
1416
+ });
1417
+ }
1418
+
1419
+ // src/core/tour.ts
1420
+ async function prepareTour(opts) {
1421
+ const debug = opts.debug ?? (() => {
1422
+ });
1423
+ const warn = opts.warn ?? (() => {
1424
+ });
1425
+ const { config } = opts;
1426
+ const llm = resolveLlm(config);
1427
+ const codehost = opts.codehost ?? createCodeHost(config, { debug });
1428
+ const resolved = await resolveRange(opts.range ?? {}, {
1429
+ cwd: opts.cwd,
1430
+ exclude: config.git.exclude,
1431
+ defaultBranch: config.git.defaultBranch,
1432
+ codehost,
1433
+ debug
1434
+ });
1435
+ debug(`source: ${JSON.stringify(resolved.source)}`);
1436
+ const diff = await collectDiff(resolved.source, { cwd: opts.cwd, exclude: config.git.exclude, warn });
1437
+ if (diff.files.length === 0) {
1438
+ throw new NoChangesError("The selected range has no changes (after excludes).", "Check git.exclude in your config, or pass a different range.");
1439
+ }
1440
+ const commits = await collectCommits(resolved.source, opts.cwd);
1441
+ const context = { source: resolved.source, branch: resolved.branch, diff, commits };
1442
+ if (resolved.pullRequest) context.pullRequest = resolved.pullRequest;
1443
+ const built = buildPrompt({ ...context, maxBytes: llm.maxPromptBytes });
1444
+ if (built.truncation.truncated.length || built.truncation.omitted.length) {
1445
+ warn(`Prompt exceeded ${llm.maxPromptBytes} bytes; truncated ${built.truncation.truncated.length} and omitted ${built.truncation.omitted.length} file diff(s).`);
1446
+ }
1447
+ const cacheKey = TourCache.keyFor(`v${PROMPT_VERSION}
1448
+ ${built.prompt}`, llm.command);
1449
+ debug(`prompt ${Buffer.byteLength(built.prompt)} bytes, cache key ${cacheKey.slice(0, 12)}`);
1450
+ return { context, built, command: llm.command, preset: llm.preset, cacheKey };
1451
+ }
1452
+ async function generateTour(opts, prepared) {
1453
+ const debug = opts.debug ?? (() => {
1454
+ });
1455
+ const warn = opts.warn ?? (() => {
1456
+ });
1457
+ const { config } = opts;
1458
+ const prep = prepared ?? await prepareTour(opts);
1459
+ const useCache = config.cache.enabled && !opts.noCache;
1460
+ const cache = useCache ? new TourCache(opts.cacheDir) : null;
1461
+ if (cache && !opts.refresh) {
1462
+ const hit = await cache.get(prep.cacheKey);
1463
+ if (hit) {
1464
+ debug(`cache hit: ${cache.pathFor(prep.cacheKey)}`);
1465
+ return { tour: hit, fromCache: true, cacheKey: prep.cacheKey, cachePath: cache.pathFor(prep.cacheKey), prompt: prep.built.prompt };
1466
+ }
1467
+ debug("cache miss");
1468
+ }
1469
+ const llm = resolveLlm(config);
1470
+ const provider = opts.provider ?? createProvider(llm, { cwd: opts.cwd, debug });
1471
+ const output = await completeWithRepair(provider, prep.built.prompt, debug);
1472
+ const tour = materializeTour({
1473
+ output,
1474
+ diff: prep.context.diff,
1475
+ source: prep.context.source,
1476
+ generator: { preset: prep.preset, command: prep.command },
1477
+ pullRequest: prep.context.pullRequest,
1478
+ maxExcerptLines: config.render.maxExcerptLines,
1479
+ warn
1480
+ });
1481
+ let cachePath = null;
1482
+ if (cache) {
1483
+ cachePath = await cache.put(prep.cacheKey, tour);
1484
+ debug(`cached: ${cachePath}`);
1485
+ }
1486
+ return { tour, fromCache: false, cacheKey: cache ? prep.cacheKey : null, cachePath, prompt: prep.built.prompt };
1487
+ }
1488
+ async function completeWithRepair(provider, prompt, debug) {
1489
+ let raw = await provider.complete(prompt);
1490
+ debug(`raw model output (attempt 1):
1491
+ ${raw}`);
1492
+ const first = parseOutput(raw);
1493
+ if (first.ok) return first.value;
1494
+ debug(`attempt 1 unusable: ${first.reason}; retrying with repair prompt`);
1495
+ raw = await provider.complete(prompt + REPAIR_SUFFIX(first.reason));
1496
+ debug(`raw model output (attempt 2):
1497
+ ${raw}`);
1498
+ const second = parseOutput(raw);
1499
+ if (second.ok) return second.value;
1500
+ throw new BadLlmOutputError(`The model did not return a usable tour: ${second.reason}`, raw, "Run with --debug to see the raw output, or try another preset.");
1501
+ }
1502
+ function parseOutput(raw) {
1503
+ let json;
1504
+ try {
1505
+ json = extractJson(raw);
1506
+ } catch (err) {
1507
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
1508
+ }
1509
+ const parsed = LlmTourOutputSchema.safeParse(json);
1510
+ if (!parsed.success) return { ok: false, reason: `JSON did not match the schema (${formatIssues(parsed.error)})` };
1511
+ return { ok: true, value: parsed.data };
1512
+ }
1513
+
1514
+ // src/render/cli.ts
1515
+ import pc from "picocolors";
1516
+ var MIN_WIDTH = 40;
1517
+ var MAX_WIDTH = 120;
1518
+ var CliRenderer = class {
1519
+ render(tour, opts) {
1520
+ const c = pc.createColors(opts.color);
1521
+ const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, opts.width));
1522
+ const out = [];
1523
+ out.push(...renderHeader(tour, c, width, opts.fromCache ?? false));
1524
+ out.push("");
1525
+ if (opts.section !== void 0) {
1526
+ const section = tour.sections[opts.section - 1];
1527
+ if (!section) {
1528
+ throw new UsageError(`No section ${opts.section}; this tour has ${tour.sections.length} section${tour.sections.length === 1 ? "" : "s"}.`);
1529
+ }
1530
+ out.push(...renderSection(section, opts.section, tour.sections.length, c, width));
1531
+ } else {
1532
+ out.push(...renderToc(tour, c, width));
1533
+ out.push("");
1534
+ tour.sections.forEach((s, i) => {
1535
+ out.push(...renderSection(s, i + 1, tour.sections.length, c, width));
1536
+ out.push("");
1537
+ });
1538
+ }
1539
+ return out.join("\n").replace(/\n+$/, "") + "\n";
1540
+ }
1541
+ };
1542
+ function renderHeader(tour, c, width, fromCache) {
1543
+ const lines = [];
1544
+ lines.push(c.bold(`\u{1F423} ${tour.title}`));
1545
+ lines.push(c.dim(describeSource2(tour.source)));
1546
+ if (tour.pullRequest) {
1547
+ lines.push(c.dim(`PR #${tour.pullRequest.number}: ${tour.pullRequest.title}${tour.pullRequest.url ? " " + tour.pullRequest.url : ""}`));
1548
+ }
1549
+ const parts = [statsLine(tour.stats, c), `${tour.sections.length} section${tour.sections.length === 1 ? "" : "s"}`];
1550
+ const meta = [];
1551
+ if (tour.generator.preset) meta.push(tour.generator.preset);
1552
+ else if (tour.generator.command[0]) meta.push(tour.generator.command[0]);
1553
+ meta.push(fromCache ? `cached ${relativeTime(tour.generatedAt)}` : "generated just now");
1554
+ lines.push(`${parts.join(c.dim(" \xB7 "))}${c.dim(" \xB7 " + meta.join(", "))}`);
1555
+ if (tour.summary) {
1556
+ lines.push("");
1557
+ lines.push(...wrap(tour.summary, width));
1558
+ }
1559
+ return lines;
1560
+ }
1561
+ function renderToc(tour, c, width) {
1562
+ const lines = [c.bold("Contents")];
1563
+ const numWidth = String(tour.sections.length).length;
1564
+ for (const [i, s] of tour.sections.entries()) {
1565
+ const n = String(i + 1).padStart(numWidth);
1566
+ const label = ` ${n}. ${s.title}`;
1567
+ const right = `${s.stats.files} file${s.stats.files === 1 ? "" : "s"} \xB7 ${statsLine(s.stats, c, true)}`;
1568
+ const rightPlain = `${s.stats.files} file${s.stats.files === 1 ? "" : "s"} \xB7 +${s.stats.additions} -${s.stats.deletions}`;
1569
+ const gap = Math.max(2, width - visibleLength(label) - rightPlain.length);
1570
+ lines.push(label + " ".repeat(gap) + c.dim(right));
1571
+ }
1572
+ return lines;
1573
+ }
1574
+ function renderSection(s, index, count, c, width) {
1575
+ const lines = [];
1576
+ lines.push(c.dim("\u2500".repeat(width)));
1577
+ lines.push(c.bold(`${index}. ${s.title}`) + c.dim(` (${index}/${count})`));
1578
+ lines.push(` ${c.dim(`${s.stats.files} file${s.stats.files === 1 ? "" : "s"} \xB7 `)}${statsLine(s.stats, c, true)}`);
1579
+ if (s.description) {
1580
+ lines.push("");
1581
+ lines.push(...wrap(s.description, width - 3).map((l) => l ? " " + l : ""));
1582
+ }
1583
+ if (s.files.length) {
1584
+ lines.push("");
1585
+ for (const f of s.files) lines.push(` ${c.cyan(f)}`);
1586
+ }
1587
+ for (const e of s.excerpts) {
1588
+ lines.push("");
1589
+ lines.push(...renderExcerpt(e, c, width));
1590
+ }
1591
+ return lines;
1592
+ }
1593
+ function renderExcerpt(e, c, width) {
1594
+ const lines = [];
1595
+ const title = ` ${c.cyan(e.file)}${c.dim(":" + e.newStart)}`;
1596
+ lines.push(e.note ? `${title} ${c.italic(c.dim(e.note))}` : title);
1597
+ const maxNo = Math.max(...e.lines.map((l) => Math.max(l.oldNo ?? 0, l.newNo ?? 0)), 1);
1598
+ const w = String(maxNo).length;
1599
+ const budget = Math.max(20, width - (3 + w * 2 + 5));
1600
+ for (const l of e.lines) {
1601
+ const oldNo = l.oldNo === void 0 ? " ".repeat(w) : String(l.oldNo).padStart(w);
1602
+ const newNo = l.newNo === void 0 ? " ".repeat(w) : String(l.newNo).padStart(w);
1603
+ const sign = l.type === "add" ? "+" : l.type === "del" ? "-" : " ";
1604
+ const text = truncate(expandTabs(l.text), budget);
1605
+ const gutter = c.dim(` ${oldNo} ${newNo} \u2502`);
1606
+ const body = `${sign}${text}`;
1607
+ lines.push(`${gutter}${l.type === "add" ? c.green(body) : l.type === "del" ? c.red(body) : body}`);
1608
+ }
1609
+ return lines;
1610
+ }
1611
+ function statsLine(stats, c, omitFiles = false) {
1612
+ const counts = `${c.green(`+${stats.additions}`)} ${c.red(`-${stats.deletions}`)}`;
1613
+ return omitFiles ? counts : `${stats.files} file${stats.files === 1 ? "" : "s"} \xB7 ${counts}`;
1614
+ }
1615
+ function describeSource2(source) {
1616
+ if (source.kind === "working") {
1617
+ return source.staged ? "Staged changes vs HEAD" : "Working tree vs HEAD (uncommitted changes)";
1618
+ }
1619
+ const how = source.resolvedBy === "pull-request" ? "base from pull request" : source.resolvedBy === "ancestor-branch" ? "nearest ancestor branch" : source.resolvedBy === "default-branch" ? "default branch" : null;
1620
+ const mb = source.mergeBase ? ` (merge-base ${source.mergeBase.slice(0, 7)})` : "";
1621
+ return `${source.head} vs ${source.base}${mb}${how ? ` \xB7 ${how}` : ""}`;
1622
+ }
1623
+ function wrap(text, width) {
1624
+ const out = [];
1625
+ for (const para of text.split(/\n\s*\n/)) {
1626
+ const words = para.split(/\s+/).filter(Boolean);
1627
+ let line = "";
1628
+ for (const word of words) {
1629
+ if (line && line.length + 1 + word.length > width) {
1630
+ out.push(line);
1631
+ line = word;
1632
+ } else {
1633
+ line = line ? `${line} ${word}` : word;
1634
+ }
1635
+ }
1636
+ if (line) out.push(line);
1637
+ out.push("");
1638
+ }
1639
+ while (out.length && out[out.length - 1] === "") out.pop();
1640
+ return out;
1641
+ }
1642
+ function truncate(s, max) {
1643
+ return s.length > max ? s.slice(0, Math.max(0, max - 1)) + "\u2026" : s;
1644
+ }
1645
+ function expandTabs(s) {
1646
+ return s.replaceAll(" ", " ");
1647
+ }
1648
+ function visibleLength(s) {
1649
+ return s.replace(/\x1b\[[0-9;]*m/g, "").length;
1650
+ }
1651
+ function relativeTime(iso, now = /* @__PURE__ */ new Date()) {
1652
+ const ms = now.getTime() - new Date(iso).getTime();
1653
+ if (!Number.isFinite(ms) || ms < 0) return "just now";
1654
+ const s = Math.round(ms / 1e3);
1655
+ if (s < 60) return "just now";
1656
+ const m = Math.round(s / 60);
1657
+ if (m < 60) return `${m} min ago`;
1658
+ const h = Math.round(m / 60);
1659
+ if (h < 48) return `${h} h ago`;
1660
+ const d = Math.round(h / 24);
1661
+ return `${d} days ago`;
1662
+ }
1663
+
1664
+ // src/render/pager.ts
1665
+ import { spawn as spawn2 } from "child_process";
1666
+ async function writeMaybePaged(output, opts) {
1667
+ const env = opts.env ?? process.env;
1668
+ const lineCount = output.split("\n").length;
1669
+ const shouldPage = opts.mode === "always" || opts.mode === "auto" && opts.isTTY && lineCount > opts.rows - 1;
1670
+ if (!shouldPage) {
1671
+ await writeStdout(output);
1672
+ return;
1673
+ }
1674
+ const pagerCmd = env.PAGER && env.PAGER.trim() || "less";
1675
+ const cmd = pagerCmd === "less" && !env.LESS ? "less -RFX" : pagerCmd;
1676
+ await new Promise((resolve) => {
1677
+ const child = spawn2(cmd, { shell: true, stdio: ["pipe", "inherit", "inherit"], env });
1678
+ let fellBack = false;
1679
+ child.on("error", () => {
1680
+ fellBack = true;
1681
+ process.stdout.write(output);
1682
+ resolve();
1683
+ });
1684
+ child.on("close", () => {
1685
+ if (!fellBack) resolve();
1686
+ });
1687
+ child.stdin.on("error", () => {
1688
+ });
1689
+ child.stdin.end(output);
1690
+ });
1691
+ }
1692
+ function writeStdout(output) {
1693
+ return new Promise((resolve) => {
1694
+ process.stdout.write(output, () => resolve());
1695
+ });
1696
+ }
1697
+
1698
+ export {
1699
+ LlmTourOutputSchema,
1700
+ TourSchema,
1701
+ formatIssues,
1702
+ BbError,
1703
+ UsageError,
1704
+ ConfigError,
1705
+ NotARepoError,
1706
+ GitError,
1707
+ NoChangesError,
1708
+ LlmFailedError,
1709
+ BadLlmOutputError,
1710
+ BUILTIN_PRESETS,
1711
+ ConfigSchema,
1712
+ PartialConfigSchema,
1713
+ DEFAULT_CONFIG,
1714
+ userConfigPath,
1715
+ defaultCacheDir,
1716
+ projectConfigPath,
1717
+ deepMerge,
1718
+ loadConfig,
1719
+ allPresets,
1720
+ resolveLlm,
1721
+ shellSplit,
1722
+ TourCache,
1723
+ extractJson,
1724
+ parseDiff,
1725
+ diffStats,
1726
+ OTHER_CHANGES_TITLE,
1727
+ materializeTour,
1728
+ sliceHunk,
1729
+ PROMPT_VERSION,
1730
+ buildPrompt,
1731
+ describeSource,
1732
+ GhCodeHost,
1733
+ NoCodeHost,
1734
+ createCodeHost,
1735
+ gitRoot,
1736
+ revParse,
1737
+ currentBranch,
1738
+ collectDiff,
1739
+ collectCommits,
1740
+ resolveRange,
1741
+ detectDefaultBranch,
1742
+ CommandProvider,
1743
+ createProvider,
1744
+ prepareTour,
1745
+ generateTour,
1746
+ CliRenderer,
1747
+ describeSource2,
1748
+ wrap,
1749
+ relativeTime,
1750
+ writeMaybePaged,
1751
+ writeStdout
1752
+ };
1753
+ //# sourceMappingURL=chunk-RWSCST2I.js.map