diffsplain 0.7.0 → 0.8.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.
- package/README.md +6 -4
- package/package.json +1 -1
- package/scripts/build-diff-data.mjs +46 -12
- package/scripts/cli-args.mjs +5 -5
- package/scripts/coding-agents.mjs +233 -18
- package/scripts/doctor.mjs +27 -18
- package/scripts/generate-summaries.mjs +96 -58
- package/scripts/present.mjs +4 -4
package/README.md
CHANGED
|
@@ -13,10 +13,12 @@ npx diffsplain
|
|
|
13
13
|
|
|
14
14
|
The command opens a local page and compares the checkout with its default
|
|
15
15
|
branch. It starts at port `2299` and uses the next free port when needed. You
|
|
16
|
-
need Node.js 22.13 or newer and a signed-in Codex, Claude, Copilot, or
|
|
17
|
-
CLI. Diffsplain tries them in that order.
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
need Node.js 22.13 or newer and a signed-in Codex, Claude, Copilot, Cursor, or
|
|
17
|
+
OpenCode CLI. Diffsplain tries them in that order. Cursor Agent must be version
|
|
18
|
+
2026.08.11 or newer. It uses the signed-in Cursor CLI in the user's home and
|
|
19
|
+
still contacts the Cursor service. Pull requests also need a signed-in GitHub
|
|
20
|
+
CLI. Once Diffsplain chooses an agent, a failed check or run ends the command;
|
|
21
|
+
it does not switch agents.
|
|
20
22
|
|
|
21
23
|
Common targets:
|
|
22
24
|
|
package/package.json
CHANGED
|
@@ -143,11 +143,42 @@ const excludedPaths = new Set(
|
|
|
143
143
|
function command(commandName, commandArgs, options = {}) {
|
|
144
144
|
return execFileSync(commandName, commandArgs, {
|
|
145
145
|
cwd: options.cwd,
|
|
146
|
+
env: options.env,
|
|
146
147
|
encoding: 'utf8',
|
|
147
148
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
148
149
|
});
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
function githubHttpsRemote(url) {
|
|
153
|
+
if (typeof url !== 'string' || !url) return false;
|
|
154
|
+
try {
|
|
155
|
+
const parsed = new URL(url);
|
|
156
|
+
return (
|
|
157
|
+
parsed.protocol === 'https:' &&
|
|
158
|
+
(parsed.hostname === 'github.com' || parsed.hostname === 'www.github.com')
|
|
159
|
+
);
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function runGit(gitArgs, { gitDir, remoteUrl } = {}) {
|
|
166
|
+
const githubHttps = githubHttpsRemote(remoteUrl);
|
|
167
|
+
return command(
|
|
168
|
+
'git',
|
|
169
|
+
[
|
|
170
|
+
...(gitDir ? ['--git-dir', gitDir] : ['-C', repo]),
|
|
171
|
+
...(githubHttps
|
|
172
|
+
? ['-c', 'credential.helper=!gh auth git-credential']
|
|
173
|
+
: []),
|
|
174
|
+
...gitArgs,
|
|
175
|
+
],
|
|
176
|
+
githubHttps
|
|
177
|
+
? { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }
|
|
178
|
+
: {},
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
151
182
|
const runRepo = (gitArgs) => command('git', ['-C', repo, ...gitArgs]);
|
|
152
183
|
const tryRepo = (gitArgs) => {
|
|
153
184
|
try {
|
|
@@ -383,16 +414,19 @@ function bareCache(remoteUrl) {
|
|
|
383
414
|
|
|
384
415
|
function fetchInto(cache, remoteUrl, refspecs) {
|
|
385
416
|
try {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
417
|
+
runGit(
|
|
418
|
+
[
|
|
419
|
+
'fetch',
|
|
420
|
+
'--quiet',
|
|
421
|
+
'--no-tags',
|
|
422
|
+
'--no-write-fetch-head',
|
|
423
|
+
'--no-auto-maintenance',
|
|
424
|
+
'--force',
|
|
425
|
+
remoteUrl,
|
|
426
|
+
...refspecs,
|
|
427
|
+
],
|
|
428
|
+
{ gitDir: cache.path, remoteUrl },
|
|
429
|
+
);
|
|
396
430
|
} catch (error) {
|
|
397
431
|
const detail = error?.stderr?.toString().trim();
|
|
398
432
|
throw new Error(
|
|
@@ -422,7 +456,7 @@ function uniqueMergeBase(runGit, base, head) {
|
|
|
422
456
|
function remoteDefaultBranchInfo(remoteUrl) {
|
|
423
457
|
let raw;
|
|
424
458
|
try {
|
|
425
|
-
raw =
|
|
459
|
+
raw = runGit(['ls-remote', '--symref', remoteUrl, 'HEAD'], { remoteUrl });
|
|
426
460
|
} catch {
|
|
427
461
|
throw new Error('Could not read the remote default branch');
|
|
428
462
|
}
|
|
@@ -441,7 +475,7 @@ function remoteDefaultBranch(remoteUrl) {
|
|
|
441
475
|
function remoteContainsCommits(remoteUrl, commits) {
|
|
442
476
|
let raw;
|
|
443
477
|
try {
|
|
444
|
-
raw =
|
|
478
|
+
raw = runGit(['ls-remote', remoteUrl], { remoteUrl });
|
|
445
479
|
} catch {
|
|
446
480
|
return false;
|
|
447
481
|
}
|
package/scripts/cli-args.mjs
CHANGED
|
@@ -86,7 +86,7 @@ Targets:
|
|
|
86
86
|
Options:
|
|
87
87
|
--repo PATH|URL|OWNER/NAME
|
|
88
88
|
Repo to review (default: current repo)
|
|
89
|
-
--agent NAME Use codex, claude, copilot, or opencode
|
|
89
|
+
--agent NAME Use codex, claude, copilot, cursor, or opencode
|
|
90
90
|
--no-agent Do not write agent notes
|
|
91
91
|
--model NAME Model for agent notes
|
|
92
92
|
--reasoning LEVEL Agent reasoning effort when supported
|
|
@@ -107,12 +107,12 @@ Options:
|
|
|
107
107
|
-h, --help Show this help
|
|
108
108
|
-v, --version Show the installed version
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
codex, claude, copilot, opencode
|
|
110
|
+
Automatic agent selection:
|
|
111
|
+
codex, claude, copilot, cursor, opencode
|
|
112
112
|
|
|
113
113
|
Cursor:
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
Requires Cursor Agent 2026.08.11 or newer. Uses the signed-in CLI in the
|
|
115
|
+
user's home. Cursor still contacts its service.
|
|
116
116
|
|
|
117
117
|
Examples:
|
|
118
118
|
diffsplain
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
2
|
import { access } from 'node:fs/promises';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
3
4
|
import {
|
|
5
|
+
basename,
|
|
4
6
|
delimiter,
|
|
5
7
|
dirname,
|
|
6
8
|
isAbsolute,
|
|
@@ -60,17 +62,103 @@ export function summaryAgentEnvironment(env = process.env) {
|
|
|
60
62
|
);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
+
const minimumCursorVersion = [2026, 8, 11];
|
|
66
|
+
const cursorRequirementSummary =
|
|
67
|
+
'Cursor needs version 2026.08.11 or newer with Ask mode, --sandbox, --trust, and --workspace.';
|
|
65
68
|
|
|
66
|
-
export
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
export const enabledCodingAgents = codingAgents;
|
|
70
|
+
|
|
71
|
+
function firstLine(value) {
|
|
72
|
+
return value
|
|
73
|
+
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
74
|
+
.split('\n')
|
|
75
|
+
.map((line) => line.trim())
|
|
76
|
+
.find(Boolean);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cursorVersionParts(version) {
|
|
80
|
+
const match = version?.match(/^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:-|$)/);
|
|
81
|
+
return match?.slice(1).map(Number);
|
|
69
82
|
}
|
|
70
83
|
|
|
71
|
-
|
|
72
|
-
(
|
|
73
|
-
);
|
|
84
|
+
function versionAtLeast(current, minimum) {
|
|
85
|
+
for (const [index, part] of current.entries()) {
|
|
86
|
+
if (part !== minimum[index]) return part > minimum[index];
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cursorCompatibilityError(detail) {
|
|
92
|
+
return `Cursor Agent is incompatible: ${detail} ${cursorRequirementSummary} Upgrade Cursor Agent.`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function inspectCursorCompatibility(
|
|
96
|
+
command,
|
|
97
|
+
{
|
|
98
|
+
env = process.env,
|
|
99
|
+
timeout = 5_000,
|
|
100
|
+
} = {},
|
|
101
|
+
) {
|
|
102
|
+
const run = (args) => spawnSync(command, args, {
|
|
103
|
+
encoding: 'utf8',
|
|
104
|
+
env,
|
|
105
|
+
timeout,
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
});
|
|
108
|
+
const versionResult = run(['--version']);
|
|
109
|
+
const version = firstLine(
|
|
110
|
+
`${versionResult.stdout || ''}\n${versionResult.stderr || ''}`,
|
|
111
|
+
);
|
|
112
|
+
if (versionResult.error || versionResult.status !== 0 || !version) {
|
|
113
|
+
return {
|
|
114
|
+
compatible: false,
|
|
115
|
+
version,
|
|
116
|
+
reason: cursorCompatibilityError('The version check failed.'),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const parts = cursorVersionParts(version);
|
|
120
|
+
if (!parts || !versionAtLeast(parts, minimumCursorVersion)) {
|
|
121
|
+
return {
|
|
122
|
+
compatible: false,
|
|
123
|
+
version,
|
|
124
|
+
reason: cursorCompatibilityError(
|
|
125
|
+
`Found ${version}; version 2026.08.11 or newer is required.`,
|
|
126
|
+
),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const helpResult = run(['--help']);
|
|
130
|
+
const help = `${helpResult.stdout || ''}\n${helpResult.stderr || ''}`;
|
|
131
|
+
if (helpResult.error || helpResult.status !== 0) {
|
|
132
|
+
return {
|
|
133
|
+
compatible: false,
|
|
134
|
+
version,
|
|
135
|
+
reason: cursorCompatibilityError('The CLI help check failed.'),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const requiredHelp = [
|
|
139
|
+
['--mode <mode>', 'Ask mode'],
|
|
140
|
+
['"ask"', 'Ask mode'],
|
|
141
|
+
['--sandbox <mode>', 'sandbox control'],
|
|
142
|
+
['"enabled"', 'sandbox control'],
|
|
143
|
+
['--workspace <path-or-name>', 'workspace selection'],
|
|
144
|
+
['--output-format <format>', 'structured output'],
|
|
145
|
+
['--model <model>', 'model selection'],
|
|
146
|
+
['--trust', 'workspace trust control'],
|
|
147
|
+
];
|
|
148
|
+
const missing = requiredHelp
|
|
149
|
+
.filter(([text]) => !help.includes(text))
|
|
150
|
+
.map(([, label]) => label);
|
|
151
|
+
if (missing.length) {
|
|
152
|
+
return {
|
|
153
|
+
compatible: false,
|
|
154
|
+
version,
|
|
155
|
+
reason: cursorCompatibilityError(
|
|
156
|
+
`The CLI lacks ${[...new Set(missing)].join(', ')}.`,
|
|
157
|
+
),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return { compatible: true, version };
|
|
161
|
+
}
|
|
74
162
|
|
|
75
163
|
async function executable(path) {
|
|
76
164
|
try {
|
|
@@ -123,7 +211,30 @@ export async function commandAvailable(command, options) {
|
|
|
123
211
|
return Boolean(await findCommand(command, options));
|
|
124
212
|
}
|
|
125
213
|
|
|
126
|
-
|
|
214
|
+
export async function codingAgentAvailability(
|
|
215
|
+
agent,
|
|
216
|
+
{
|
|
217
|
+
binary = codingAgentBinary(agent),
|
|
218
|
+
env = process.env,
|
|
219
|
+
platform = process.platform,
|
|
220
|
+
} = {},
|
|
221
|
+
) {
|
|
222
|
+
const path = await findCommand(binary, { env, platform });
|
|
223
|
+
if (!path) return { available: false, installed: false };
|
|
224
|
+
if (agent !== 'cursor') {
|
|
225
|
+
return { available: true, installed: true, path };
|
|
226
|
+
}
|
|
227
|
+
const inspection = inspectCursorCompatibility(path, { env });
|
|
228
|
+
return {
|
|
229
|
+
available: inspection.compatible,
|
|
230
|
+
installed: true,
|
|
231
|
+
path,
|
|
232
|
+
version: inspection.version,
|
|
233
|
+
reason: inspection.reason,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// fallow-ignore-next-line complexity -- validation and discovery share one public selector.
|
|
127
238
|
export async function selectCodingAgent(
|
|
128
239
|
requested,
|
|
129
240
|
available = commandAvailable,
|
|
@@ -134,19 +245,32 @@ export async function selectCodingAgent(
|
|
|
134
245
|
`Unsupported agent "${requested}". Choose ${enabledCodingAgents.join(', ')}.`,
|
|
135
246
|
);
|
|
136
247
|
}
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
248
|
+
const result = await available(requested);
|
|
249
|
+
const availableResult = typeof result === 'object'
|
|
250
|
+
? result.available
|
|
251
|
+
: result;
|
|
252
|
+
if (!availableResult) {
|
|
253
|
+
if (typeof result === 'object' && result.reason) {
|
|
254
|
+
throw new Error(result.reason);
|
|
255
|
+
}
|
|
140
256
|
throw new Error(`Coding agent "${requested}" is not available.`);
|
|
141
257
|
}
|
|
142
258
|
return requested;
|
|
143
259
|
}
|
|
144
260
|
|
|
261
|
+
let cursorReason;
|
|
145
262
|
for (const agent of enabledCodingAgents) {
|
|
146
|
-
|
|
263
|
+
const result = await available(agent);
|
|
264
|
+
const availableResult = typeof result === 'object'
|
|
265
|
+
? result.available
|
|
266
|
+
: result;
|
|
267
|
+
if (availableResult) return agent;
|
|
268
|
+
if (agent === 'cursor' && typeof result === 'object') {
|
|
269
|
+
cursorReason = result.reason;
|
|
270
|
+
}
|
|
147
271
|
}
|
|
148
272
|
throw new Error(
|
|
149
|
-
`No coding agent is available. Install one of: ${enabledCodingAgents.join(', ')}
|
|
273
|
+
`No coding agent is available. Install one of: ${enabledCodingAgents.join(', ')}.${cursorReason ? ` ${cursorReason}` : ''}`,
|
|
150
274
|
);
|
|
151
275
|
}
|
|
152
276
|
|
|
@@ -169,12 +293,29 @@ export function codingAgentBinary(
|
|
|
169
293
|
);
|
|
170
294
|
}
|
|
171
295
|
|
|
296
|
+
function extractJsonValue(text) {
|
|
297
|
+
const start = text.search(/[{[]/);
|
|
298
|
+
if (start === -1) return undefined;
|
|
299
|
+
const opening = text[start];
|
|
300
|
+
const closing = opening === '{' ? '}' : ']';
|
|
301
|
+
const end = text.lastIndexOf(closing);
|
|
302
|
+
if (end <= start) return undefined;
|
|
303
|
+
try {
|
|
304
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
305
|
+
} catch {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
172
310
|
function parseJsonText(text, agent) {
|
|
173
311
|
const trimmed = text.trim();
|
|
174
312
|
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
313
|
+
const candidate = fenced ? fenced[1] : trimmed;
|
|
175
314
|
try {
|
|
176
|
-
return JSON.parse(
|
|
315
|
+
return JSON.parse(candidate);
|
|
177
316
|
} catch {
|
|
317
|
+
const extracted = extractJsonValue(candidate);
|
|
318
|
+
if (extracted !== undefined) return extracted;
|
|
178
319
|
throw new Error(`${agent} did not return valid summary JSON`);
|
|
179
320
|
}
|
|
180
321
|
}
|
|
@@ -207,8 +348,35 @@ function parseOpenCodeResponse(stdout) {
|
|
|
207
348
|
return parseJsonText(parts.join(''), 'OpenCode');
|
|
208
349
|
}
|
|
209
350
|
|
|
351
|
+
export function parseCursorStreamResponse(stdout) {
|
|
352
|
+
const trimmed = stdout.trim();
|
|
353
|
+
const lines = trimmed.split('\n').filter(Boolean);
|
|
354
|
+
const events = lines.map(parseEvent);
|
|
355
|
+
if (!lines.length || !events.every(Boolean)) {
|
|
356
|
+
throw new Error('Cursor did not return a valid event stream');
|
|
357
|
+
}
|
|
358
|
+
const envelope = [...events]
|
|
359
|
+
.reverse()
|
|
360
|
+
.find((event) => event.type === 'result');
|
|
361
|
+
if (!envelope || envelope.subtype !== 'success' || envelope.is_error) {
|
|
362
|
+
throw new Error('Cursor did not return a successful result');
|
|
363
|
+
}
|
|
364
|
+
if (typeof envelope.result !== 'string') {
|
|
365
|
+
throw new Error('Cursor did not return summary JSON');
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
events,
|
|
369
|
+
response: parseJsonText(envelope.result, 'Cursor'),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
210
373
|
function parseCursorResponse(stdout) {
|
|
211
|
-
const
|
|
374
|
+
const trimmed = stdout.trim();
|
|
375
|
+
const lines = trimmed.split('\n').filter(Boolean);
|
|
376
|
+
if (lines.length > 1) {
|
|
377
|
+
return parseCursorStreamResponse(trimmed).response;
|
|
378
|
+
}
|
|
379
|
+
const envelope = parseJsonText(trimmed, 'Cursor');
|
|
212
380
|
if (typeof envelope?.result === 'string') {
|
|
213
381
|
return parseJsonText(envelope.result, 'Cursor');
|
|
214
382
|
}
|
|
@@ -380,6 +548,53 @@ function openCodeCommand({
|
|
|
380
548
|
};
|
|
381
549
|
}
|
|
382
550
|
|
|
551
|
+
function cursorCommand({
|
|
552
|
+
binary,
|
|
553
|
+
inputPath,
|
|
554
|
+
model,
|
|
555
|
+
prompt,
|
|
556
|
+
schema,
|
|
557
|
+
summaryDirectory,
|
|
558
|
+
summaryEnv,
|
|
559
|
+
sourceEnv,
|
|
560
|
+
}) {
|
|
561
|
+
const args = [
|
|
562
|
+
'--print',
|
|
563
|
+
'--output-format',
|
|
564
|
+
'stream-json',
|
|
565
|
+
'--mode',
|
|
566
|
+
'ask',
|
|
567
|
+
'--sandbox',
|
|
568
|
+
'enabled',
|
|
569
|
+
'--trust',
|
|
570
|
+
'--workspace',
|
|
571
|
+
summaryDirectory,
|
|
572
|
+
];
|
|
573
|
+
if (model) args.push('--model', model);
|
|
574
|
+
args.push(
|
|
575
|
+
`${prompt}\n\nRead the snapshot JSON from ${basename(inputPath)} in this workspace. Return JSON that matches this schema:\n${JSON.stringify(schema)}`,
|
|
576
|
+
);
|
|
577
|
+
return {
|
|
578
|
+
command: binary,
|
|
579
|
+
args,
|
|
580
|
+
input: 'stdin',
|
|
581
|
+
cwd: summaryDirectory,
|
|
582
|
+
env: {
|
|
583
|
+
...summaryEnv,
|
|
584
|
+
...(sourceEnv.XDG_CONFIG_HOME
|
|
585
|
+
? { XDG_CONFIG_HOME: sourceEnv.XDG_CONFIG_HOME }
|
|
586
|
+
: {}),
|
|
587
|
+
...(sourceEnv.APPDATA ? { APPDATA: sourceEnv.APPDATA } : {}),
|
|
588
|
+
...(sourceEnv.CURSOR_API_KEY
|
|
589
|
+
? { CURSOR_API_KEY: sourceEnv.CURSOR_API_KEY }
|
|
590
|
+
: {}),
|
|
591
|
+
...(sourceEnv.CURSOR_AUTH_TOKEN
|
|
592
|
+
? { CURSOR_AUTH_TOKEN: sourceEnv.CURSOR_AUTH_TOKEN }
|
|
593
|
+
: {}),
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
383
598
|
export function agentCommand({
|
|
384
599
|
agent,
|
|
385
600
|
binary = agent,
|
|
@@ -391,8 +606,6 @@ export function agentCommand({
|
|
|
391
606
|
inputPath,
|
|
392
607
|
env = process.env,
|
|
393
608
|
}) {
|
|
394
|
-
const disabled = agentDisabledReason(agent);
|
|
395
|
-
if (disabled) throw new Error(disabled);
|
|
396
609
|
const options = {
|
|
397
610
|
binary,
|
|
398
611
|
inputPath,
|
|
@@ -403,9 +616,11 @@ export function agentCommand({
|
|
|
403
616
|
schemaPath,
|
|
404
617
|
summaryDirectory: dirname(inputPath),
|
|
405
618
|
summaryEnv: summaryAgentEnvironment(env),
|
|
619
|
+
sourceEnv: env,
|
|
406
620
|
};
|
|
407
621
|
if (agent === 'codex') return codexCommand(options);
|
|
408
622
|
if (agent === 'claude') return claudeCommand(options);
|
|
409
623
|
if (agent === 'copilot') return copilotCommand(options);
|
|
624
|
+
if (agent === 'cursor') return cursorCommand(options);
|
|
410
625
|
return openCodeCommand(options);
|
|
411
626
|
}
|
package/scripts/doctor.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import {
|
|
3
|
-
agentDisabledReason,
|
|
4
3
|
codingAgentBinary,
|
|
5
4
|
codingAgents,
|
|
6
5
|
findCommand,
|
|
6
|
+
inspectCursorCompatibility,
|
|
7
7
|
} from './coding-agents.mjs';
|
|
8
8
|
|
|
9
9
|
const agentLabels = {
|
|
@@ -76,6 +76,28 @@ async function inspectDependency(label, command, { env, platform }) {
|
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
async function inspectAgent(agent, { env, platform }) {
|
|
80
|
+
const label = agentLabels[agent];
|
|
81
|
+
const command = codingAgentBinary(agent, { env });
|
|
82
|
+
if (agent !== 'cursor') {
|
|
83
|
+
return inspectDependency(label, command, { env, platform });
|
|
84
|
+
}
|
|
85
|
+
const path = await findCommand(command, { env, platform });
|
|
86
|
+
if (!path) {
|
|
87
|
+
return { label, command, installed: false, compatible: 'not-checked' };
|
|
88
|
+
}
|
|
89
|
+
const inspection = inspectCursorCompatibility(path, { env });
|
|
90
|
+
return {
|
|
91
|
+
label,
|
|
92
|
+
command,
|
|
93
|
+
installed: true,
|
|
94
|
+
path,
|
|
95
|
+
version: inspection.version,
|
|
96
|
+
compatible: inspection.compatible ? 'yes' : 'no',
|
|
97
|
+
...(inspection.reason ? { boundaryError: inspection.reason } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
79
101
|
function dependencyLine(dependency) {
|
|
80
102
|
const label = dependency.label.padEnd(9);
|
|
81
103
|
if (dependency.disabled) {
|
|
@@ -84,6 +106,9 @@ function dependencyLine(dependency) {
|
|
|
84
106
|
if (!dependency.installed) {
|
|
85
107
|
return ` ✗ ${label} not found (${dependency.command})`;
|
|
86
108
|
}
|
|
109
|
+
if (dependency.boundaryError) {
|
|
110
|
+
return ` ! ${label} ${dependency.version || 'version unavailable'} (${dependency.path}; ${dependency.boundaryError})`;
|
|
111
|
+
}
|
|
87
112
|
const mark = dependency.version ? '✓' : '!';
|
|
88
113
|
return ` ${mark} ${label} ${dependency.version || 'version unavailable'} (${dependency.path})`;
|
|
89
114
|
}
|
|
@@ -132,23 +157,7 @@ async function inspectDependencies(env, platform) {
|
|
|
132
157
|
const [git, gh, ...agents] = await Promise.all([
|
|
133
158
|
inspectDependency('Git', 'git', { env, platform }),
|
|
134
159
|
inspectDependency('gh', 'gh', { env, platform }),
|
|
135
|
-
...codingAgents.map((agent) => {
|
|
136
|
-
const disabled = agentDisabledReason(agent);
|
|
137
|
-
if (disabled) {
|
|
138
|
-
return Promise.resolve({
|
|
139
|
-
label: agentLabels[agent],
|
|
140
|
-
command: codingAgentBinary(agent, { env }),
|
|
141
|
-
installed: false,
|
|
142
|
-
compatible: 'no',
|
|
143
|
-
disabled,
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
return inspectDependency(
|
|
147
|
-
agentLabels[agent],
|
|
148
|
-
codingAgentBinary(agent, { env }),
|
|
149
|
-
{ env, platform },
|
|
150
|
-
);
|
|
151
|
-
}),
|
|
160
|
+
...codingAgents.map((agent) => inspectAgent(agent, { env, platform })),
|
|
152
161
|
]);
|
|
153
162
|
return { git, gh, agents };
|
|
154
163
|
}
|
|
@@ -15,8 +15,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
import {
|
|
16
16
|
agentCommand,
|
|
17
17
|
assertReasoningSupported,
|
|
18
|
+
codingAgentAvailability,
|
|
18
19
|
codingAgentBinary,
|
|
19
|
-
commandAvailable,
|
|
20
20
|
parseAgentResponse,
|
|
21
21
|
selectCodingAgent,
|
|
22
22
|
} from './coding-agents.mjs';
|
|
@@ -104,8 +104,8 @@ Options:
|
|
|
104
104
|
--summaries FILE Agent note file
|
|
105
105
|
--output FILE Rebuilt Diffsplain JSON
|
|
106
106
|
--cache-dir PATH Bare cache for fetched Git objects
|
|
107
|
-
--agent NAME Use codex, claude, copilot, or opencode
|
|
108
|
-
Cursor
|
|
107
|
+
--agent NAME Use codex, claude, copilot, cursor, or opencode
|
|
108
|
+
Cursor needs version 2026.08.11 or newer
|
|
109
109
|
--codex-bin FILE Codex CLI path (default: codex)
|
|
110
110
|
--model NAME Model passed to the coding agent
|
|
111
111
|
--reasoning LEVEL Agent reasoning effort when supported
|
|
@@ -173,6 +173,7 @@ const proseCodePointLimit = 1_200;
|
|
|
173
173
|
const detailItemLimit = 4;
|
|
174
174
|
const riskItemLimit = 3;
|
|
175
175
|
const listItemCodePointLimit = 500;
|
|
176
|
+
const fileNoteAttemptLimit = 3;
|
|
176
177
|
const jobsValue = option('--jobs') || '3';
|
|
177
178
|
if (!/^[1-9]\d*$/.test(jobsValue) || Number(jobsValue) > 8) {
|
|
178
179
|
fail('--jobs must be a number from 1 to 8');
|
|
@@ -930,7 +931,9 @@ async function selectAgentForNotes() {
|
|
|
930
931
|
try {
|
|
931
932
|
selectedAgent = await selectCodingAgent(
|
|
932
933
|
requestedAgent,
|
|
933
|
-
(agent) =>
|
|
934
|
+
(agent) => codingAgentAvailability(agent, {
|
|
935
|
+
binary: codingAgentBinary(agent, { codexBin }),
|
|
936
|
+
}),
|
|
934
937
|
);
|
|
935
938
|
assertReasoningSupported(selectedAgent, reasoning);
|
|
936
939
|
agentBinary = codingAgentBinary(selectedAgent, { codexBin });
|
|
@@ -973,7 +976,7 @@ function failureReason(error) {
|
|
|
973
976
|
'Agent note generation failed.';
|
|
974
977
|
}
|
|
975
978
|
|
|
976
|
-
function runAgent(invocation, input) {
|
|
979
|
+
function runAgent(invocation, input, { timeoutMs } = {}) {
|
|
977
980
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
978
981
|
const child = spawn(invocation.command, invocation.args, {
|
|
979
982
|
cwd: invocation.cwd || root,
|
|
@@ -981,6 +984,15 @@ function runAgent(invocation, input) {
|
|
|
981
984
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
982
985
|
});
|
|
983
986
|
activeAgentProcesses.add(child);
|
|
987
|
+
const timeout = timeoutMs
|
|
988
|
+
? setTimeout(() => {
|
|
989
|
+
child.kill('SIGTERM');
|
|
990
|
+
rejectPromise(
|
|
991
|
+
new Error(`${selectedAgent} timed out`),
|
|
992
|
+
);
|
|
993
|
+
}, timeoutMs)
|
|
994
|
+
: undefined;
|
|
995
|
+
timeout?.unref();
|
|
984
996
|
const stdout = [];
|
|
985
997
|
const stderr = [];
|
|
986
998
|
let outputBytes = 0;
|
|
@@ -1001,10 +1013,12 @@ function runAgent(invocation, input) {
|
|
|
1001
1013
|
if (error.code !== 'EPIPE') rejectPromise(error);
|
|
1002
1014
|
});
|
|
1003
1015
|
child.once('error', (error) => {
|
|
1016
|
+
if (timeout) clearTimeout(timeout);
|
|
1004
1017
|
activeAgentProcesses.delete(child);
|
|
1005
1018
|
rejectPromise(error);
|
|
1006
1019
|
});
|
|
1007
1020
|
child.once('close', (status, signal) => {
|
|
1021
|
+
if (timeout) clearTimeout(timeout);
|
|
1008
1022
|
activeAgentProcesses.delete(child);
|
|
1009
1023
|
if (interrupted) {
|
|
1010
1024
|
rejectPromise(new Error('Agent note generation was interrupted'));
|
|
@@ -1013,7 +1027,7 @@ function runAgent(invocation, input) {
|
|
|
1013
1027
|
const stdoutText = Buffer.concat(stdout).toString('utf8');
|
|
1014
1028
|
const stderrText = Buffer.concat(stderr).toString('utf8');
|
|
1015
1029
|
if (status !== 0 || signal) {
|
|
1016
|
-
const detail = stderrText
|
|
1030
|
+
const detail = `${stdoutText}\n${stderrText}`
|
|
1017
1031
|
.split('\n')
|
|
1018
1032
|
.map((line) =>
|
|
1019
1033
|
line.replace(
|
|
@@ -1120,6 +1134,10 @@ const temporaryDirectory = mkdtempSync(
|
|
|
1120
1134
|
let workingSummaries;
|
|
1121
1135
|
let workingSnapshot;
|
|
1122
1136
|
|
|
1137
|
+
function agentTemporaryPath(name) {
|
|
1138
|
+
return resolve(temporaryDirectory, name);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1123
1141
|
try {
|
|
1124
1142
|
recordSyncStage('cache', acquireOwnership);
|
|
1125
1143
|
const { rawSnapshot, snapshot } = recordSyncStage('snapshot', () => {
|
|
@@ -1269,10 +1287,9 @@ try {
|
|
|
1269
1287
|
}
|
|
1270
1288
|
if (batch.length) batches.push(batch);
|
|
1271
1289
|
let nextBatch = 0;
|
|
1272
|
-
const requestBatch = async (index, batchPaths) => {
|
|
1273
|
-
const schemaPath =
|
|
1274
|
-
|
|
1275
|
-
`summary-schema-${index + 1}.json`,
|
|
1290
|
+
const requestBatch = async (index, batchPaths, attempt) => {
|
|
1291
|
+
const schemaPath = agentTemporaryPath(
|
|
1292
|
+
`summary-schema-${index + 1}-${attempt}.json`,
|
|
1276
1293
|
);
|
|
1277
1294
|
writeFileSync(
|
|
1278
1295
|
schemaPath,
|
|
@@ -1289,9 +1306,8 @@ try {
|
|
|
1289
1306
|
batchPaths,
|
|
1290
1307
|
workingSummaries.files,
|
|
1291
1308
|
);
|
|
1292
|
-
const inputPath =
|
|
1293
|
-
|
|
1294
|
-
`summary-input-${index + 1}.json`,
|
|
1309
|
+
const inputPath = agentTemporaryPath(
|
|
1310
|
+
`summary-input-${index + 1}-${attempt}.json`,
|
|
1295
1311
|
);
|
|
1296
1312
|
writeFileSync(inputPath, input);
|
|
1297
1313
|
const invocation = agentCommand({
|
|
@@ -1307,7 +1323,7 @@ try {
|
|
|
1307
1323
|
});
|
|
1308
1324
|
|
|
1309
1325
|
console.error(
|
|
1310
|
-
`Asking ${selectedAgent} for batch ${index + 1} of ${batches.length} (${batchPaths.length} changed files)...`,
|
|
1326
|
+
`Asking ${selectedAgent} for batch ${index + 1} of ${batches.length} (${batchPaths.length} changed files, attempt ${attempt} of ${fileNoteAttemptLimit})...`,
|
|
1311
1327
|
);
|
|
1312
1328
|
return requestAgent(
|
|
1313
1329
|
invocation,
|
|
@@ -1321,45 +1337,73 @@ try {
|
|
|
1321
1337
|
};
|
|
1322
1338
|
const runBatch = async (index) => {
|
|
1323
1339
|
const batchPaths = batches[index];
|
|
1324
|
-
let
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1340
|
+
let pendingPaths = batchPaths;
|
|
1341
|
+
for (
|
|
1342
|
+
let attempt = 1;
|
|
1343
|
+
attempt <= fileNoteAttemptLimit && pendingPaths.length;
|
|
1344
|
+
attempt += 1
|
|
1345
|
+
) {
|
|
1346
|
+
let outcome;
|
|
1347
|
+
let requestFailed = false;
|
|
1348
|
+
try {
|
|
1349
|
+
outcome = await requestBatch(index, pendingPaths, attempt);
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
if (interrupted) throw error;
|
|
1352
|
+
requestFailed = true;
|
|
1353
|
+
const reason = failureReason(error);
|
|
1354
|
+
console.error(
|
|
1355
|
+
error instanceof Error ? error.message : String(error),
|
|
1356
|
+
);
|
|
1357
|
+
outcome = {
|
|
1358
|
+
files: {},
|
|
1359
|
+
failedFiles: pendingPaths.map((path) => ({ path, reason })),
|
|
1360
|
+
errors: [],
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
const requestedPaths = new Set(pendingPaths);
|
|
1364
|
+
const retryableFailures = requestFailed
|
|
1365
|
+
? []
|
|
1366
|
+
: outcome.failedFiles.filter(
|
|
1367
|
+
(failure) => requestedPaths.has(failure.path),
|
|
1368
|
+
);
|
|
1369
|
+
const finalAttempt =
|
|
1370
|
+
requestFailed || attempt === fileNoteAttemptLimit;
|
|
1371
|
+
const keptFailures = outcome.failedFiles.filter(
|
|
1372
|
+
(failure) =>
|
|
1373
|
+
requestedPaths.has(failure.path)
|
|
1374
|
+
? finalAttempt
|
|
1375
|
+
: !completeFileNote(workingSummaries.files[failure.path]),
|
|
1332
1376
|
);
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1377
|
+
workingSummaries = {
|
|
1378
|
+
...(workingSummaries.change
|
|
1379
|
+
? { change: workingSummaries.change }
|
|
1380
|
+
: {}),
|
|
1381
|
+
files: {
|
|
1382
|
+
...workingSummaries.files,
|
|
1383
|
+
...outcome.files,
|
|
1384
|
+
},
|
|
1385
|
+
meta: {
|
|
1386
|
+
...workingSummaries.meta,
|
|
1387
|
+
status: 'generating',
|
|
1388
|
+
generatedAt: new Date().toISOString(),
|
|
1389
|
+
},
|
|
1337
1390
|
};
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
...workingSummaries.files,
|
|
1345
|
-
...outcome.files,
|
|
1346
|
-
},
|
|
1347
|
-
meta: {
|
|
1348
|
-
...workingSummaries.meta,
|
|
1349
|
-
status: 'generating',
|
|
1350
|
-
generatedAt: new Date().toISOString(),
|
|
1351
|
-
},
|
|
1352
|
-
};
|
|
1353
|
-
workingSummaries = addFailures(
|
|
1354
|
-
workingSummaries,
|
|
1355
|
-
outcome.failedFiles,
|
|
1356
|
-
outcome.errors,
|
|
1357
|
-
);
|
|
1358
|
-
storeProgress(rawSnapshot, workingSummaries);
|
|
1359
|
-
if (batchPaths.length) {
|
|
1360
|
-
console.log(
|
|
1361
|
-
`Wrote ${Object.keys(workingSummaries.files).length} of ${paths.length} agent notes to ${summariesPath}`,
|
|
1391
|
+
workingSummaries = addFailures(
|
|
1392
|
+
workingSummaries,
|
|
1393
|
+
keptFailures,
|
|
1394
|
+
finalAttempt || retryableFailures.length === 0
|
|
1395
|
+
? outcome.errors
|
|
1396
|
+
: [],
|
|
1362
1397
|
);
|
|
1398
|
+
storeProgress(rawSnapshot, workingSummaries);
|
|
1399
|
+
if (pendingPaths.length) {
|
|
1400
|
+
console.log(
|
|
1401
|
+
`Wrote ${Object.keys(workingSummaries.files).length} of ${paths.length} agent notes to ${summariesPath}`,
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
pendingPaths = [...new Set(
|
|
1405
|
+
retryableFailures.map((failure) => failure.path),
|
|
1406
|
+
)];
|
|
1363
1407
|
}
|
|
1364
1408
|
};
|
|
1365
1409
|
const workers = Array.from(
|
|
@@ -1375,10 +1419,7 @@ try {
|
|
|
1375
1419
|
await Promise.all(workers);
|
|
1376
1420
|
if (changeNeedsRefresh) {
|
|
1377
1421
|
try {
|
|
1378
|
-
const schemaPath =
|
|
1379
|
-
temporaryDirectory,
|
|
1380
|
-
'change-summary-schema.json',
|
|
1381
|
-
);
|
|
1422
|
+
const schemaPath = agentTemporaryPath('change-summary-schema.json');
|
|
1382
1423
|
const schema = outputSchema([]);
|
|
1383
1424
|
writeFileSync(
|
|
1384
1425
|
schemaPath,
|
|
@@ -1390,10 +1431,7 @@ try {
|
|
|
1390
1431
|
[],
|
|
1391
1432
|
workingSummaries.files,
|
|
1392
1433
|
);
|
|
1393
|
-
const inputPath =
|
|
1394
|
-
temporaryDirectory,
|
|
1395
|
-
'change-summary-input.json',
|
|
1396
|
-
);
|
|
1434
|
+
const inputPath = agentTemporaryPath('change-summary-input.json');
|
|
1397
1435
|
writeFileSync(inputPath, input);
|
|
1398
1436
|
const invocation = agentCommand({
|
|
1399
1437
|
agent: selectedAgent,
|
package/scripts/present.mjs
CHANGED
|
@@ -18,8 +18,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
18
18
|
import { helpText, parseCliArgs } from './cli-args.mjs';
|
|
19
19
|
import {
|
|
20
20
|
assertReasoningSupported,
|
|
21
|
+
codingAgentAvailability,
|
|
21
22
|
codingAgentBinary,
|
|
22
|
-
commandAvailable,
|
|
23
23
|
selectCodingAgent,
|
|
24
24
|
} from './coding-agents.mjs';
|
|
25
25
|
import { doctorReport } from './doctor.mjs';
|
|
@@ -189,9 +189,9 @@ if (agentEnabled) {
|
|
|
189
189
|
selectedAgent = await selectCodingAgent(
|
|
190
190
|
cli.agent,
|
|
191
191
|
(agent) =>
|
|
192
|
-
|
|
193
|
-
codingAgentBinary(agent, { codexBin: cli.codexBin }),
|
|
194
|
-
),
|
|
192
|
+
codingAgentAvailability(agent, {
|
|
193
|
+
binary: codingAgentBinary(agent, { codexBin: cli.codexBin }),
|
|
194
|
+
}),
|
|
195
195
|
);
|
|
196
196
|
assertReasoningSupported(selectedAgent, cli.reasoning);
|
|
197
197
|
const agentBinary = codingAgentBinary(selectedAgent, {
|