@astrosheep/keiyaku 0.1.11 → 0.1.13
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/build/config/term-presets.js +17 -21
- package/build/handlers/ask.js +2 -2
- package/build/handlers/close.js +2 -2
- package/build/handlers/drive.js +2 -2
- package/build/handlers/start.js +2 -2
- package/build/index.js +1 -1
- package/build/utils/git-diff.js +399 -0
- package/build/utils/git-ops.js +276 -0
- package/build/utils/git.js +2 -617
- package/build/utils/trace.js +9 -6
- package/build/workflow/ask.js +35 -0
- package/build/workflow/contract.js +51 -0
- package/build/workflow/drive.js +78 -0
- package/build/workflow/oath.js +1 -1
- package/build/workflow/present.js +213 -0
- package/build/workflow/prompts.js +17 -14
- package/build/workflow/response-builders.js +3 -2
- package/build/workflow/round-summary.js +125 -0
- package/build/workflow/round.js +85 -0
- package/build/workflow/summon.js +194 -0
- package/package.json +1 -1
- package/build/workflow/orchestrator.js +0 -605
|
@@ -29,11 +29,11 @@ export const DEFAULT_PRESET = {
|
|
|
29
29
|
'Otherwise, do not even think about ${close} yet.',
|
|
30
30
|
],
|
|
31
31
|
drive: [
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
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
|
|
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: '
|
|
96
|
-
'The
|
|
97
|
-
'
|
|
98
|
-
'
|
|
99
|
-
'
|
|
100
|
-
'
|
|
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
|
-
|
|
142
|
-
"
|
|
143
|
-
|
|
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
|
-
|
|
238
|
-
"
|
|
239
|
-
|
|
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: [
|
package/build/handlers/ask.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { appendDebugLog } from "../utils/debug-log.js";
|
|
2
|
-
import {
|
|
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
|
|
11
|
+
const result = await askServant({
|
|
12
12
|
cwd: workingDir,
|
|
13
13
|
request,
|
|
14
14
|
context,
|
package/build/handlers/close.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { appendDebugLog } from "../utils/debug-log.js";
|
|
2
|
-
import {
|
|
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
|
|
15
|
+
const outcome = await presentWork({
|
|
16
16
|
cwd: workingDir,
|
|
17
17
|
petition,
|
|
18
18
|
criteriaChecks: criteriaCheckParts,
|
package/build/handlers/drive.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { appendDebugLog } from "../utils/debug-log.js";
|
|
2
|
-
import {
|
|
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
|
|
14
|
+
const outcome = await driveServant({
|
|
15
15
|
cwd: workingDir,
|
|
16
16
|
directive,
|
|
17
17
|
context,
|
package/build/handlers/start.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { appendDebugLog } from "../utils/debug-log.js";
|
|
2
|
-
import {
|
|
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
|
|
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/
|
|
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
|
+
}
|