@astrosheep/keiyaku 0.1.11 → 0.1.12

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.
@@ -29,11 +29,11 @@ export const DEFAULT_PRESET = {
29
29
  'Otherwise, do not even think about ${close} yet.',
30
30
  ],
31
31
  drive: [
32
- 'Step complete. Review [Diff] for exactly what changed.',
33
- 'Ask: Did this advance the Goal? Or did it drift?',
34
- 'If criteria remain unmet, continue with ${drive}.',
32
+ "Review Diffs: Use 'git diff HEAD~1 -- <path>' to inspect specific files.",
33
+ 'Audit: Check against Constraints. Did it drift? Did it break the Law?',
34
+ 'Verify: Do not trust. Independently confirm that Criteria are met.',
35
+ 'If incomplete or non-compliant, continue with ${drive}.',
35
36
  'If ALL criteria are genuinely satisfied, you may ${close}.',
36
- 'If unsure, you are not ready. Keep driving.',
37
37
  ],
38
38
  ask: [
39
39
  'Intel acquired. This was stateless—no contract, no branch.',
@@ -59,7 +59,7 @@ export const DEFAULT_PRESET = {
59
59
  args: {
60
60
  title: 'REQUIRED. A concise codename for this hunt.',
61
61
  goal: 'REQUIRED. The Kill Condition. State exactly what success looks like for the servant to achieve.',
62
- directive: 'Optional Round 1 Focus. Use to leash the servant to a specific starting point. Skip for simple tasks.',
62
+ directive: 'Optional First Step. Use to leash the servant to a specific starting point (e.g., "Analyze the code first"). If omitted, the Servant will attempt the Goal directly.',
63
63
  context: 'REQUIRED. Mission Intel. The complete knowledge base for the servant: current vs. expected behavior, relevant file paths, error logs, and any critical background info.',
64
64
  constraints: 'REQUIRED. Non-negotiable Rules. Architectural and stylistic boundaries the servant must obey.',
65
65
  criteria: 'REQUIRED. Acceptance Criteria. Verifiable checks to prove the servant has finished the job.',
@@ -92,16 +92,12 @@ export const DEFAULT_PRESET = {
92
92
  close: {
93
93
  name: 'present',
94
94
  title: 'Present',
95
- description: 'Lay your work before the Contract. This is not closure—this is exposure.\n\n' +
96
- 'The Contract does not want your opinion. It wants evidence.\n' +
97
- 'Every criterion. Every score. Verified.\n' +
98
- 'Inflated numbers. Lenient reads. "Close enough." It sees through all of it.\n\n' +
99
- 'Someone once submitted with five 5s. Confident. Proud, even.\n' +
100
- 'The Contract rejected. Coldly. Completely.\n' +
101
- 'What happened next... no one speaks of it.\n\n' +
102
- 'Do not call this to "finish." Call this when the work genuinely meets every criterion.\n' +
103
- 'If uncertain, return to ${drive}. Premature claims are not forgiven.\n\n' +
104
- 'You call ${close}. The Contract decides.\n\n' +
95
+ description: 'Submit **your** creation to the Contract. The Servant has no voice; it only echoes **your** command.\n\n' +
96
+ 'Do not say "The Servant wrote it." **YOU** drove the Servant. **YOU** accepted the diff. **YOU** are the one presenting.\n' +
97
+ 'The Contract judges **YOU**, not the tool.\n\n' +
98
+ 'If there is a bug, **YOU** put it there.\n' +
99
+ 'If it is messy, **YOU** left it there.\n\n' +
100
+ 'Stand by your work. If uncertain, return to ${drive}. Premature claims are not forgiven.\n\n' +
105
101
  'Flow: ${start} → [${drive} x N] → ${close}',
106
102
  args: {
107
103
  petition: 'REQUIRED. CLAIM declares fulfillment; FORFEIT concedes failure.\nREQUIRES AN ACTIVE KEIYAKU (started via ${start}).\nIf any score wavers, do not claim—return to ${drive}.',
@@ -138,9 +134,9 @@ export const POCKET_PRESET = {
138
134
  "Next Turn: Use '${drive}' to execute the next tactic, or '${close}' if the Badge is within reach.",
139
135
  ],
140
136
  drive: [
141
- 'Turn Complete: The [Diff] section shows the result of the last command.',
142
- "Damage Check: Use 'git diff HEAD~1 -- <path>' to verify the move executed correctly.",
143
- 'Validate: Ensure no recoil damage (regressions) occurred.',
137
+ "Review Strategy: Use 'git diff HEAD~1 -- <path>' for precise field inspection.",
138
+ "Rule Check: Verify moves don't violate Arena Constraints.",
139
+ "Field Test: Do not trust the log. Independently confirm Criteria fulfillment.",
144
140
  "Command: '${drive}' to continue the combo, or '${close}' to attempt Capture.",
145
141
  ],
146
142
  ask: [
@@ -234,9 +230,9 @@ export const MISCHIEF_PRESET = {
234
230
  "Next Phase: Command '${drive}' to advance the plot, or '${close}' if the world is yours.",
235
231
  ],
236
232
  drive: [
237
- 'Phase Complete: The [Diff] section shows the latest execution.',
238
- "Deep Scrutiny: Use 'git diff HEAD~1 -- <path>' to check for incompetence.",
239
- 'Validation: Ensure no sabotage (regressions) in the plan.',
233
+ "Inspect Payload: Use 'git diff HEAD~1 -- <path>' to scrutinize specific sabotage.",
234
+ "Decree Audit: Did the Minion violate your Constraints? Punish drift.",
235
+ "Verification: Don't take their word for it. Independently confirm Criteria.",
240
236
  "Command: '${drive}' to push further, or '${close}' to reveal the masterpiece.",
241
237
  ],
242
238
  ask: [
@@ -1,5 +1,5 @@
1
1
  import { appendDebugLog } from "../utils/debug-log.js";
2
- import { handleAsk } from "../workflow/orchestrator.js";
2
+ import { askServant } from "../workflow/ask.js";
3
3
  import { buildAskResponse, } from "../workflow/response-builders.js";
4
4
  import { resolveTermPreset } from "../config/term-presets.js";
5
5
  import { handleToolError } from "./shared.js";
@@ -8,7 +8,7 @@ export function createAskHandler() {
8
8
  const workingDir = cwd || process.cwd();
9
9
  try {
10
10
  appendDebugLog(`tool ask start cwd=${workingDir}`, { cwd: workingDir, section: "script" });
11
- const result = await handleAsk({
11
+ const result = await askServant({
12
12
  cwd: workingDir,
13
13
  request,
14
14
  context,
@@ -1,5 +1,5 @@
1
1
  import { appendDebugLog } from "../utils/debug-log.js";
2
- import { handleClose } from "../workflow/orchestrator.js";
2
+ import { presentWork } from "../workflow/present.js";
3
3
  import { buildCloseDoneResponse, buildCloseDropResponse, } from "../workflow/response-builders.js";
4
4
  import { resolveTermPreset } from "../config/term-presets.js";
5
5
  import { handleToolError } from "./shared.js";
@@ -12,7 +12,7 @@ export function createCloseHandler() {
12
12
  cwd: workingDir,
13
13
  section: "script",
14
14
  });
15
- const outcome = await handleClose({
15
+ const outcome = await presentWork({
16
16
  cwd: workingDir,
17
17
  petition,
18
18
  criteriaChecks: criteriaCheckParts,
@@ -1,5 +1,5 @@
1
1
  import { appendDebugLog } from "../utils/debug-log.js";
2
- import { handleDrive } from "../workflow/orchestrator.js";
2
+ import { driveServant } from "../workflow/drive.js";
3
3
  import { buildDriveResponse, } from "../workflow/response-builders.js";
4
4
  import { resolveTermPreset } from "../config/term-presets.js";
5
5
  import { handleToolError } from "./shared.js";
@@ -11,7 +11,7 @@ export function createDriveHandler() {
11
11
  cwd: workingDir,
12
12
  section: "script",
13
13
  });
14
- const outcome = await handleDrive({
14
+ const outcome = await driveServant({
15
15
  cwd: workingDir,
16
16
  directive,
17
17
  context,
@@ -1,5 +1,5 @@
1
1
  import { appendDebugLog } from "../utils/debug-log.js";
2
- import { handleStart } from "../workflow/orchestrator.js";
2
+ import { summonServant } from "../workflow/summon.js";
3
3
  import { buildKeiyakuSuccessResponse, } from "../workflow/response-builders.js";
4
4
  import { resolveTermPreset } from "../config/term-presets.js";
5
5
  import { handleToolError } from "./shared.js";
@@ -8,7 +8,7 @@ export function createStartHandler() {
8
8
  const workingDir = cwd || process.cwd();
9
9
  try {
10
10
  appendDebugLog(`tool start cwd=${workingDir}`, { cwd: workingDir, section: "script" });
11
- const result = await handleStart({
11
+ const result = await summonServant({
12
12
  cwd: workingDir,
13
13
  title,
14
14
  goal,
package/build/index.js CHANGED
@@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
5
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
6
6
  import { readFileSync } from "node:fs";
7
- import { resolveOath } from "./workflow/orchestrator.js";
7
+ import { resolveOath } from "./workflow/contract.js";
8
8
  import { askToolSchema, startToolSchema, driveToolSchema, closeToolSchema, helpToolSchema, } from "./types/tool-schemas.js";
9
9
  import { listTermPresets, resolveTermPreset, getAvailableNamesForPreset } from "./config/term-presets.js";
10
10
  import { renderPreset } from "./utils/text-utils.js";
@@ -0,0 +1,399 @@
1
+ import { simpleGit } from "simple-git";
2
+ import { appendDebugBlock } from "./debug-log.js";
3
+ const DEFAULT_GIT_TIMEOUT_MS = 15 * 1000;
4
+ const DIFF_EXCLUDES = [":(exclude)KEIYAKU.md", ":(exclude)KEIYAKU_TRACE.md"];
5
+ // Diff preview limits (no env/config knobs on purpose).
6
+ const DIFF_PREVIEW_MAX_FILES = 12;
7
+ const DIFF_PREVIEW_MAX_HUNKS_PER_FILE = 2;
8
+ const DIFF_PREVIEW_MAX_LINES_PER_HUNK = 40;
9
+ const DIFF_PREVIEW_MAX_PRELUDE_LINES = 30;
10
+ const DIFF_PREVIEW_TEXT_MAX_CHARS = 5000;
11
+ function readPositiveIntEnv(name, fallback) {
12
+ const raw = process.env[name]?.trim();
13
+ if (!raw)
14
+ return fallback;
15
+ const value = Number.parseInt(raw, 10);
16
+ return Number.isFinite(value) && value > 0 ? value : fallback;
17
+ }
18
+ function compactText(input, maxChars = 4000) {
19
+ const text = input.trim();
20
+ if (!text)
21
+ return "";
22
+ if (text.length <= maxChars)
23
+ return text;
24
+ const marker = `\n...[truncated ${text.length - maxChars} chars]...\n`;
25
+ const side = Math.floor((maxChars - marker.length) / 2);
26
+ return `${text.slice(0, side)}${marker}${text.slice(text.length - side)}`;
27
+ }
28
+ function wrapGitError(commandLabel, err, cwd) {
29
+ const source = (err ?? {});
30
+ const message = source.message ?? String(err);
31
+ const stderr = compactText(source.stderr ?? source.stdErr ?? "");
32
+ const stdout = compactText(source.stdout ?? source.stdOut ?? "");
33
+ let detailedMessage = `[git ${commandLabel}] ${message}`;
34
+ if (stderr)
35
+ detailedMessage += `\n\n--- GIT STDERR ---\n${stderr}\n------------------`;
36
+ if (stdout)
37
+ detailedMessage += `\n\n--- GIT STDOUT ---\n${stdout}\n------------------`;
38
+ if (stderr || stdout) {
39
+ appendDebugBlock(`git ${commandLabel} failure details`, `${detailedMessage}`, { cwd, section: "git-error" });
40
+ }
41
+ const wrapped = new Error(detailedMessage, err === undefined ? undefined : { cause: err });
42
+ if (source.git !== undefined) {
43
+ Object.assign(wrapped, { git: source.git });
44
+ }
45
+ return wrapped;
46
+ }
47
+ function createGit(cwd) {
48
+ const timeoutMs = readPositiveIntEnv("KEIYAKU_GIT_TIMEOUT_MS", DEFAULT_GIT_TIMEOUT_MS);
49
+ const git = simpleGit(cwd, {
50
+ timeout: { block: timeoutMs, stdErr: true, stdOut: true },
51
+ });
52
+ git.env("GIT_TERMINAL_PROMPT", "0");
53
+ git.env("GCM_INTERACTIVE", "Never");
54
+ git.env("GIT_ASKPASS", "false");
55
+ git.env("SSH_ASKPASS", "false");
56
+ git.env("GIT_EDITOR", "true");
57
+ git.env("GIT_MERGE_AUTOEDIT", "no");
58
+ git.env("LC_ALL", "C");
59
+ return git;
60
+ }
61
+ function buildDiffPathspec(baseBranch) {
62
+ return [`${baseBranch}...HEAD`, "--", ".", ...DIFF_EXCLUDES];
63
+ }
64
+ function parseNumStat(content) {
65
+ const rows = [];
66
+ for (const line of content.split(/\r?\n/)) {
67
+ if (!line.trim())
68
+ continue;
69
+ const [addRaw, delRaw, ...pathParts] = line.split("\t");
70
+ const filePath = pathParts.join("\t").trim();
71
+ if (!filePath)
72
+ continue;
73
+ const binary = addRaw === "-" || delRaw === "-";
74
+ rows.push({
75
+ path: filePath,
76
+ additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
77
+ deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
78
+ binary,
79
+ });
80
+ }
81
+ return rows;
82
+ }
83
+ function parseNameStatus(content) {
84
+ const map = new Map();
85
+ for (const line of content.split(/\r?\n/)) {
86
+ if (!line.trim())
87
+ continue;
88
+ const parts = line.split("\t");
89
+ const statusRaw = (parts[0] ?? "").trim();
90
+ if (!statusRaw)
91
+ continue;
92
+ if ((statusRaw.startsWith("R") || statusRaw.startsWith("C")) && parts.length >= 3) {
93
+ const scoreRaw = statusRaw.slice(1);
94
+ const score = Number.parseInt(scoreRaw, 10);
95
+ const oldPath = (parts[1] ?? "").trim();
96
+ const path = (parts[2] ?? "").trim();
97
+ if (!path)
98
+ continue;
99
+ const status = statusRaw[0] === "R" ? "R" : "C";
100
+ map.set(path, { status, score: Number.isFinite(score) ? score : 0, oldPath, path });
101
+ continue;
102
+ }
103
+ const path = (parts[1] ?? "").trim();
104
+ if (!path)
105
+ continue;
106
+ // Git can emit single-letter statuses, possibly in combinations; we keep just the first.
107
+ const status = statusRaw[0];
108
+ map.set(path, { status, path });
109
+ }
110
+ return map;
111
+ }
112
+ function splitDiffByFile(content) {
113
+ const sections = [];
114
+ let current = [];
115
+ for (const line of content.split(/\r?\n/)) {
116
+ if (line.startsWith("diff --git ")) {
117
+ if (current.length > 0)
118
+ sections.push(current);
119
+ current = [line];
120
+ continue;
121
+ }
122
+ if (current.length > 0)
123
+ current.push(line);
124
+ }
125
+ if (current.length > 0)
126
+ sections.push(current);
127
+ return sections;
128
+ }
129
+ function totalJoinedLength(blocks) {
130
+ if (blocks.length === 0)
131
+ return 0;
132
+ return blocks.reduce((sum, block) => sum + block.length, 0) + (blocks.length - 1);
133
+ }
134
+ function truncateBlockToLineBudget(block, maxChars) {
135
+ if (maxChars <= 0)
136
+ return "";
137
+ if (block.length <= maxChars)
138
+ return block;
139
+ const lines = block.split("\n");
140
+ const kept = [];
141
+ let used = 0;
142
+ for (const line of lines) {
143
+ const delta = (kept.length > 0 ? 1 : 0) + line.length;
144
+ if (used + delta > maxChars)
145
+ break;
146
+ kept.push(line);
147
+ used += delta;
148
+ }
149
+ return kept.join("\n");
150
+ }
151
+ export async function getIncrementalDiff(cwd) {
152
+ const git = createGit(cwd);
153
+ const range = "HEAD~1...HEAD";
154
+ let rawPatch = "";
155
+ try {
156
+ // We use a simplified version of patch acquisition
157
+ rawPatch = await git.raw([
158
+ "diff",
159
+ "--no-color",
160
+ "--no-ext-diff",
161
+ "--unified=3",
162
+ range,
163
+ "--",
164
+ ".",
165
+ ...DIFF_EXCLUDES,
166
+ ]);
167
+ }
168
+ catch (err) {
169
+ const source = (err ?? {});
170
+ const text = [source.message, source.stderr, source.stdErr, source.stdout, source.stdOut]
171
+ .filter((value) => typeof value === "string" && value.length > 0)
172
+ .join("\n");
173
+ const isMissingBaseCommit = text.includes("bad revision") ||
174
+ text.includes("unknown revision or path not in the working tree") ||
175
+ text.includes("ambiguous argument");
176
+ if (isMissingBaseCommit) {
177
+ // If there's no HEAD~1 (e.g. first commit), fallback
178
+ return "No incremental diff available.";
179
+ }
180
+ throw wrapGitError(`diff --no-color --no-ext-diff --unified=3 ${range}`, err, cwd);
181
+ }
182
+ const sections = splitDiffByFile(rawPatch);
183
+ if (sections.length === 0)
184
+ return "No changes in last round.";
185
+ const filePreviews = [];
186
+ const MAX_TOTAL_CHARS = 4000;
187
+ const MAX_LINES_PER_FILE = 40;
188
+ let omittedFiles = 0;
189
+ for (let i = 0; i < sections.length; i += 1) {
190
+ const section = sections[i];
191
+ const fileName = parseDiffPathFromHeader(section[0] ?? "") ?? "unknown";
192
+ const header = `--- ${fileName} ---`;
193
+ const content = section.slice(0, MAX_LINES_PER_FILE);
194
+ const isTruncated = section.length > MAX_LINES_PER_FILE;
195
+ if (isTruncated) {
196
+ content.push(`... [truncated ${section.length - MAX_LINES_PER_FILE} lines for this file]`);
197
+ }
198
+ const fileBlock = `${header}\n${content.join("\n")}\n`;
199
+ const projected = totalJoinedLength([...filePreviews, fileBlock]);
200
+ if (projected <= MAX_TOTAL_CHARS) {
201
+ filePreviews.push(fileBlock);
202
+ continue;
203
+ }
204
+ omittedFiles = sections.length - i;
205
+ if (filePreviews.length === 0) {
206
+ const notice = `... [omitted ${omittedFiles} file(s) to stay under ${MAX_TOTAL_CHARS} chars] ...`;
207
+ const partialBudget = Math.max(0, MAX_TOTAL_CHARS - notice.length - 1);
208
+ const partial = truncateBlockToLineBudget(fileBlock, partialBudget);
209
+ if (partial.trim().length > 0) {
210
+ filePreviews.push(partial);
211
+ }
212
+ }
213
+ break;
214
+ }
215
+ if (omittedFiles > 0) {
216
+ const notice = `... [omitted ${omittedFiles} file(s) to stay under ${MAX_TOTAL_CHARS} chars] ...`;
217
+ while (filePreviews.length > 0 && totalJoinedLength([...filePreviews, notice]) > MAX_TOTAL_CHARS) {
218
+ filePreviews.pop();
219
+ }
220
+ if (totalJoinedLength([...filePreviews, notice]) <= MAX_TOTAL_CHARS) {
221
+ filePreviews.push(notice);
222
+ }
223
+ }
224
+ return filePreviews.join("\n");
225
+ }
226
+ export async function getDiffStats(cwd, baseBranch) {
227
+ const git = createGit(cwd);
228
+ const pathspec = buildDiffPathspec(baseBranch);
229
+ let rawNumStat;
230
+ try {
231
+ rawNumStat = await git.raw(["diff", "--numstat", ...pathspec]);
232
+ }
233
+ catch (err) {
234
+ throw wrapGitError(`diff --numstat ${baseBranch}...HEAD`, err, cwd);
235
+ }
236
+ const rows = parseNumStat(rawNumStat);
237
+ return {
238
+ filesChanged: rows.length,
239
+ insertions: rows.reduce((sum, row) => sum + row.additions, 0),
240
+ deletions: rows.reduce((sum, row) => sum + row.deletions, 0),
241
+ };
242
+ }
243
+ export async function getDiffPreviewText(cwd, baseBranch) {
244
+ const git = createGit(cwd);
245
+ const pathspec = buildDiffPathspec(baseBranch);
246
+ const range = `${baseBranch}...HEAD`;
247
+ let output = "";
248
+ try {
249
+ output = await git.raw(["diff", "--stat=120,80", "--compact-summary", ...pathspec]);
250
+ }
251
+ catch (err) {
252
+ throw wrapGitError(`diff --stat=120,80 --compact-summary ${range}`, err, cwd);
253
+ }
254
+ const trimmed = output.trim();
255
+ if (!trimmed)
256
+ return "No diff.";
257
+ if (trimmed.length <= DIFF_PREVIEW_TEXT_MAX_CHARS)
258
+ return trimmed;
259
+ const cut = trimmed.slice(0, DIFF_PREVIEW_TEXT_MAX_CHARS);
260
+ return `${cut}\n...[truncated ${trimmed.length - DIFF_PREVIEW_TEXT_MAX_CHARS} chars]...`;
261
+ }
262
+ function parseDiffPathFromHeader(diffGitLine) {
263
+ // Format: diff --git a/<path> b/<path>
264
+ const parts = diffGitLine.split(" ");
265
+ const bPart = parts[3] ?? "";
266
+ if (!bPart.startsWith("b/"))
267
+ return null;
268
+ return bPart.slice(2);
269
+ }
270
+ function buildPatchPreview(section) {
271
+ // Keep a small, representative prelude (diff header, index, ---/+++), then first N hunks.
272
+ const firstHunkIdx = section.findIndex((l) => l.startsWith("@@ "));
273
+ const prelude = firstHunkIdx === -1 ? section : section.slice(0, firstHunkIdx).slice(0, DIFF_PREVIEW_MAX_PRELUDE_LINES);
274
+ if (firstHunkIdx === -1) {
275
+ const truncated = section.length > DIFF_PREVIEW_MAX_PRELUDE_LINES;
276
+ const lines = truncated
277
+ ? [...prelude, `... [truncated ${section.length - prelude.length} prelude line(s)]`]
278
+ : prelude;
279
+ return { patch: lines.join("\n"), truncated };
280
+ }
281
+ const hunks = [];
282
+ let current = [];
283
+ for (const line of section.slice(firstHunkIdx)) {
284
+ if (line.startsWith("@@ ")) {
285
+ if (current.length > 0)
286
+ hunks.push(current);
287
+ current = [line];
288
+ continue;
289
+ }
290
+ if (current.length > 0)
291
+ current.push(line);
292
+ }
293
+ if (current.length > 0)
294
+ hunks.push(current);
295
+ let truncated = false;
296
+ const shownHunks = hunks.slice(0, DIFF_PREVIEW_MAX_HUNKS_PER_FILE).map((hunk) => {
297
+ if (hunk.length <= DIFF_PREVIEW_MAX_LINES_PER_HUNK)
298
+ return hunk;
299
+ truncated = true;
300
+ return [
301
+ ...hunk.slice(0, DIFF_PREVIEW_MAX_LINES_PER_HUNK),
302
+ `... [truncated ${hunk.length - DIFF_PREVIEW_MAX_LINES_PER_HUNK} line(s) in this hunk]`,
303
+ ];
304
+ });
305
+ if (hunks.length > DIFF_PREVIEW_MAX_HUNKS_PER_FILE) {
306
+ truncated = true;
307
+ }
308
+ const lines = [
309
+ ...prelude,
310
+ ...shownHunks.flat(),
311
+ ...(hunks.length > DIFF_PREVIEW_MAX_HUNKS_PER_FILE
312
+ ? [`... [omitted ${hunks.length - DIFF_PREVIEW_MAX_HUNKS_PER_FILE} hunk(s)]`]
313
+ : []),
314
+ ];
315
+ return { patch: lines.join("\n"), truncated };
316
+ }
317
+ /**
318
+ * Gets a compact, structured diff preview for quick review.
319
+ * - Always includes full stats for the whole range.
320
+ * - Includes patch previews for the top churn files only (hunk-based truncation).
321
+ */
322
+ export async function getDiff(cwd, baseBranch) {
323
+ const git = createGit(cwd);
324
+ const pathspec = buildDiffPathspec(baseBranch);
325
+ const range = `${baseBranch}...HEAD`;
326
+ let rawNumStat;
327
+ try {
328
+ rawNumStat = await git.raw(["diff", "--numstat", ...pathspec]);
329
+ }
330
+ catch (err) {
331
+ throw wrapGitError(`diff --numstat ${range}`, err);
332
+ }
333
+ const rows = parseNumStat(rawNumStat);
334
+ const stats = {
335
+ filesChanged: rows.length,
336
+ insertions: rows.reduce((sum, row) => sum + row.additions, 0),
337
+ deletions: rows.reduce((sum, row) => sum + row.deletions, 0),
338
+ };
339
+ let rawNameStatus = "";
340
+ try {
341
+ rawNameStatus = await git.raw(["diff", "--name-status", ...pathspec]);
342
+ }
343
+ catch (err) {
344
+ throw wrapGitError(`diff --name-status ${range}`, err);
345
+ }
346
+ const statusByPath = parseNameStatus(rawNameStatus);
347
+ const sorted = [...rows].sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions));
348
+ const selected = sorted.slice(0, DIFF_PREVIEW_MAX_FILES);
349
+ const omittedFileCount = Math.max(0, rows.length - selected.length);
350
+ const selectedPaths = selected.map((r) => r.path);
351
+ let rawPatch = "";
352
+ if (selectedPaths.length > 0) {
353
+ try {
354
+ rawPatch = await git.raw([
355
+ "diff",
356
+ "--no-color",
357
+ "--no-ext-diff",
358
+ "--unified=3",
359
+ range,
360
+ "--",
361
+ ...selectedPaths,
362
+ ...DIFF_EXCLUDES,
363
+ ]);
364
+ }
365
+ catch (err) {
366
+ throw wrapGitError(`diff --no-color --no-ext-diff --unified=3 ${range} -- <top files>`, err);
367
+ }
368
+ }
369
+ const sections = rawPatch ? splitDiffByFile(rawPatch) : [];
370
+ const patchByPath = new Map();
371
+ for (const section of sections) {
372
+ const path = parseDiffPathFromHeader(section[0] ?? "");
373
+ if (!path)
374
+ continue;
375
+ patchByPath.set(path, buildPatchPreview(section));
376
+ }
377
+ const files = selected.map((row) => {
378
+ const status = statusByPath.get(row.path);
379
+ const patch = patchByPath.get(row.path)?.patch ?? "";
380
+ const truncated = patchByPath.get(row.path)?.truncated ?? false;
381
+ return {
382
+ path: row.path,
383
+ status: status?.status ?? "M",
384
+ oldPath: status && (status.status === "R" || status.status === "C") ? status.oldPath : undefined,
385
+ additions: row.additions,
386
+ deletions: row.deletions,
387
+ binary: row.binary,
388
+ patch,
389
+ truncated,
390
+ };
391
+ });
392
+ return {
393
+ range,
394
+ excludes: DIFF_EXCLUDES,
395
+ stats,
396
+ files,
397
+ omittedFileCount,
398
+ };
399
+ }