bosun 0.42.0 → 0.42.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.env.example +12 -0
  2. package/README.md +2 -0
  3. package/agent/agent-pool.mjs +34 -1
  4. package/agent/agent-work-report.mjs +89 -3
  5. package/agent/analyze-agent-work-helpers.mjs +14 -0
  6. package/agent/analyze-agent-work.mjs +23 -3
  7. package/agent/primary-agent.mjs +23 -1
  8. package/bosun-tui.mjs +4 -3
  9. package/bosun.schema.json +1 -1
  10. package/config/config.mjs +58 -0
  11. package/config/workspace-health.mjs +36 -6
  12. package/git/diff-stats.mjs +550 -124
  13. package/github/github-app-auth.mjs +9 -5
  14. package/infra/maintenance.mjs +13 -6
  15. package/infra/monitor.mjs +398 -10
  16. package/infra/runtime-accumulator.mjs +9 -1
  17. package/infra/session-tracker.mjs +163 -1
  18. package/infra/tui-bridge.mjs +415 -0
  19. package/infra/worktree-recovery-state.mjs +159 -0
  20. package/kanban/kanban-adapter.mjs +41 -8
  21. package/lib/repo-map.mjs +411 -0
  22. package/package.json +140 -137
  23. package/server/ui-server.mjs +953 -59
  24. package/shell/codex-config.mjs +34 -8
  25. package/task/task-cli.mjs +93 -19
  26. package/task/task-executor.mjs +397 -8
  27. package/task/task-store.mjs +194 -1
  28. package/telegram/telegram-bot.mjs +267 -18
  29. package/tools/vitest-runner.mjs +108 -0
  30. package/tui/app.mjs +252 -148
  31. package/tui/components/status-header.mjs +88 -131
  32. package/tui/lib/ws-bridge.mjs +125 -35
  33. package/tui/screens/agents-screen-helpers.mjs +219 -0
  34. package/tui/screens/agents.mjs +287 -270
  35. package/tui/screens/status.mjs +51 -189
  36. package/tui/screens/tasks.mjs +41 -253
  37. package/ui/app.js +52 -23
  38. package/ui/components/chat-view.js +263 -84
  39. package/ui/components/diff-viewer.js +324 -140
  40. package/ui/components/kanban-board.js +13 -9
  41. package/ui/components/session-list.js +111 -41
  42. package/ui/demo-defaults.js +481 -59
  43. package/ui/demo.html +32 -0
  44. package/ui/modules/session-api.js +320 -5
  45. package/ui/modules/stream-timeline.js +356 -0
  46. package/ui/modules/telegram.js +5 -2
  47. package/ui/modules/worktree-recovery.js +85 -0
  48. package/ui/styles.css +44 -0
  49. package/ui/tabs/chat.js +19 -4
  50. package/ui/tabs/dashboard.js +22 -0
  51. package/ui/tabs/infra.js +25 -0
  52. package/ui/tabs/tasks.js +119 -11
  53. package/voice/voice-auth-manager.mjs +10 -5
  54. package/workflow/workflow-engine.mjs +179 -1
  55. package/workflow/workflow-nodes.mjs +872 -16
  56. package/workflow/workflow-templates.mjs +4 -0
  57. package/workflow-templates/github.mjs +2 -1
  58. package/workflow-templates/planning.mjs +2 -1
  59. package/workflow-templates/sub-workflows.mjs +10 -0
  60. package/workflow-templates/task-batch.mjs +9 -8
  61. package/workflow-templates/task-execution.mjs +30 -12
  62. package/workflow-templates/task-lifecycle.mjs +59 -4
  63. package/workspace/shared-knowledge.mjs +409 -155
@@ -1,10 +1,5 @@
1
1
  /**
2
- * diff-stats.mjs — Collects git diff statistics for review handoff.
3
- *
4
- * Produces a tree of file changes with +/- line counts, like:
5
- * monitor.mjs +890 -750
6
- * task-executor.mjs +320 -180
7
- * review-agent.mjs +680 -528
2
+ * diff-stats.mjs — Collects git diff statistics and patch hunks for review UIs.
8
3
  *
9
4
  * @module diff-stats
10
5
  */
@@ -13,12 +8,38 @@ import { spawnSync } from "node:child_process";
13
8
 
14
9
  const TAG = "[diff-stats]";
15
10
 
11
+ /**
12
+ * @typedef {Object} DiffLine
13
+ * @property {"context"|"addition"|"deletion"|"meta"} type
14
+ * @property {number|null} oldNumber
15
+ * @property {number|null} newNumber
16
+ * @property {string} marker
17
+ * @property {string} content
18
+ * @property {string} raw
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} DiffHunk
23
+ * @property {string} header
24
+ * @property {number} oldStart
25
+ * @property {number} oldLines
26
+ * @property {number} newStart
27
+ * @property {number} newLines
28
+ * @property {DiffLine[]} lines
29
+ */
30
+
16
31
  /**
17
32
  * @typedef {Object} FileChangeStats
18
- * @property {string} file - Relative file path
19
- * @property {number} additions - Lines added
20
- * @property {number} deletions - Lines deleted
21
- * @property {boolean} binary - True if binary file
33
+ * @property {string} file
34
+ * @property {string} filename
35
+ * @property {string} oldFilename
36
+ * @property {string} newFilename
37
+ * @property {string} status
38
+ * @property {number} additions
39
+ * @property {number} deletions
40
+ * @property {boolean} binary
41
+ * @property {string} patch
42
+ * @property {DiffHunk[]} hunks
22
43
  */
23
44
 
24
45
  /**
@@ -27,70 +48,50 @@ const TAG = "[diff-stats]";
27
48
  * @property {number} totalFiles
28
49
  * @property {number} totalAdditions
29
50
  * @property {number} totalDeletions
30
- * @property {string} formatted - Human-readable summary string
51
+ * @property {string} formatted
52
+ * @property {string} [sourceRange]
31
53
  */
32
54
 
33
- // ── Main Collectors ─────────────────────────────────────────────────────────
34
-
35
55
  /**
36
- * Collect diff stats for a worktree (vs origin/main or the upstream branch).
56
+ * Collect diff stats for a worktree and optionally include parsed hunks.
37
57
  *
38
- * Tries three strategies in order:
39
- * 1. `git diff --numstat origin/main...HEAD`
40
- * 2. `git diff --numstat HEAD~10...HEAD` (last 10 commits)
41
- * 3. `git diff --stat origin/main...HEAD` (parsed stat output)
42
- *
43
- * @param {string} worktreePath - Path to the git worktree
58
+ * @param {string} worktreePath
44
59
  * @param {Object} [options]
45
- * @param {string} [options.baseBranch="origin/main"] - Branch to diff against
60
+ * @param {string} [options.baseBranch="origin/main"]
61
+ * @param {string} [options.targetRef="HEAD"]
62
+ * @param {string} [options.range]
46
63
  * @param {number} [options.timeoutMs=30000]
64
+ * @param {boolean} [options.includePatch=false]
65
+ * @param {number} [options.contextLines=3]
47
66
  * @returns {DiffStats}
48
67
  */
49
68
  export function collectDiffStats(worktreePath, options = {}) {
50
69
  const {
51
70
  baseBranch = "origin/main",
71
+ targetRef = "HEAD",
72
+ range = "",
52
73
  timeoutMs = 30_000,
74
+ includePatch = false,
75
+ contextLines = 3,
53
76
  } = options;
54
77
 
55
- // Strategy 1: --numstat (most reliable)
56
- const numstat = tryNumstat(worktreePath, `${baseBranch}...HEAD`, timeoutMs);
57
- if (numstat) return buildResult(numstat);
78
+ const candidates = buildRangeCandidates({ baseBranch, targetRef, range });
58
79
 
59
- // Strategy 2: last N commits
60
- const recent = tryNumstat(worktreePath, "HEAD~10...HEAD", timeoutMs);
61
- if (recent) return buildResult(recent);
62
-
63
- // Strategy 3: --stat fallback
64
- const stat = tryStat(worktreePath, `${baseBranch}...HEAD`, timeoutMs);
65
- if (stat) return buildResult(stat);
66
-
67
- // Strategy 4: staged + unstaged changes
68
- const working = tryNumstat(worktreePath, "HEAD", timeoutMs);
69
- if (working) return buildResult(working);
80
+ for (const candidate of candidates) {
81
+ const result = collectDiffForRange(worktreePath, candidate, {
82
+ timeoutMs,
83
+ includePatch,
84
+ contextLines,
85
+ });
86
+ if (result) return result;
87
+ }
70
88
 
71
- // Nothing worked
72
- return {
73
- files: [],
74
- totalFiles: 0,
75
- totalAdditions: 0,
76
- totalDeletions: 0,
77
- formatted: "(no diff stats available)",
78
- };
89
+ return emptyDiffStats("(no diff stats available)");
79
90
  }
80
91
 
81
92
  /**
82
93
  * Get a compact string summary of diff stats.
83
94
  *
84
- * Example output:
85
- * ```
86
- * 5 files changed, +1250 -890
87
- * review-agent.mjs +680 -528
88
- * task-executor.mjs +320 -180
89
- * monitor.mjs +150 -82
90
- * session-tracker.mjs +370 -0
91
- * diff-stats.mjs +380 -0
92
- * ```
93
- *
94
95
  * @param {string} worktreePath
95
96
  * @param {Object} [options]
96
97
  * @returns {string}
@@ -101,15 +102,14 @@ export function getCompactDiffSummary(worktreePath, options = {}) {
101
102
  }
102
103
 
103
104
  /**
104
- * Get the recent commits on the current branch (vs origin/main).
105
+ * Get the recent commits on the current branch (vs origin/main when available).
105
106
  *
106
107
  * @param {string} worktreePath
107
108
  * @param {number} [maxCommits=10]
108
- * @returns {string[]} - Array of one-line commit messages
109
+ * @returns {string[]}
109
110
  */
110
111
  export function getRecentCommits(worktreePath, maxCommits = 10) {
111
112
  try {
112
- // Try vs origin/main first
113
113
  const result = spawnSync(
114
114
  "git",
115
115
  ["log", "--oneline", `--max-count=${maxCommits}`, "origin/main..HEAD"],
@@ -120,7 +120,6 @@ export function getRecentCommits(worktreePath, maxCommits = 10) {
120
120
  return result.stdout.trim().split("\n").filter(Boolean);
121
121
  }
122
122
 
123
- // Fallback: last N commits on current branch
124
123
  const fallback = spawnSync(
125
124
  "git",
126
125
  ["log", "--oneline", `--max-count=${maxCommits}`],
@@ -137,20 +136,267 @@ export function getRecentCommits(worktreePath, maxCommits = 10) {
137
136
  return [];
138
137
  }
139
138
 
140
- // ── Internal Strategies ─────────────────────────────────────────────────────
141
-
142
139
  /**
143
- * Try `git diff --numstat` and parse the output.
144
- * @param {string} cwd
145
- * @param {string} range - e.g., "origin/main...HEAD"
146
- * @param {number} timeoutMs
147
- * @returns {FileChangeStats[]|null}
140
+ * Parse a unified diff string into file + hunk structures suitable for review UIs.
141
+ *
142
+ * @param {string} rawDiff
143
+ * @returns {FileChangeStats[]}
148
144
  */
145
+ export function parseUnifiedDiff(rawDiff) {
146
+ if (!rawDiff || !String(rawDiff).trim()) return [];
147
+ const text = String(rawDiff).replace(/\r\n/g, "\n");
148
+ const lines = text.split("\n");
149
+
150
+ const files = [];
151
+ let currentFile = null;
152
+ let currentHunk = null;
153
+
154
+ const pushCurrentFile = () => {
155
+ if (!currentFile) return;
156
+ finalizeParsedFile(currentFile);
157
+ files.push(currentFile);
158
+ currentFile = null;
159
+ currentHunk = null;
160
+ };
161
+
162
+ for (const line of lines) {
163
+ if (line.startsWith("diff --git ")) {
164
+ pushCurrentFile();
165
+ const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
166
+ currentFile = {
167
+ file: "",
168
+ filename: "",
169
+ oldFilename: match?.[1] || "",
170
+ newFilename: match?.[2] || "",
171
+ status: "modified",
172
+ additions: 0,
173
+ deletions: 0,
174
+ binary: false,
175
+ patch: "",
176
+ hunks: [],
177
+ headers: [line],
178
+ patchLines: [line],
179
+ };
180
+ continue;
181
+ }
182
+
183
+ if (!currentFile) continue;
184
+ currentFile.patchLines.push(line);
185
+
186
+ if (line.startsWith("new file mode ")) {
187
+ currentFile.status = "added";
188
+ currentFile.headers.push(line);
189
+ continue;
190
+ }
191
+ if (line.startsWith("deleted file mode ")) {
192
+ currentFile.status = "deleted";
193
+ currentFile.headers.push(line);
194
+ continue;
195
+ }
196
+ if (line.startsWith("rename from ")) {
197
+ currentFile.status = "renamed";
198
+ currentFile.oldFilename = line.slice("rename from ".length).trim();
199
+ currentFile.headers.push(line);
200
+ continue;
201
+ }
202
+ if (line.startsWith("rename to ")) {
203
+ currentFile.status = "renamed";
204
+ currentFile.newFilename = line.slice("rename to ".length).trim();
205
+ currentFile.headers.push(line);
206
+ continue;
207
+ }
208
+ if (line.startsWith("copy from ")) {
209
+ currentFile.status = "copied";
210
+ currentFile.oldFilename = line.slice("copy from ".length).trim();
211
+ currentFile.headers.push(line);
212
+ continue;
213
+ }
214
+ if (line.startsWith("copy to ")) {
215
+ currentFile.status = "copied";
216
+ currentFile.newFilename = line.slice("copy to ".length).trim();
217
+ currentFile.headers.push(line);
218
+ continue;
219
+ }
220
+ if (line.startsWith("Binary files ") || line === "GIT binary patch") {
221
+ currentFile.binary = true;
222
+ currentFile.headers.push(line);
223
+ continue;
224
+ }
225
+ if (line.startsWith("index ") || line.startsWith("similarity index ") || line.startsWith("dissimilarity index ")) {
226
+ currentFile.headers.push(line);
227
+ continue;
228
+ }
229
+ if (line.startsWith("--- ")) {
230
+ currentFile.oldFilename = normalizeDiffPath(parsePatchFilename(line.slice(4)));
231
+ currentFile.headers.push(line);
232
+ continue;
233
+ }
234
+ if (line.startsWith("+++ ")) {
235
+ currentFile.newFilename = normalizeDiffPath(parsePatchFilename(line.slice(4)));
236
+ currentFile.headers.push(line);
237
+ continue;
238
+ }
239
+ if (line.startsWith("@@ ")) {
240
+ currentHunk = createHunk(line);
241
+ currentFile.hunks.push(currentHunk);
242
+ continue;
243
+ }
244
+
245
+ if (!currentHunk) {
246
+ currentFile.headers.push(line);
247
+ continue;
248
+ }
249
+
250
+ if (line.startsWith("+") && !line.startsWith("+++")) {
251
+ currentFile.additions += 1;
252
+ currentHunk.lines.push({
253
+ type: "addition",
254
+ oldNumber: null,
255
+ newNumber: currentHunk.nextNewNumber,
256
+ marker: "+",
257
+ content: line.slice(1),
258
+ raw: line,
259
+ });
260
+ currentHunk.nextNewNumber += 1;
261
+ continue;
262
+ }
263
+ if (line.startsWith("-") && !line.startsWith("---")) {
264
+ currentFile.deletions += 1;
265
+ currentHunk.lines.push({
266
+ type: "deletion",
267
+ oldNumber: currentHunk.nextOldNumber,
268
+ newNumber: null,
269
+ marker: "-",
270
+ content: line.slice(1),
271
+ raw: line,
272
+ });
273
+ currentHunk.nextOldNumber += 1;
274
+ continue;
275
+ }
276
+ if (line.startsWith("\")) {
277
+ currentHunk.lines.push({
278
+ type: "meta",
279
+ oldNumber: null,
280
+ newNumber: null,
281
+ marker: "\\",
282
+ content: line,
283
+ raw: line,
284
+ });
285
+ continue;
286
+ }
287
+
288
+ const content = line.startsWith(" ") ? line.slice(1) : line;
289
+ currentHunk.lines.push({
290
+ type: "context",
291
+ oldNumber: currentHunk.nextOldNumber,
292
+ newNumber: currentHunk.nextNewNumber,
293
+ marker: " ",
294
+ content,
295
+ raw: line,
296
+ });
297
+ currentHunk.nextOldNumber += 1;
298
+ currentHunk.nextNewNumber += 1;
299
+ }
300
+
301
+ pushCurrentFile();
302
+ return files;
303
+ }
304
+
305
+ function emptyDiffStats(formatted) {
306
+ return {
307
+ files: [],
308
+ totalFiles: 0,
309
+ totalAdditions: 0,
310
+ totalDeletions: 0,
311
+ formatted,
312
+ };
313
+ }
314
+
315
+ function buildRangeCandidates({ baseBranch, targetRef, range }) {
316
+ if (String(range || "").trim()) {
317
+ return [{ range: String(range).trim() }];
318
+ }
319
+
320
+ const target = String(targetRef || "HEAD").trim() || "HEAD";
321
+ const targets = buildTargetCandidates(target);
322
+ const bases = buildBaseCandidates(baseBranch);
323
+
324
+ const candidates = [];
325
+ for (const base of bases) {
326
+ for (const candidateTarget of targets) {
327
+ candidates.push({ range: `${base}...${candidateTarget}` });
328
+ if (candidateTarget !== "HEAD") {
329
+ candidates.push({ range: `${base}..${candidateTarget}` });
330
+ }
331
+ }
332
+ }
333
+
334
+ if (target === "HEAD") {
335
+ candidates.push({ range: "HEAD~10...HEAD" });
336
+ candidates.push({ range: "HEAD" });
337
+ }
338
+
339
+ return dedupeByRange(candidates);
340
+ }
341
+
342
+ function buildBaseCandidates(baseBranch) {
343
+ const raw = String(baseBranch || "").trim() || "origin/main";
344
+ const out = [raw];
345
+ if (raw.startsWith("origin/")) {
346
+ out.push(raw.slice("origin/".length));
347
+ } else if (!raw.includes("/")) {
348
+ out.push(`origin/${raw}`);
349
+ }
350
+ return dedupeStrings(out);
351
+ }
352
+
353
+ function buildTargetCandidates(targetRef) {
354
+ const raw = String(targetRef || "").trim() || "HEAD";
355
+ if (raw === "HEAD") return ["HEAD"];
356
+ const out = [raw];
357
+ if (!raw.includes("/")) out.push(`origin/${raw}`);
358
+ return dedupeStrings(out);
359
+ }
360
+
361
+ function dedupeStrings(values) {
362
+ return [...new Set(values.filter(Boolean))];
363
+ }
364
+
365
+ function dedupeByRange(items) {
366
+ const seen = new Set();
367
+ return items.filter((item) => {
368
+ const key = String(item?.range || "").trim();
369
+ if (!key || seen.has(key)) return false;
370
+ seen.add(key);
371
+ return true;
372
+ });
373
+ }
374
+
375
+ function collectDiffForRange(worktreePath, candidate, options = {}) {
376
+ const range = String(candidate?.range || "").trim();
377
+ if (!range) return null;
378
+
379
+ const patchFiles = options.includePatch
380
+ ? tryPatch(worktreePath, range, options.timeoutMs, options.contextLines)
381
+ : null;
382
+ const statFiles =
383
+ tryNumstat(worktreePath, range, options.timeoutMs) ||
384
+ tryStat(worktreePath, range, options.timeoutMs) ||
385
+ [];
386
+
387
+ const combined = buildCombinedFiles(statFiles, patchFiles?.files || []);
388
+ if (!combined.length) return null;
389
+
390
+ return buildResult(combined, {
391
+ sourceRange: range,
392
+ });
393
+ }
394
+
149
395
  function tryNumstat(cwd, range, timeoutMs) {
150
396
  try {
151
397
  const result = spawnSync(
152
398
  "git",
153
- ["diff", "--numstat", range],
399
+ ["diff", "--find-renames", "--find-copies", "--numstat", range],
154
400
  { cwd, encoding: "utf8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"] },
155
401
  );
156
402
 
@@ -159,46 +405,50 @@ function tryNumstat(cwd, range, timeoutMs) {
159
405
  const files = [];
160
406
  for (const line of result.stdout.trim().split("\n")) {
161
407
  if (!line.trim()) continue;
162
-
163
408
  const parts = line.split("\t");
164
409
  if (parts.length < 3) continue;
165
-
166
410
  const [addStr, delStr, ...fileParts] = parts;
167
- const file = fileParts.join("\t"); // Handle filenames with tabs
168
-
411
+ const file = fileParts.join("\t");
169
412
  if (addStr === "-" && delStr === "-") {
170
- // Binary file
171
- files.push({ file, additions: 0, deletions: 0, binary: true });
413
+ files.push({
414
+ file,
415
+ filename: file,
416
+ oldFilename: file,
417
+ newFilename: file,
418
+ status: "modified",
419
+ additions: 0,
420
+ deletions: 0,
421
+ binary: true,
422
+ patch: "",
423
+ hunks: [],
424
+ });
172
425
  } else {
173
426
  files.push({
174
427
  file,
428
+ filename: file,
429
+ oldFilename: file,
430
+ newFilename: file,
431
+ status: "modified",
175
432
  additions: parseInt(addStr, 10) || 0,
176
433
  deletions: parseInt(delStr, 10) || 0,
177
434
  binary: false,
435
+ patch: "",
436
+ hunks: [],
178
437
  });
179
438
  }
180
439
  }
181
440
 
182
- return files.length > 0 ? files : null;
441
+ return files.length ? files : null;
183
442
  } catch {
184
443
  return null;
185
444
  }
186
445
  }
187
446
 
188
- /**
189
- * Try `git diff --stat` and parse the output.
190
- * Fallback when --numstat fails.
191
- *
192
- * @param {string} cwd
193
- * @param {string} range
194
- * @param {number} timeoutMs
195
- * @returns {FileChangeStats[]|null}
196
- */
197
447
  function tryStat(cwd, range, timeoutMs) {
198
448
  try {
199
449
  const result = spawnSync(
200
450
  "git",
201
- ["diff", "--stat", range],
451
+ ["diff", "--find-renames", "--find-copies", "--stat", range],
202
452
  { cwd, encoding: "utf8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"] },
203
453
  );
204
454
 
@@ -206,77 +456,253 @@ function tryStat(cwd, range, timeoutMs) {
206
456
 
207
457
  const files = [];
208
458
  const lines = result.stdout.trim().split("\n");
209
-
210
- // Last line is the summary — skip it
211
459
  for (let i = 0; i < lines.length - 1; i++) {
212
460
  const line = lines[i].trim();
213
461
  if (!line) continue;
214
-
215
- // Format: " filename | 123 +++---" or " filename | Bin 0 -> 1234 bytes"
216
- const pipeIdx = line.lastIndexOf("|");
217
- if (pipeIdx === -1) continue;
218
-
219
- const file = line.slice(0, pipeIdx).trim();
220
- const statsStr = line.slice(pipeIdx + 1).trim();
221
-
462
+ const pipeIndex = line.lastIndexOf("|");
463
+ if (pipeIndex === -1) continue;
464
+ const file = line.slice(0, pipeIndex).trim();
465
+ const statsStr = line.slice(pipeIndex + 1).trim();
222
466
  if (statsStr.startsWith("Bin")) {
223
- files.push({ file, additions: 0, deletions: 0, binary: true });
467
+ files.push({
468
+ file,
469
+ filename: file,
470
+ oldFilename: file,
471
+ newFilename: file,
472
+ status: "modified",
473
+ additions: 0,
474
+ deletions: 0,
475
+ binary: true,
476
+ patch: "",
477
+ hunks: [],
478
+ });
224
479
  } else {
225
- // Count '+' and '-' chars
226
- const additions = (statsStr.match(/\+/g) || []).length;
227
- const deletions = (statsStr.match(/-/g) || []).length;
228
- files.push({ file, additions, deletions, binary: false });
480
+ files.push({
481
+ file,
482
+ filename: file,
483
+ oldFilename: file,
484
+ newFilename: file,
485
+ status: "modified",
486
+ additions: (statsStr.match(/\+/g) || []).length,
487
+ deletions: (statsStr.match(/-/g) || []).length,
488
+ binary: false,
489
+ patch: "",
490
+ hunks: [],
491
+ });
229
492
  }
230
493
  }
231
494
 
232
- return files.length > 0 ? files : null;
495
+ return files.length ? files : null;
233
496
  } catch {
234
497
  return null;
235
498
  }
236
499
  }
237
500
 
238
- // ── Result Builder ──────────────────────────────────────────────────────────
501
+ function tryPatch(cwd, range, timeoutMs, contextLines = 3) {
502
+ try {
503
+ const result = spawnSync(
504
+ "git",
505
+ [
506
+ "diff",
507
+ "--find-renames",
508
+ "--find-copies",
509
+ "--no-ext-diff",
510
+ "--no-color",
511
+ `--unified=${Math.max(0, Number(contextLines) || 3)}`,
512
+ range,
513
+ ],
514
+ { cwd, encoding: "utf8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"] },
515
+ );
239
516
 
240
- /**
241
- * Build a DiffStats result from parsed file changes.
242
- * @param {FileChangeStats[]} files
243
- * @returns {DiffStats}
244
- */
245
- function buildResult(files) {
517
+ if (result.status !== 0 || !(result.stdout || "").trim()) return null;
518
+ const rawPatch = result.stdout.replace(/\r\n/g, "\n");
519
+ return {
520
+ rawPatch,
521
+ files: parseUnifiedDiff(rawPatch),
522
+ };
523
+ } catch (err) {
524
+ console.warn(`${TAG} tryPatch error: ${err.message}`);
525
+ return null;
526
+ }
527
+ }
528
+
529
+ function buildCombinedFiles(statFiles = [], patchFiles = []) {
530
+ const normalizedStatFiles = Array.isArray(statFiles) ? statFiles : [];
531
+ const normalizedPatchFiles = Array.isArray(patchFiles) ? patchFiles : [];
532
+ const byKey = new Map();
533
+
534
+ const upsert = (file) => {
535
+ if (!file) return;
536
+ const keys = buildFileLookupKeys(file);
537
+ let target = null;
538
+ for (const key of keys) {
539
+ if (byKey.has(key)) {
540
+ target = byKey.get(key);
541
+ break;
542
+ }
543
+ }
544
+ if (!target) {
545
+ target = {
546
+ file: "",
547
+ filename: "",
548
+ oldFilename: "",
549
+ newFilename: "",
550
+ status: "modified",
551
+ additions: 0,
552
+ deletions: 0,
553
+ binary: false,
554
+ patch: "",
555
+ hunks: [],
556
+ };
557
+ }
558
+ mergeFileData(target, file);
559
+ for (const key of keys) {
560
+ byKey.set(key, target);
561
+ }
562
+ };
563
+
564
+ for (const patchFile of normalizedPatchFiles) upsert(patchFile);
565
+ for (const statFile of normalizedStatFiles) upsert(statFile);
566
+
567
+ const unique = [];
568
+ const seen = new Set();
569
+ for (const file of byKey.values()) {
570
+ finalizeParsedFile(file);
571
+ const key = buildStableFileKey(file);
572
+ if (seen.has(key)) continue;
573
+ seen.add(key);
574
+ unique.push(file);
575
+ }
576
+ return unique;
577
+ }
578
+
579
+ function mergeFileData(target, source) {
580
+ if (source.file) target.file = source.file;
581
+ if (source.filename) target.filename = source.filename;
582
+ if (source.oldFilename) target.oldFilename = source.oldFilename;
583
+ if (source.newFilename) target.newFilename = source.newFilename;
584
+ if (source.status && (target.status === "modified" || target.status === "unknown")) {
585
+ target.status = source.status;
586
+ }
587
+ if (source.status && source.status !== "modified") {
588
+ target.status = source.status;
589
+ }
590
+ if (typeof source.additions === "number") target.additions = Math.max(target.additions, source.additions);
591
+ if (typeof source.deletions === "number") target.deletions = Math.max(target.deletions, source.deletions);
592
+ target.binary = Boolean(target.binary || source.binary);
593
+ if (source.patch) target.patch = source.patch;
594
+ if (Array.isArray(source.hunks) && source.hunks.length) target.hunks = source.hunks;
595
+ }
596
+
597
+ function buildFileLookupKeys(file) {
598
+ const values = [
599
+ file?.filename,
600
+ file?.file,
601
+ file?.newFilename,
602
+ file?.oldFilename,
603
+ ].map((value) => normalizeDiffPath(value)).filter(Boolean);
604
+ return values.length ? values : [buildStableFileKey(file)];
605
+ }
606
+
607
+ function buildStableFileKey(file) {
608
+ return normalizeDiffPath(file?.filename || file?.file || file?.newFilename || file?.oldFilename || "unknown");
609
+ }
610
+
611
+ function finalizeParsedFile(file) {
612
+ if (!file || typeof file !== "object") return file;
613
+ file.oldFilename = normalizeDiffPath(file.oldFilename);
614
+ file.newFilename = normalizeDiffPath(file.newFilename);
615
+
616
+ if (!file.filename) {
617
+ if (file.status === "deleted") file.filename = file.oldFilename || file.newFilename;
618
+ else file.filename = file.newFilename || file.oldFilename;
619
+ }
620
+ file.filename = normalizeDiffPath(file.filename);
621
+ if (!file.file) file.file = file.filename;
622
+
623
+ if (!file.status || file.status === "modified") {
624
+ if (file.oldFilename === "/dev/null" || (file.oldFilename === "" && file.newFilename)) file.status = "added";
625
+ else if (file.newFilename === "/dev/null" || (file.newFilename === "" && file.oldFilename)) file.status = "deleted";
626
+ else if (file.oldFilename && file.newFilename && file.oldFilename !== file.newFilename) file.status = "renamed";
627
+ else file.status = "modified";
628
+ }
629
+
630
+ if (!file.patch) {
631
+ const patchLines = Array.isArray(file.patchLines) ? file.patchLines : [];
632
+ file.patch = patchLines.join("\n").trim();
633
+ }
634
+
635
+ if (!Array.isArray(file.hunks)) file.hunks = [];
636
+ for (const hunk of file.hunks) {
637
+ delete hunk.nextOldNumber;
638
+ delete hunk.nextNewNumber;
639
+ }
640
+ delete file.headers;
641
+ delete file.patchLines;
642
+ return file;
643
+ }
644
+
645
+ function createHunk(header) {
646
+ const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(header);
647
+ const oldStart = Number(match?.[1] || 0);
648
+ const oldLines = Number(match?.[2] || 1);
649
+ const newStart = Number(match?.[3] || 0);
650
+ const newLines = Number(match?.[4] || 1);
651
+ return {
652
+ header,
653
+ oldStart,
654
+ oldLines,
655
+ newStart,
656
+ newLines,
657
+ nextOldNumber: oldStart,
658
+ nextNewNumber: newStart,
659
+ lines: [],
660
+ };
661
+ }
662
+
663
+ function parsePatchFilename(value) {
664
+ const trimmed = String(value || "").trim();
665
+ if (!trimmed || trimmed === "/dev/null") return trimmed;
666
+ return trimmed.replace(/^[ab]\//, "");
667
+ }
668
+
669
+ function normalizeDiffPath(value) {
670
+ const trimmed = String(value || "").trim();
671
+ if (!trimmed) return "";
672
+ if (trimmed === "/dev/null") return trimmed;
673
+ return trimmed.replace(/^[ab]\//, "");
674
+ }
675
+
676
+ function buildResult(files, extra = {}) {
246
677
  let totalAdditions = 0;
247
678
  let totalDeletions = 0;
248
679
 
249
- for (const f of files) {
250
- totalAdditions += f.additions;
251
- totalDeletions += f.deletions;
680
+ for (const file of files) {
681
+ totalAdditions += Number(file.additions || 0);
682
+ totalDeletions += Number(file.deletions || 0);
252
683
  }
253
684
 
254
- // Sort by total changes (largest first)
255
685
  const sorted = [...files].sort(
256
- (a, b) => (b.additions + b.deletions) - (a.additions + a.deletions),
686
+ (a, b) => (Number(b.additions || 0) + Number(b.deletions || 0)) - (Number(a.additions || 0) + Number(a.deletions || 0)),
257
687
  );
258
688
 
259
- // Find max filename length for alignment
260
- const maxNameLen = Math.max(...sorted.map((f) => f.file.length), 10);
689
+ const maxNameLen = Math.max(...sorted.map((file) => (file.filename || file.file || "").length), 10);
261
690
 
262
- const lines = sorted.map((f) => {
263
- const name = f.file.padEnd(maxNameLen);
264
- if (f.binary) {
265
- return ` ${name} (binary)`;
266
- }
267
- const add = `+${f.additions}`.padStart(6);
268
- const del = `-${f.deletions}`.padStart(6);
691
+ const lines = sorted.map((file) => {
692
+ const name = String(file.filename || file.file || "").padEnd(maxNameLen);
693
+ if (file.binary) return ` ${name} (binary)`;
694
+ const add = `+${Number(file.additions || 0)}`.padStart(6);
695
+ const del = `-${Number(file.deletions || 0)}`.padStart(6);
269
696
  return ` ${name} ${add} ${del}`;
270
697
  });
271
698
 
272
- const header = `${files.length} file(s) changed, +${totalAdditions} -${totalDeletions}`;
273
- const formatted = `${header}\n${lines.join("\n")}`;
274
-
699
+ const header = `${sorted.length} file(s) changed, +${totalAdditions} -${totalDeletions}`;
275
700
  return {
276
701
  files: sorted,
277
- totalFiles: files.length,
702
+ totalFiles: sorted.length,
278
703
  totalAdditions,
279
704
  totalDeletions,
280
- formatted,
705
+ formatted: `${header}\n${lines.join("\n")}`,
706
+ ...(extra?.sourceRange ? { sourceRange: extra.sourceRange } : {}),
281
707
  };
282
708
  }