@inceptionstack/pi-hard-no 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/context.ts ADDED
@@ -0,0 +1,658 @@
1
+ /**
2
+ * context.ts — Build rich review context
3
+ *
4
+ * Gathers: file tree, changed files list, per-file diffs, per-file commits.
5
+ * The reviewer reads full file contents itself via tools.
6
+ * Falls back gracefully when git is unavailable.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import { truncateDiff } from "./helpers";
11
+ import { filterIgnored } from "./ignore";
12
+ import { log } from "./logger";
13
+ import {
14
+ type TrackedToolCall,
15
+ buildChangeSummary,
16
+ collectModifiedPaths,
17
+ isBinaryPath,
18
+ hasGitCommitCommand,
19
+ } from "./changes";
20
+
21
+ export interface ReviewContext {
22
+ diff: string;
23
+ changedFiles: string[];
24
+ fileTree: string;
25
+ commitLog: string;
26
+ }
27
+
28
+ /**
29
+ * Size limits for content gathering.
30
+ * The "large" profile targets ~800k chars (~200k tokens) for models with 1M+ context.
31
+ * The "fallback" profile targets ~120k chars (~30k tokens) for 200k context models
32
+ * or when the large profile triggers a context-too-long error.
33
+ */
34
+ export interface ContentSizeLimits {
35
+ maxFileSize: number;
36
+ maxTotalContentSize: number;
37
+ maxDiffSize: number;
38
+ }
39
+
40
+ export const LARGE_LIMITS: ContentSizeLimits = {
41
+ maxFileSize: 80_000,
42
+ maxTotalContentSize: 400_000,
43
+ maxDiffSize: 200_000,
44
+ };
45
+
46
+ export const FALLBACK_LIMITS: ContentSizeLimits = {
47
+ maxFileSize: 10_000,
48
+ maxTotalContentSize: 60_000,
49
+ maxDiffSize: 30_000,
50
+ };
51
+
52
+ /**
53
+ * Build full review context from the current working directory.
54
+ */
55
+ export async function buildReviewContext(
56
+ pi: ExtensionAPI,
57
+ onStatus?: (msg: string) => void,
58
+ ignorePatterns?: string[],
59
+ _limits?: ContentSizeLimits,
60
+ ): Promise<ReviewContext | null> {
61
+ onStatus?.("getting diff…");
62
+
63
+ const fullDiffResult = await pi.exec("git", ["diff", "HEAD"], { timeout: 15000 });
64
+ let diff = fullDiffResult.code === 0 ? fullDiffResult.stdout.trim() : "";
65
+
66
+ onStatus?.("listing changed files…");
67
+ let changedFiles = await listDiffFiles(pi, ".", "HEAD");
68
+
69
+ // Include untracked (new) files
70
+ const untrackedResult = await pi.exec("git", ["ls-files", "--others", "--exclude-standard"], {
71
+ timeout: 5000,
72
+ });
73
+ if (untrackedResult.code === 0 && untrackedResult.stdout.trim()) {
74
+ const untracked = untrackedResult.stdout.trim().split("\n").filter(Boolean);
75
+ const existing = new Set(changedFiles);
76
+ for (const f of untracked) {
77
+ if (!existing.has(f)) changedFiles.push(f);
78
+ }
79
+ }
80
+
81
+ if (!diff && changedFiles.length === 0) return null;
82
+
83
+ if (ignorePatterns && ignorePatterns.length > 0) {
84
+ const before = changedFiles.length;
85
+ changedFiles = filterIgnored(changedFiles, ignorePatterns);
86
+ if (changedFiles.length < before) {
87
+ onStatus?.(`filtered ${before - changedFiles.length} ignored files`);
88
+ }
89
+ }
90
+
91
+ if (changedFiles.length === 0) return null;
92
+
93
+ if (ignorePatterns && ignorePatterns.length > 0) {
94
+ const filteredDiffResult = await pi.exec("git", ["diff", "HEAD", "--", ...changedFiles], {
95
+ timeout: 15000,
96
+ });
97
+ if (filteredDiffResult.code === 0 && filteredDiffResult.stdout.trim()) {
98
+ diff = filteredDiffResult.stdout.trim();
99
+ }
100
+ }
101
+
102
+ onStatus?.("scanning file tree…");
103
+ const treeResult = await pi.exec(
104
+ "find",
105
+ [
106
+ ".",
107
+ "-maxdepth",
108
+ "3",
109
+ "-not",
110
+ "-path",
111
+ "*/node_modules/*",
112
+ "-not",
113
+ "-path",
114
+ "*/.git/*",
115
+ "-not",
116
+ "-path",
117
+ "*/dist/*",
118
+ ],
119
+ { timeout: 5000 },
120
+ );
121
+ const fileTree = treeResult.code === 0 ? treeResult.stdout.trim() : "(file tree unavailable)";
122
+
123
+ onStatus?.("getting commit history…");
124
+ const commitLogResult = await pi.exec("git", ["log", "--oneline", "-10"], { timeout: 5000 });
125
+ const commitLog = commitLogResult.code === 0 ? commitLogResult.stdout.trim() : "";
126
+
127
+ return { diff, changedFiles, fileTree, commitLog };
128
+ }
129
+
130
+ /**
131
+ * Format the review context into a prompt section.
132
+ */
133
+ export function formatReviewContext(ctx: ReviewContext, limits?: ContentSizeLimits): string {
134
+ const maxDiff = (limits ?? LARGE_LIMITS).maxDiffSize;
135
+ const parts: string[] = [];
136
+
137
+ parts.push(`## Changed files (${ctx.changedFiles.length})\n`);
138
+ for (const f of ctx.changedFiles) {
139
+ parts.push(`- ${f}`);
140
+ }
141
+
142
+ parts.push(`\n## Files to review\n`);
143
+ parts.push(
144
+ `Read each file with read(path) to see its full contents, then review using the diff below.\n`,
145
+ );
146
+ for (const f of ctx.changedFiles) {
147
+ parts.push(`### ${f}\n**Full path:** \`${f}\`\n`);
148
+ }
149
+
150
+ parts.push(`## Git diff\n\`\`\`diff\n${truncateDiff(ctx.diff, maxDiff)}\n\`\`\`\n`);
151
+
152
+ if (ctx.commitLog) {
153
+ parts.push(`## Recent commits\n\`\`\`\n${ctx.commitLog}\n\`\`\`\n`);
154
+ }
155
+
156
+ parts.push(`## Project file tree (depth 3)\n\`\`\`\n${ctx.fileTree.slice(0, 5000)}\n\`\`\`\n`);
157
+
158
+ return parts.join("\n");
159
+ }
160
+
161
+ export interface ReviewContent {
162
+ content: string;
163
+ label: string;
164
+ files: string[];
165
+ /** True when the content was gathered from a git repository (diff, commit log, etc.) */
166
+ isGitBased: boolean;
167
+ }
168
+
169
+ // ── Helper: format tool call summary section ────────
170
+
171
+ function buildSummarySection(agentToolCalls: TrackedToolCall[]): {
172
+ summarySection: string;
173
+ changeSummary: string;
174
+ } {
175
+ const changeSummary = buildChangeSummary(agentToolCalls);
176
+ const summarySection = changeSummary.trim()
177
+ ? `\n\n---\n\n## Agent tool calls (what was changed)\n\n${changeSummary}`
178
+ : "";
179
+ return { summarySection, changeSummary };
180
+ }
181
+
182
+ // ── Path 1: git diff from known git roots ───────────
183
+
184
+ /**
185
+ * Try to build review content from each known git root.
186
+ * For each root: get diff + untracked files, read full contents, include commit log.
187
+ */
188
+ export async function getContentFromGitRoots(
189
+ pi: ExtensionAPI,
190
+ gitRoots: Set<string>,
191
+ ignorePatterns: string[] | undefined,
192
+ summarySection: string,
193
+ onStatus?: (msg: string) => void,
194
+ limits?: ContentSizeLimits,
195
+ allowLastCommitFallback = false,
196
+ ): Promise<ReviewContent | null> {
197
+ const allContexts: string[] = [];
198
+ const allFiles: string[] = [];
199
+
200
+ for (const root of gitRoots) {
201
+ onStatus?.(`checking ${root}…`);
202
+ const repoContext = await buildRepoContext(
203
+ pi,
204
+ root,
205
+ ignorePatterns,
206
+ onStatus,
207
+ limits,
208
+ allowLastCommitFallback,
209
+ );
210
+ if (!repoContext) continue;
211
+
212
+ allFiles.push(...repoContext.files.map((f) => `${root}/${f}`));
213
+ allContexts.push(repoContext.text);
214
+ }
215
+
216
+ if (allContexts.length === 0) return null;
217
+
218
+ log("path1: returning", allContexts.length, "repo(s)", "files=", allFiles);
219
+ return {
220
+ content: allContexts.join("\n\n---\n\n") + summarySection,
221
+ label: `${allContexts.length} repo(s)`,
222
+ files: allFiles,
223
+ isGitBased: true,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Build context text for a single git repo.
229
+ * Tries uncommitted changes first, falls back to last commit, then untracked-only.
230
+ */
231
+ async function buildRepoContext(
232
+ pi: ExtensionAPI,
233
+ root: string,
234
+ ignorePatterns: string[] | undefined,
235
+ onStatus?: (msg: string) => void,
236
+ limits?: ContentSizeLimits,
237
+ allowLastCommitFallback = false,
238
+ ): Promise<{ text: string; files: string[] } | null> {
239
+ const lim = limits ?? LARGE_LIMITS;
240
+ let diff = "";
241
+ let files: string[] = [];
242
+ let commitLabel = "";
243
+ const untrackedFiles = new Set<string>();
244
+
245
+ // Always check for untracked (new) files first — these are invisible to git diff
246
+ const untracked = await listUntrackedFiles(pi, root);
247
+ for (const f of untracked) untrackedFiles.add(f);
248
+
249
+ // Try uncommitted changes (staged + unstaged vs HEAD)
250
+ const result = await pi.exec("git", ["-C", root, "diff", "HEAD"], { timeout: 15000 });
251
+ if (result.code === 0 && result.stdout.trim()) {
252
+ diff = result.stdout.trim();
253
+ files = await listDiffFiles(pi, root, "HEAD");
254
+
255
+ // Merge in untracked files
256
+ const existing = new Set(files);
257
+ for (const f of untracked) {
258
+ if (!existing.has(f)) files.push(f);
259
+ }
260
+ } else if (untracked.length > 0) {
261
+ // No tracked changes but we have untracked files — use those directly
262
+ // (don't fall through to last-commit which would review stale files)
263
+ files = [...untracked];
264
+ } else if (allowLastCommitFallback) {
265
+ // Clean tree after an agent-created commit: review the commit.
266
+ const lastResult = await pi.exec("git", ["-C", root, "diff", "HEAD~1", "HEAD"], {
267
+ timeout: 15000,
268
+ });
269
+ if (lastResult.code === 0 && lastResult.stdout.trim()) {
270
+ diff = lastResult.stdout.trim();
271
+ const logResult = await pi.exec("git", ["-C", root, "log", "--oneline", "-1"], {
272
+ timeout: 5000,
273
+ });
274
+ commitLabel = ` (last commit: ${logResult.stdout.trim()})`;
275
+ files = await listDiffFiles(pi, root, "HEAD~1", "HEAD");
276
+ }
277
+ }
278
+
279
+ // If still nothing found, bail out
280
+ if (files.length === 0 && !diff) return null;
281
+
282
+ const filteredFiles = ignorePatterns ? filterIgnored(files, ignorePatterns) : files;
283
+ if (filteredFiles.length === 0) return null;
284
+
285
+ // Determine the diff range for per-file diffs
286
+ const diffRange = commitLabel ? ["HEAD~1", "HEAD"] : ["HEAD"];
287
+
288
+ // Build per-file review context (path, diff, commits — reviewer reads files itself)
289
+ const perFileSections = await buildPerFileContext(
290
+ pi,
291
+ root,
292
+ filteredFiles,
293
+ diffRange,
294
+ untrackedFiles,
295
+ lim,
296
+ onStatus,
297
+ );
298
+
299
+ log(
300
+ "path1: root=",
301
+ root,
302
+ "diff=",
303
+ diff.length,
304
+ "files=",
305
+ filteredFiles,
306
+ "perFileSections=",
307
+ perFileSections.length,
308
+ );
309
+
310
+ const fileList = filteredFiles.map((f) => (untrackedFiles.has(f) ? `${f} (new)` : f)).join(", ");
311
+
312
+ const text =
313
+ `## Repo: ${root}${commitLabel}\n\n` +
314
+ `Changed files: ${fileList}\n\n` +
315
+ `## Files to review\n\nRead each file with read(path) to see its full contents, then review using the diff and commits below.\n\n` +
316
+ perFileSections.join("\n\n---\n\n");
317
+
318
+ return { text, files: filteredFiles };
319
+ }
320
+
321
+ /** List files changed in a git diff range, excluding deleted files. */
322
+ export async function listDiffFiles(
323
+ pi: ExtensionAPI,
324
+ root: string,
325
+ ...range: string[]
326
+ ): Promise<string[]> {
327
+ const result = await pi.exec(
328
+ "git",
329
+ ["-C", root, "diff", "--diff-filter=d", ...range, "--name-only"],
330
+ {
331
+ timeout: 5000,
332
+ },
333
+ );
334
+ return result.code === 0 ? result.stdout.trim().split("\n").filter(Boolean) : [];
335
+ }
336
+
337
+ /** List untracked files in a git repo. */
338
+ async function listUntrackedFiles(pi: ExtensionAPI, root: string): Promise<string[]> {
339
+ const result = await pi.exec("git", ["-C", root, "ls-files", "--others", "--exclude-standard"], {
340
+ timeout: 5000,
341
+ });
342
+ return result.code === 0 ? result.stdout.trim().split("\n").filter(Boolean) : [];
343
+ }
344
+
345
+ /** Get git diff for a single file. */
346
+ async function getFileDiff(
347
+ pi: ExtensionAPI,
348
+ root: string,
349
+ file: string,
350
+ range: string[],
351
+ ): Promise<string> {
352
+ const result = await pi.exec("git", ["-C", root, "diff", ...range, "--", file], {
353
+ timeout: 10000,
354
+ });
355
+ return result.code === 0 ? result.stdout.trim() : "";
356
+ }
357
+
358
+ /** Get commit messages that touched a specific file (last 5). */
359
+ async function getFileCommits(pi: ExtensionAPI, root: string, file: string): Promise<string> {
360
+ const result = await pi.exec("git", ["-C", root, "log", "--oneline", "-5", "--", file], {
361
+ timeout: 5000,
362
+ });
363
+ return result.code === 0 ? result.stdout.trim() : "";
364
+ }
365
+
366
+ /**
367
+ * Build per-file review context: path, diff, commits.
368
+ * The reviewer will read each file itself using tools.
369
+ */
370
+ export async function buildPerFileContext(
371
+ pi: ExtensionAPI,
372
+ root: string,
373
+ files: string[],
374
+ diffRange: string[],
375
+ untrackedFiles: Set<string>,
376
+ limits: ContentSizeLimits,
377
+ onStatus?: (msg: string) => void,
378
+ ): Promise<string[]> {
379
+ const sections: string[] = [];
380
+
381
+ for (const file of files) {
382
+ const fullPath = `${root}/${file}`;
383
+ onStatus?.(`gathering context for ${file}…`);
384
+
385
+ const isNew = untrackedFiles.has(file);
386
+ const newLabel = isNew ? " (new file)" : "";
387
+
388
+ // Get per-file diff
389
+ let fileDiff = "";
390
+ if (!isNew) {
391
+ fileDiff = await getFileDiff(pi, root, file, diffRange);
392
+ }
393
+
394
+ // Get commit messages for this file
395
+ const commits = await getFileCommits(pi, root, file);
396
+
397
+ let section = `### ${fullPath}${newLabel}\n`;
398
+ section += `**Full path:** \`${fullPath}\`\n`;
399
+
400
+ if (commits) {
401
+ section += `\n**Recent commits:**\n\`\`\`\n${commits}\n\`\`\`\n`;
402
+ }
403
+
404
+ if (fileDiff) {
405
+ const truncated = truncateDiff(fileDiff, limits.maxDiffSize);
406
+ section += `\n**Diff:**\n\`\`\`diff\n${truncated}\n\`\`\`\n`;
407
+ } else if (isNew) {
408
+ section += `\n*New file — no diff available. Read the file to review its contents.*\n`;
409
+ }
410
+
411
+ sections.push(section);
412
+ }
413
+
414
+ return sections;
415
+ }
416
+
417
+ // ── Path 2: cwd as git repo (full buildReviewContext) ──
418
+
419
+ export async function getContentFromCwd(
420
+ pi: ExtensionAPI,
421
+ ignorePatterns: string[] | undefined,
422
+ summarySection: string,
423
+ onStatus?: (msg: string) => void,
424
+ limits?: ContentSizeLimits,
425
+ ): Promise<ReviewContent | null> {
426
+ const lim = limits ?? LARGE_LIMITS;
427
+ const reviewContext = await buildReviewContext(pi, onStatus, ignorePatterns, lim);
428
+ if (!reviewContext) return null;
429
+
430
+ log("path2: cwd git repo, files=", reviewContext.changedFiles);
431
+ return {
432
+ content: formatReviewContext(reviewContext, lim) + summarySection,
433
+ label: "",
434
+ files: reviewContext.changedFiles,
435
+ isGitBased: true,
436
+ };
437
+ }
438
+
439
+ // ── Path 3: last commit from cwd ─────────────────────
440
+
441
+ export async function getContentFromLastCommit(
442
+ pi: ExtensionAPI,
443
+ ignorePatterns: string[] | undefined,
444
+ summarySection: string,
445
+ onStatus?: (msg: string) => void,
446
+ limits?: ContentSizeLimits,
447
+ ): Promise<ReviewContent | null> {
448
+ const lim = limits ?? LARGE_LIMITS;
449
+ onStatus?.("checking last commit…");
450
+ try {
451
+ const lastCommitDiff = await pi.exec("git", ["diff", "HEAD~1", "HEAD"], { timeout: 15000 });
452
+ if (lastCommitDiff.code !== 0 || !lastCommitDiff.stdout.trim()) return null;
453
+
454
+ const commitLog = (
455
+ await pi.exec("git", ["log", "--oneline", "-10"], { timeout: 5000 })
456
+ ).stdout.trim();
457
+ let files = await listDiffFiles(pi, ".", "HEAD~1", "HEAD");
458
+
459
+ // Apply ignore patterns so the last-commit fallback respects .hardno/ignore
460
+ if (ignorePatterns && ignorePatterns.length > 0) {
461
+ files = filterIgnored(files, ignorePatterns);
462
+ }
463
+ if (files.length === 0) return null;
464
+
465
+ // Re-scope diff to filtered files only
466
+ let diff = lastCommitDiff.stdout.trim();
467
+ if (ignorePatterns && ignorePatterns.length > 0) {
468
+ const scopedResult = await pi.exec("git", ["diff", "HEAD~1", "HEAD", "--", ...files], {
469
+ timeout: 15000,
470
+ });
471
+ if (scopedResult.code === 0 && scopedResult.stdout.trim()) {
472
+ diff = scopedResult.stdout.trim();
473
+ }
474
+ }
475
+
476
+ const truncated = truncateDiff(diff, lim.maxDiffSize);
477
+
478
+ // Build per-file sections with paths (reviewer reads files itself)
479
+ const fileSection = files.map((f) => `### ${f}\n**Full path:** \`${f}\``).join("\n\n");
480
+
481
+ log("path3: last commit, files=", files);
482
+ return {
483
+ content: `## Recent commits\n\`\`\`\n${commitLog}\n\`\`\`\n\n## Files to review\n\nRead each file with read(path) to see its full contents.\n\n${fileSection}\n\n## Diff\n\`\`\`diff\n${truncated}\n\`\`\`${summarySection}`,
484
+ label: "last commit",
485
+ files,
486
+ isGitBased: true,
487
+ };
488
+ } catch {
489
+ return null;
490
+ }
491
+ }
492
+
493
+ // ── Path 4: read files directly from tool calls (no git) ──
494
+
495
+ export async function getContentFromToolCalls(
496
+ pi: ExtensionAPI,
497
+ agentToolCalls: TrackedToolCall[],
498
+ changeSummary: string,
499
+ onStatus?: (msg: string) => void,
500
+ _limits?: ContentSizeLimits,
501
+ ): Promise<ReviewContent | null> {
502
+ if (agentToolCalls.length === 0) return null;
503
+
504
+ const candidatePaths = collectModifiedPaths(agentToolCalls);
505
+ let reviewedFiles: string[] = [];
506
+
507
+ for (const filePath of candidatePaths) {
508
+ if (isBinaryPath(filePath)) continue;
509
+
510
+ onStatus?.(`checking ${filePath}…`);
511
+ try {
512
+ // Just verify the file exists and is readable
513
+ const result = await pi.exec("test", ["-r", filePath], { timeout: 5000 });
514
+ if (result.code !== 0) continue;
515
+ reviewedFiles.push(filePath);
516
+ } catch {
517
+ // skip unreadable files
518
+ }
519
+ }
520
+
521
+ // Cross-check against git: for files inside a git repo, only keep them
522
+ // if they actually have uncommitted changes or are untracked. Files NOT in
523
+ // any git repo keep using the heuristic (exist + were in a modifying command).
524
+ // This prevents read-only commands (rg, grep, cat) from falsely including
525
+ // files they merely inspected.
526
+ if (reviewedFiles.length > 0) {
527
+ const verified: string[] = [];
528
+ // Cache git status per git root to avoid redundant calls
529
+ const rootCache = new Map<string, Set<string> | null>(); // gitRoot → changed files
530
+ const dirToRoot = new Map<string, string | null>(); // dir → gitRoot (or null)
531
+
532
+ for (const f of reviewedFiles) {
533
+ const dir = f.includes("/") ? f.slice(0, f.lastIndexOf("/")) || "." : ".";
534
+
535
+ // Resolve dir → git root (cached)
536
+ if (!dirToRoot.has(dir)) {
537
+ const rootCheck = await pi.exec("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
538
+ timeout: 3000,
539
+ });
540
+ dirToRoot.set(dir, rootCheck.code === 0 ? rootCheck.stdout.trim() : null);
541
+ }
542
+ const root = dirToRoot.get(dir) ?? null;
543
+
544
+ if (root === null) {
545
+ // Not in a git repo — keep using heuristic
546
+ verified.push(f);
547
+ continue;
548
+ }
549
+
550
+ // Get git status for this root (cached)
551
+ if (!rootCache.has(root)) {
552
+ const gitStatus = await pi.exec("git", ["-C", root, "status", "--porcelain", "-uall"], {
553
+ timeout: 5000,
554
+ });
555
+ rootCache.set(
556
+ root,
557
+ gitStatus.code === 0
558
+ ? new Set(
559
+ gitStatus.stdout
560
+ .trim()
561
+ .split("\n")
562
+ .filter(Boolean)
563
+ .map((line) => `${root}/${line.slice(3).trim()}`),
564
+ )
565
+ : null,
566
+ );
567
+ }
568
+ const changed = rootCache.get(root) ?? null;
569
+
570
+ if (changed === null) {
571
+ // git status failed — keep using heuristic
572
+ verified.push(f);
573
+ } else if (
574
+ changed.has(f) ||
575
+ [...changed].some((c) => f === c || f.endsWith("/" + c) || c.endsWith("/" + f))
576
+ ) {
577
+ // In a git repo AND actually changed
578
+ verified.push(f);
579
+ }
580
+ // In a git repo but NOT changed — skip (read-only inspection)
581
+ }
582
+ reviewedFiles = verified;
583
+ }
584
+
585
+ // No readable files remain — a bash summary alone (e.g. "rm foo.ts") is not reviewable content
586
+ if (reviewedFiles.length === 0) return null;
587
+
588
+ const fileSection = reviewedFiles.map((f) => `### ${f}\n**Full path:** \`${f}\``).join("\n\n");
589
+
590
+ const content = [
591
+ reviewedFiles.length > 0
592
+ ? `## Files to review (no git)\n\nRead each file with read(path) to see its full contents.\n\n${fileSection}`
593
+ : "",
594
+ changeSummary.trim() ? `## Tool call summary\n\n${changeSummary}` : "",
595
+ ]
596
+ .filter(Boolean)
597
+ .join("\n\n---\n\n");
598
+
599
+ return { content, label: "tracked changes", files: reviewedFiles, isGitBased: false };
600
+ }
601
+
602
+ // ── Main entry: try each path in order ───────────────
603
+
604
+ /**
605
+ * Get the best available review content.
606
+ * Tries: git roots → cwd git repo → last commit → tool call summaries.
607
+ * All size limits are threaded explicitly to sub-functions.
608
+ */
609
+ export async function getBestReviewContent(
610
+ pi: ExtensionAPI,
611
+ agentToolCalls: TrackedToolCall[],
612
+ onStatus?: (msg: string) => void,
613
+ ignorePatterns?: string[],
614
+ gitRoots?: Set<string>,
615
+ limits?: ContentSizeLimits,
616
+ ): Promise<ReviewContent | null> {
617
+ const lim = limits ?? LARGE_LIMITS;
618
+
619
+ log(
620
+ "getBestReviewContent: gitRoots=",
621
+ gitRoots ? [...gitRoots] : "none",
622
+ "toolCalls=",
623
+ agentToolCalls.length,
624
+ );
625
+
626
+ const { summarySection, changeSummary } = buildSummarySection(agentToolCalls);
627
+
628
+ const allowLastCommitFallback = hasGitCommitCommand(agentToolCalls);
629
+
630
+ if (gitRoots && gitRoots.size > 0) {
631
+ const result = await getContentFromGitRoots(
632
+ pi,
633
+ gitRoots,
634
+ ignorePatterns,
635
+ summarySection,
636
+ onStatus,
637
+ lim,
638
+ allowLastCommitFallback,
639
+ );
640
+ if (result) return result;
641
+ }
642
+
643
+ const cwdResult = await getContentFromCwd(pi, ignorePatterns, summarySection, onStatus, lim);
644
+ if (cwdResult) return cwdResult;
645
+
646
+ if (allowLastCommitFallback) {
647
+ const lastCommitResult = await getContentFromLastCommit(
648
+ pi,
649
+ ignorePatterns,
650
+ summarySection,
651
+ onStatus,
652
+ lim,
653
+ );
654
+ if (lastCommitResult) return lastCommitResult;
655
+ }
656
+
657
+ return getContentFromToolCalls(pi, agentToolCalls, changeSummary, onStatus, lim);
658
+ }