@shipwithai/loopnote 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -2
- package/THIRD_PARTY_NOTICES.md +580 -0
- package/dist/bin/loopnote.mjs +5 -0
- package/dist/cli/confirm.js +38 -0
- package/dist/cli/picker.js +342 -0
- package/dist/cli/request.js +120 -0
- package/dist/cli/run.js +970 -0
- package/dist/contracts/api.js +640 -0
- package/dist/contracts/comparison.js +178 -0
- package/dist/contracts/draft.js +606 -0
- package/dist/contracts/request.js +124 -0
- package/dist/domain/anchor.js +122 -0
- package/dist/domain/comparison-key.js +51 -0
- package/dist/domain/errors.js +43 -0
- package/dist/domain/path-bytes.js +82 -0
- package/dist/domain/product-identity.js +66 -0
- package/dist/domain/source.js +4 -0
- package/dist/draft/mutate-draft.js +87 -0
- package/dist/export/render-review-markdown.js +163 -0
- package/dist/export/review-export.js +273 -0
- package/dist/git/availability.js +92 -0
- package/dist/git/candidates.js +413 -0
- package/dist/git/comparison.js +263 -0
- package/dist/git/exact-patch.js +702 -0
- package/dist/git/ignore-status.js +23 -0
- package/dist/git/inventory.js +160 -0
- package/dist/git/numstat.js +111 -0
- package/dist/git/objects.js +95 -0
- package/dist/git/raw-diff.js +84 -0
- package/dist/git/repository.js +144 -0
- package/dist/git/runner.js +145 -0
- package/dist/git/selector-drift.js +169 -0
- package/dist/git/standalone-resolution.js +174 -0
- package/dist/git/worktree-content.js +97 -0
- package/dist/git/worktree-inventory.js +391 -0
- package/dist/native/directory_exchange.node +0 -0
- package/dist/server/app.js +171 -0
- package/dist/server/attached-completion.js +85 -0
- package/dist/server/capabilities.js +1572 -0
- package/dist/server/draft-loader.js +134 -0
- package/dist/server/draft-recovery.js +5 -0
- package/dist/server/draft-store.js +380 -0
- package/dist/server/export-store.js +291 -0
- package/dist/server/gitignore-capability.js +197 -0
- package/dist/server/image-content.js +101 -0
- package/dist/server/lifecycle.js +72 -0
- package/dist/server/native-exchange-capability.js +45 -0
- package/dist/server/patch-snapshot.js +500 -0
- package/dist/server/routes.js +418 -0
- package/dist/server/security.js +123 -0
- package/dist/server/snapshot-fs.js +58 -0
- package/dist/server/support-client.js +76 -0
- package/dist/server/support-store.js +194 -0
- package/dist/server/worktree-snapshot.js +553 -0
- package/dist/web/assets/abap-08VXUWAP.js +1 -0
- package/dist/web/assets/apex-BWPQTe0t.js +1 -0
- package/dist/web/assets/azcli-Bc_sGQ0U.js +1 -0
- package/dist/web/assets/bat-i0X4ZdIN.js +1 -0
- package/dist/web/assets/bicep-B5-_aFwp.js +2 -0
- package/dist/web/assets/cameligo-DMUM7wLl.js +1 -0
- package/dist/web/assets/clojure-Cm7r79vr.js +1 -0
- package/dist/web/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/dist/web/assets/coffee-Ba7i2nA0.js +1 -0
- package/dist/web/assets/cpp-C7h46wYY.js +1 -0
- package/dist/web/assets/csharp-BKxtCVv1.js +1 -0
- package/dist/web/assets/csp-bTuwJoIa.js +1 -0
- package/dist/web/assets/css-DIMkf-bt.js +3 -0
- package/dist/web/assets/css.worker-yKcq13f2.js +89 -0
- package/dist/web/assets/cssMode-CpBTfhMg.js +1 -0
- package/dist/web/assets/cypher-CVaqCwHa.js +1 -0
- package/dist/web/assets/dart-onAF5SnQ.js +1 -0
- package/dist/web/assets/dockerfile-DZFCIeNp.js +1 -0
- package/dist/web/assets/ecl-D05T4iGw.js +1 -0
- package/dist/web/assets/editor.worker-tytyn86m.js +26 -0
- package/dist/web/assets/elixir-6RTg0lbw.js +1 -0
- package/dist/web/assets/flow9-C5_-GSwl.js +1 -0
- package/dist/web/assets/freemarker2-skQ63wb7.js +3 -0
- package/dist/web/assets/fsharp-C8Ef5oNN.js +1 -0
- package/dist/web/assets/go-C-y9NEjX.js +1 -0
- package/dist/web/assets/graphql-fmXr3nnJ.js +1 -0
- package/dist/web/assets/handlebars-CqvPAZY8.js +1 -0
- package/dist/web/assets/hcl-CpzslTdj.js +1 -0
- package/dist/web/assets/html-BDAqstpo.js +1 -0
- package/dist/web/assets/html.worker-DSJ9zmd7.js +502 -0
- package/dist/web/assets/htmlMode-BRYA3pnT.js +1 -0
- package/dist/web/assets/index-CCeYBqPo.js +1111 -0
- package/dist/web/assets/index-CRj9ojWJ.css +1 -0
- package/dist/web/assets/ini-sBoK_t0W.js +1 -0
- package/dist/web/assets/java-BEtHBSE6.js +1 -0
- package/dist/web/assets/javascript-1Ywytw67.js +1 -0
- package/dist/web/assets/json.worker-Oa9UrkKE.js +58 -0
- package/dist/web/assets/jsonMode-AO1C_0kX.js +7 -0
- package/dist/web/assets/julia-Bri6UV-V.js +1 -0
- package/dist/web/assets/kotlin-BOotOW0E.js +1 -0
- package/dist/web/assets/less-B9JPFI3C.js +2 -0
- package/dist/web/assets/lexon-CfSJPG6W.js +1 -0
- package/dist/web/assets/liquid-CkkqyyGc.js +1 -0
- package/dist/web/assets/lspLanguageFeatures-CYOFEMZp.js +4 -0
- package/dist/web/assets/lua-CsQS60Ue.js +1 -0
- package/dist/web/assets/m3-D-oSqn_W.js +1 -0
- package/dist/web/assets/markdown-Cimd5fb3.js +1 -0
- package/dist/web/assets/mdx-Bd3Wlfqw.js +1 -0
- package/dist/web/assets/mips-CIPQ_RoX.js +1 -0
- package/dist/web/assets/msdax-DauUninz.js +1 -0
- package/dist/web/assets/mysql-SOo6toE5.js +1 -0
- package/dist/web/assets/objective-c-FvmIjYaQ.js +1 -0
- package/dist/web/assets/pascal-DrH0SRf2.js +1 -0
- package/dist/web/assets/pascaligo-D-ptJ9y-.js +1 -0
- package/dist/web/assets/perl-oz_6vUea.js +1 -0
- package/dist/web/assets/pgsql-DTj74zXo.js +1 -0
- package/dist/web/assets/php-nr791fC2.js +1 -0
- package/dist/web/assets/pla-CopQ2nXW.js +1 -0
- package/dist/web/assets/postiats-43DmfD33.js +1 -0
- package/dist/web/assets/powerquery-D3hlyOfw.js +1 -0
- package/dist/web/assets/powershell-DmHpPYUd.js +1 -0
- package/dist/web/assets/protobuf-C531GsRP.js +2 -0
- package/dist/web/assets/pug-Z5eAx3Zn.js +1 -0
- package/dist/web/assets/python-CET4IumD.js +1 -0
- package/dist/web/assets/qsharp-DkqhCAOL.js +1 -0
- package/dist/web/assets/r-BwWrilGY.js +1 -0
- package/dist/web/assets/razor-d-reaHeT.js +1 -0
- package/dist/web/assets/redis-ClamHrr6.js +1 -0
- package/dist/web/assets/redshift-DT7zqm-g.js +1 -0
- package/dist/web/assets/restructuredtext-BYgofb2h.js +1 -0
- package/dist/web/assets/ruby-DezsRK8O.js +1 -0
- package/dist/web/assets/rust-DdL9SqIa.js +1 -0
- package/dist/web/assets/sb-CcwsVR0C.js +1 -0
- package/dist/web/assets/scala-DHpiXF5c.js +1 -0
- package/dist/web/assets/scheme-BeGwcela.js +1 -0
- package/dist/web/assets/scss-gp-XZpBa.js +3 -0
- package/dist/web/assets/shell-CC2rA5mh.js +1 -0
- package/dist/web/assets/solidity-BEEn4gHE.js +1 -0
- package/dist/web/assets/sophia-CRfGWb83.js +1 -0
- package/dist/web/assets/sparql-D_Lu-MrJ.js +1 -0
- package/dist/web/assets/sql-NEE52Syq.js +1 -0
- package/dist/web/assets/st-DbInun42.js +1 -0
- package/dist/web/assets/swift-Bxkupp3x.js +1 -0
- package/dist/web/assets/systemverilog-Bz4Y3fRF.js +1 -0
- package/dist/web/assets/tcl-DISqw1ZD.js +1 -0
- package/dist/web/assets/ts.worker-BtjBFNHX.js +67734 -0
- package/dist/web/assets/tsMode-DLxSAtkD.js +11 -0
- package/dist/web/assets/twig-De2hgUGE.js +1 -0
- package/dist/web/assets/typescript-DprEVA-S.js +1 -0
- package/dist/web/assets/typespec-B8J7ngcE.js +1 -0
- package/dist/web/assets/vb-DV3o63ZY.js +1 -0
- package/dist/web/assets/wgsl-DpFanUEy.js +298 -0
- package/dist/web/assets/xml-B7ZwKOhJ.js +1 -0
- package/dist/web/assets/yaml-OQ9Asg7B.js +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +87 -4
package/dist/cli/run.js
ADDED
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import open from 'open';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { confirmPinnedComparison } from './confirm.js';
|
|
8
|
+
import { AgentRequestError, readAgentReviewRequest } from './request.js';
|
|
9
|
+
import { pickSource, } from './picker.js';
|
|
10
|
+
import { ComparisonSelectionSchema, GitObjectIdSchema, UncommittedScopeSchema, } from '../contracts/comparison.js';
|
|
11
|
+
import { isLaunchError } from '../domain/errors.js';
|
|
12
|
+
import { RENAME_MAP } from '../domain/product-identity.js';
|
|
13
|
+
import { discoverSourceCandidates } from '../git/candidates.js';
|
|
14
|
+
import { discoverGitRepository } from '../git/repository.js';
|
|
15
|
+
import { createGitRunner, GitRunnerError } from '../git/runner.js';
|
|
16
|
+
import { createWorktreeChangedFileInventory } from '../git/worktree-inventory.js';
|
|
17
|
+
import { classifyStandaloneGitState, createStandaloneChangedFileInventory, resolveStandaloneArguments, StandaloneResolutionError, } from '../git/standalone-resolution.js';
|
|
18
|
+
import { createPinnedComparison, createPinnedRangeComparison, } from '../git/comparison.js';
|
|
19
|
+
import { createGroundedExactPatch, ExactPatchGroundingError, } from '../git/exact-patch.js';
|
|
20
|
+
import { createConfiguredSupportCapability, createExactPatchSessionApp, createSessionApp, createStandaloneSessionApp, createUncommittedSessionApp, } from '../server/app.js';
|
|
21
|
+
import { createAttachedCompletionCoordinator, } from '../server/attached-completion.js';
|
|
22
|
+
import { PatchSnapshotError } from '../server/patch-snapshot.js';
|
|
23
|
+
import { findUncommittedDraftCandidate } from '../server/draft-store.js';
|
|
24
|
+
import { createShutdownController, } from '../server/lifecycle.js';
|
|
25
|
+
const expectedPackageName = RENAME_MAP.npmPackage;
|
|
26
|
+
const semanticVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
|
|
27
|
+
export async function readPackageVersionFromManifest(manifestUrl = new URL('../../package.json', import.meta.url)) {
|
|
28
|
+
const manifest = JSON.parse(await readFile(manifestUrl, 'utf8'));
|
|
29
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
30
|
+
throw new Error('Package manifest must be an object');
|
|
31
|
+
}
|
|
32
|
+
const { name, version } = manifest;
|
|
33
|
+
if (name !== expectedPackageName) {
|
|
34
|
+
throw new Error(`Package manifest name must be ${expectedPackageName}`);
|
|
35
|
+
}
|
|
36
|
+
if (typeof version !== 'string' || !semanticVersionPattern.test(version)) {
|
|
37
|
+
throw new Error('Package manifest version must be a semantic version');
|
|
38
|
+
}
|
|
39
|
+
return version;
|
|
40
|
+
}
|
|
41
|
+
const packagedLaunchOptionsSchema = z.strictObject({
|
|
42
|
+
cwd: z.string().min(1),
|
|
43
|
+
base: ComparisonSelectionSchema,
|
|
44
|
+
head: ComparisonSelectionSchema,
|
|
45
|
+
});
|
|
46
|
+
const browserFallback = 'Open the URL above if the browser did not open. Press Ctrl+C to stop.';
|
|
47
|
+
function runCmux(executable, arguments_) {
|
|
48
|
+
const { promise, reject, resolve } = Promise.withResolvers();
|
|
49
|
+
const child = spawn(executable, arguments_, { shell: false, stdio: 'ignore' });
|
|
50
|
+
child.once('error', reject);
|
|
51
|
+
child.once('close', (code) => {
|
|
52
|
+
if (code === 0) {
|
|
53
|
+
resolve();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
reject(new Error('cmux could not open the browser URL'));
|
|
57
|
+
});
|
|
58
|
+
return promise;
|
|
59
|
+
}
|
|
60
|
+
export function createBrowserUrlOpener(dependencies = {}) {
|
|
61
|
+
const environment = dependencies.environment ?? process.env;
|
|
62
|
+
const openSystemBrowser = dependencies.openSystemBrowser ??
|
|
63
|
+
(async (url) => {
|
|
64
|
+
await open(url);
|
|
65
|
+
});
|
|
66
|
+
const runCmuxCommand = dependencies.runCmux ?? runCmux;
|
|
67
|
+
return async (url) => {
|
|
68
|
+
if (environment.CMUX_WORKSPACE_ID !== undefined) {
|
|
69
|
+
await runCmuxCommand('cmux', ['open', url]);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await openSystemBrowser(url);
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export async function createComparisonLaunchDescriptor(options) {
|
|
76
|
+
return await createPinnedComparison(options);
|
|
77
|
+
}
|
|
78
|
+
function createLaunchRuntime(dependencies) {
|
|
79
|
+
const support = dependencies.support ?? createConfiguredSupportCapability();
|
|
80
|
+
const activeGit = new AbortController();
|
|
81
|
+
const output = dependencies.output ?? console.log;
|
|
82
|
+
const openBrowser = dependencies.openBrowser ?? createBrowserUrlOpener();
|
|
83
|
+
const revealDraftFile = dependencies.revealDraftFile ??
|
|
84
|
+
(async (canonicalPath) => {
|
|
85
|
+
await open(canonicalPath);
|
|
86
|
+
});
|
|
87
|
+
let app;
|
|
88
|
+
const shutdown = createShutdownController({
|
|
89
|
+
signalSource: dependencies.signalSource,
|
|
90
|
+
abortActiveWork: () => {
|
|
91
|
+
activeGit.abort();
|
|
92
|
+
},
|
|
93
|
+
closeListener: async () => {
|
|
94
|
+
if (app !== undefined) {
|
|
95
|
+
await app.close();
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
setExitStatus: dependencies.setExitStatus,
|
|
99
|
+
reportError: (error) => {
|
|
100
|
+
console.error(`${RENAME_MAP.displayName} shutdown failed.`, error);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const fail = async (error) => {
|
|
104
|
+
if (shutdown.isShuttingDown) {
|
|
105
|
+
await shutdown.shutdown();
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
await shutdown.shutdown(1);
|
|
109
|
+
throw error;
|
|
110
|
+
};
|
|
111
|
+
const launch = async (comparison) => {
|
|
112
|
+
try {
|
|
113
|
+
if (shutdown.isShuttingDown) {
|
|
114
|
+
await shutdown.shutdown();
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
const token = randomBytes(32).toString('base64url');
|
|
118
|
+
app = createSessionApp(comparison, {
|
|
119
|
+
webRoot: dependencies.webRoot,
|
|
120
|
+
support,
|
|
121
|
+
sessionToken: token,
|
|
122
|
+
revealDraftFile,
|
|
123
|
+
diagnostics: ({ correlationId, reason }) => {
|
|
124
|
+
console.error(`${RENAME_MAP.displayName} request denied [${correlationId}]: ${reason}.`);
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
await app.listen({ host: '127.0.0.1', port: 0 });
|
|
128
|
+
if (shutdown.isShuttingDown) {
|
|
129
|
+
await shutdown.shutdown();
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
const address = app.server.address();
|
|
133
|
+
if (address === null ||
|
|
134
|
+
typeof address === 'string' ||
|
|
135
|
+
address.address !== '127.0.0.1' ||
|
|
136
|
+
address.port === 0) {
|
|
137
|
+
throw new Error('Fastify did not bind the required IPv4 loopback address');
|
|
138
|
+
}
|
|
139
|
+
const authority = `127.0.0.1:${address.port}`;
|
|
140
|
+
app.bindSessionSecurity({
|
|
141
|
+
expectedHost: authority,
|
|
142
|
+
expectedOrigin: `http://${authority}`,
|
|
143
|
+
});
|
|
144
|
+
const url = `http://${authority}/#token=${token}`;
|
|
145
|
+
output(url);
|
|
146
|
+
output(browserFallback);
|
|
147
|
+
try {
|
|
148
|
+
await openBrowser(url);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
output('Browser did not open automatically. Open the URL above manually.');
|
|
152
|
+
}
|
|
153
|
+
return { comparison, shutdown, url };
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return await fail(error);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
return { activeGit, launch, fail };
|
|
160
|
+
}
|
|
161
|
+
export async function launchPinnedComparison(comparison, dependencies = {}) {
|
|
162
|
+
return await createLaunchRuntime(dependencies).launch(comparison);
|
|
163
|
+
}
|
|
164
|
+
export async function launchPinnedSession(options, dependencies = {}) {
|
|
165
|
+
const runtime = createLaunchRuntime(dependencies);
|
|
166
|
+
let comparison;
|
|
167
|
+
try {
|
|
168
|
+
comparison = await createComparisonLaunchDescriptor({
|
|
169
|
+
...options,
|
|
170
|
+
signal: options.signal === undefined
|
|
171
|
+
? runtime.activeGit.signal
|
|
172
|
+
: AbortSignal.any([options.signal, runtime.activeGit.signal]),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
return await runtime.fail(error);
|
|
177
|
+
}
|
|
178
|
+
return await runtime.launch(comparison);
|
|
179
|
+
}
|
|
180
|
+
function selectionForCandidate(candidate) {
|
|
181
|
+
if (candidate.kind === 'branch') {
|
|
182
|
+
return {
|
|
183
|
+
label: candidate.label,
|
|
184
|
+
revision: candidate.refName,
|
|
185
|
+
source: {
|
|
186
|
+
kind: 'branch',
|
|
187
|
+
id: candidate.id,
|
|
188
|
+
refName: candidate.refName,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return selectionForWorktree(candidate);
|
|
193
|
+
}
|
|
194
|
+
function selectionForWorktree(candidate) {
|
|
195
|
+
if ((candidate.availability !== 'clean' &&
|
|
196
|
+
candidate.availability !== 'dirty') ||
|
|
197
|
+
candidate.commitOid === undefined) {
|
|
198
|
+
throw new Error(`Unavailable worktree cannot be selected: ${candidate.path}`);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
label: candidate.label,
|
|
202
|
+
revision: candidate.commitOid,
|
|
203
|
+
source: {
|
|
204
|
+
kind: 'worktree',
|
|
205
|
+
id: candidate.id,
|
|
206
|
+
path: candidate.path,
|
|
207
|
+
detached: candidate.detached,
|
|
208
|
+
dirty: candidate.availability === 'dirty',
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function reportFatalLaunchError(error, output, setExitStatus) {
|
|
213
|
+
output(error.message);
|
|
214
|
+
setExitStatus(1);
|
|
215
|
+
}
|
|
216
|
+
const UNSTAGED_BASE_CONFLICT_MESSAGE = '--base cannot be used with --unstaged — Unstaged always compares the index against the working tree.';
|
|
217
|
+
export function resolveUncommittedFlagSelection(cliOptions) {
|
|
218
|
+
if (cliOptions.staged === true) {
|
|
219
|
+
return 'staged';
|
|
220
|
+
}
|
|
221
|
+
if (cliOptions.unstaged === true) {
|
|
222
|
+
return 'unstaged';
|
|
223
|
+
}
|
|
224
|
+
if (cliOptions.worktree === true) {
|
|
225
|
+
return 'worktree';
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
export function validateUncommittedFlags(cliOptions) {
|
|
230
|
+
const selectedCount = [
|
|
231
|
+
cliOptions.staged,
|
|
232
|
+
cliOptions.unstaged,
|
|
233
|
+
cliOptions.worktree,
|
|
234
|
+
].filter((flag) => flag === true).length;
|
|
235
|
+
if (selectedCount > 1) {
|
|
236
|
+
throw new Error('--staged, --unstaged, and --worktree are mutually exclusive. Choose exactly one.');
|
|
237
|
+
}
|
|
238
|
+
if (cliOptions.unstaged === true && cliOptions.base !== undefined) {
|
|
239
|
+
throw new Error(UNSTAGED_BASE_CONFLICT_MESSAGE);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
export function validateStandaloneExclusivity(paths, cliOptions) {
|
|
243
|
+
if (paths.length === 0) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (cliOptions.staged === true ||
|
|
247
|
+
cliOptions.unstaged === true ||
|
|
248
|
+
cliOptions.worktree === true ||
|
|
249
|
+
cliOptions.base !== undefined) {
|
|
250
|
+
throw new Error('Positional paths cannot be combined with --staged, --unstaged, --worktree, or --base.');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function resolveUncommittedObjectFormat(repositoryRoot, runner, signal) {
|
|
254
|
+
const result = await runner.run(['rev-parse', '--show-object-format=storage'], { cwd: repositoryRoot, signal });
|
|
255
|
+
const objectFormat = result.stdout.toString('utf8').trim();
|
|
256
|
+
if (objectFormat !== 'sha1' && objectFormat !== 'sha256') {
|
|
257
|
+
throw new Error('Git repository reports an unsupported object format.');
|
|
258
|
+
}
|
|
259
|
+
return objectFormat;
|
|
260
|
+
}
|
|
261
|
+
async function resolveUncommittedBaseCommitOid(baseRef, repository, runner, signal) {
|
|
262
|
+
const revision = baseRef ?? repository.headOid;
|
|
263
|
+
try {
|
|
264
|
+
const result = await runner.run(['rev-parse', '--verify', '--end-of-options', `${revision}^{commit}`], { cwd: repository.root, signal });
|
|
265
|
+
return GitObjectIdSchema.parse(result.stdout.toString('ascii').trim());
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (signal?.aborted) {
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
throw new Error(`Base ref "${revision}" could not be resolved to a commit.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async function resolveUncommittedMergeBaseOid(baseCommitOid, headOid, repositoryRoot, runner, signal) {
|
|
275
|
+
let stdout = Buffer.alloc(0);
|
|
276
|
+
try {
|
|
277
|
+
const result = await runner.run(['merge-base', '--all', baseCommitOid, headOid], { cwd: repositoryRoot, signal });
|
|
278
|
+
stdout = result.stdout;
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (!(error instanceof GitRunnerError &&
|
|
282
|
+
error.kind === 'exit' &&
|
|
283
|
+
error.exitCode === 1 &&
|
|
284
|
+
error.stderr.length === 0)) {
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const mergeBases = stdout
|
|
289
|
+
.toString('ascii')
|
|
290
|
+
.split('\n')
|
|
291
|
+
.map((line) => line.trim())
|
|
292
|
+
.filter((line) => line.length > 0);
|
|
293
|
+
if (mergeBases.length === 0) {
|
|
294
|
+
throw new Error('Base and HEAD have unrelated histories; Git could not find a merge base.');
|
|
295
|
+
}
|
|
296
|
+
if (mergeBases.length > 1) {
|
|
297
|
+
throw new Error('Base and HEAD have multiple merge bases, so this comparison cannot be resolved unambiguously.');
|
|
298
|
+
}
|
|
299
|
+
return GitObjectIdSchema.parse(mergeBases[0]);
|
|
300
|
+
}
|
|
301
|
+
function registerUncommittedRefreshKeypress(authority, token, output, shutdown) {
|
|
302
|
+
const stdin = process.stdin;
|
|
303
|
+
if (stdin.isTTY !== true) {
|
|
304
|
+
return () => undefined;
|
|
305
|
+
}
|
|
306
|
+
const wasRaw = stdin.isRaw === true;
|
|
307
|
+
stdin.setRawMode(true);
|
|
308
|
+
stdin.resume();
|
|
309
|
+
const onData = (chunk) => {
|
|
310
|
+
const key = chunk.toString('utf8');
|
|
311
|
+
if (key === '\u0003') {
|
|
312
|
+
shutdown.shutdown(130).catch((error) => {
|
|
313
|
+
console.error(`${RENAME_MAP.displayName} shutdown failed.`, error);
|
|
314
|
+
});
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (key === 'r') {
|
|
318
|
+
void fetch(`http://${authority}/api/refresh`, {
|
|
319
|
+
method: 'POST',
|
|
320
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
321
|
+
})
|
|
322
|
+
.then((response) => {
|
|
323
|
+
output(response.ok
|
|
324
|
+
? 'Refreshed the uncommitted session snapshot.'
|
|
325
|
+
: 'Could not refresh the uncommitted session snapshot.');
|
|
326
|
+
})
|
|
327
|
+
.catch(() => {
|
|
328
|
+
output('Could not refresh the uncommitted session snapshot.');
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
stdin.on('data', onData);
|
|
333
|
+
return () => {
|
|
334
|
+
stdin.off('data', onData);
|
|
335
|
+
if (stdin.isTTY === true) {
|
|
336
|
+
stdin.setRawMode(wasRaw);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
async function launchUncommittedSession(selection, dependencies, options) {
|
|
341
|
+
const output = dependencies.output ?? console.error;
|
|
342
|
+
const support = dependencies.support ?? createConfiguredSupportCapability();
|
|
343
|
+
const openBrowser = dependencies.openBrowser ?? createBrowserUrlOpener();
|
|
344
|
+
const revealDraftFile = dependencies.revealDraftFile ??
|
|
345
|
+
(async (canonicalPath) => {
|
|
346
|
+
await open(canonicalPath);
|
|
347
|
+
});
|
|
348
|
+
const createApp = dependencies.createUncommittedSessionApp ??
|
|
349
|
+
((scope, appOptions) => createUncommittedSessionApp(scope, appOptions));
|
|
350
|
+
const findDraftCandidate = dependencies.findUncommittedDraftCandidate ?? findUncommittedDraftCandidate;
|
|
351
|
+
const activeGit = new AbortController();
|
|
352
|
+
let app;
|
|
353
|
+
let stopRefreshKeypress;
|
|
354
|
+
const shutdown = createShutdownController({
|
|
355
|
+
signalSource: dependencies.signalSource,
|
|
356
|
+
abortActiveWork: () => {
|
|
357
|
+
activeGit.abort();
|
|
358
|
+
},
|
|
359
|
+
closeListener: async () => {
|
|
360
|
+
stopRefreshKeypress?.();
|
|
361
|
+
if (app !== undefined) {
|
|
362
|
+
await app.close();
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
setExitStatus: dependencies.setExitStatus,
|
|
366
|
+
reportError: (error) => {
|
|
367
|
+
console.error(`${RENAME_MAP.displayName} shutdown failed.`, error);
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
try {
|
|
371
|
+
const runner = createGitRunner();
|
|
372
|
+
const repository = await discoverGitRepository(options.cwd, runner, activeGit.signal);
|
|
373
|
+
const objectFormat = await resolveUncommittedObjectFormat(repository.root, runner, activeGit.signal);
|
|
374
|
+
let baseCommitOid;
|
|
375
|
+
let mergeBaseOid;
|
|
376
|
+
if (selection.sourceKind !== 'unstaged') {
|
|
377
|
+
baseCommitOid = await resolveUncommittedBaseCommitOid(selection.baseRef, repository, runner, activeGit.signal);
|
|
378
|
+
mergeBaseOid = await resolveUncommittedMergeBaseOid(baseCommitOid, repository.headOid, repository.root, runner, activeGit.signal);
|
|
379
|
+
}
|
|
380
|
+
const changedFiles = await createWorktreeChangedFileInventory({
|
|
381
|
+
sourceKind: selection.sourceKind,
|
|
382
|
+
repositoryRoot: repository.root,
|
|
383
|
+
objectFormat,
|
|
384
|
+
signal: activeGit.signal,
|
|
385
|
+
...(mergeBaseOid === undefined ? {} : { mergeBaseOid }),
|
|
386
|
+
}, { runner });
|
|
387
|
+
const scope = UncommittedScopeSchema.parse({
|
|
388
|
+
kind: 'uncommitted',
|
|
389
|
+
sourceKind: selection.sourceKind,
|
|
390
|
+
repositoryRoot: repository.root,
|
|
391
|
+
objectFormat,
|
|
392
|
+
changedFiles,
|
|
393
|
+
...(baseCommitOid === undefined ? {} : { baseCommitOid }),
|
|
394
|
+
...(mergeBaseOid === undefined ? {} : { mergeBaseOid }),
|
|
395
|
+
});
|
|
396
|
+
const recoveryCandidate = await findDraftCandidate(repository.root, selection.sourceKind, baseCommitOid);
|
|
397
|
+
if (shutdown.isShuttingDown) {
|
|
398
|
+
await shutdown.shutdown();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const token = randomBytes(32).toString('base64url');
|
|
402
|
+
app = await createApp(scope, {
|
|
403
|
+
webRoot: dependencies.webRoot,
|
|
404
|
+
support,
|
|
405
|
+
sessionToken: token,
|
|
406
|
+
revealDraftFile,
|
|
407
|
+
...(recoveryCandidate === undefined ? {} : { uncommittedRecoveryCandidate: { path: recoveryCandidate.path } }),
|
|
408
|
+
diagnostics: ({ correlationId, reason }) => {
|
|
409
|
+
console.error(`${RENAME_MAP.displayName} request denied [${correlationId}]: ${reason}.`);
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
await app.listen({ host: '127.0.0.1', port: 0 });
|
|
413
|
+
if (shutdown.isShuttingDown) {
|
|
414
|
+
await shutdown.shutdown();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const address = app.server.address();
|
|
418
|
+
if (address === null ||
|
|
419
|
+
typeof address === 'string' ||
|
|
420
|
+
address.address !== '127.0.0.1' ||
|
|
421
|
+
address.port === 0) {
|
|
422
|
+
throw new Error('Fastify did not bind the required IPv4 loopback address');
|
|
423
|
+
}
|
|
424
|
+
const authority = `127.0.0.1:${address.port}`;
|
|
425
|
+
app.bindSessionSecurity({
|
|
426
|
+
expectedHost: authority,
|
|
427
|
+
expectedOrigin: `http://${authority}`,
|
|
428
|
+
});
|
|
429
|
+
const url = `http://${authority}/#token=${token}`;
|
|
430
|
+
output(url);
|
|
431
|
+
output(browserFallback);
|
|
432
|
+
try {
|
|
433
|
+
await openBrowser(url);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
output('Browser did not open automatically. Open the URL above manually.');
|
|
437
|
+
}
|
|
438
|
+
stopRefreshKeypress = registerUncommittedRefreshKeypress(authority, token, output, shutdown);
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
if (shutdown.isShuttingDown) {
|
|
442
|
+
await shutdown.shutdown();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
await shutdown.shutdown(1);
|
|
446
|
+
if (error instanceof Error) {
|
|
447
|
+
output(error.message);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async function launchStandaloneSession(paths, dependencies, options) {
|
|
454
|
+
const output = dependencies.output ?? console.error;
|
|
455
|
+
const support = dependencies.support ?? createConfiguredSupportCapability();
|
|
456
|
+
const openBrowser = dependencies.openBrowser ?? createBrowserUrlOpener();
|
|
457
|
+
const revealDraftFile = dependencies.revealDraftFile ??
|
|
458
|
+
(async (canonicalPath) => {
|
|
459
|
+
await open(canonicalPath);
|
|
460
|
+
});
|
|
461
|
+
const createApp = dependencies.createStandaloneSessionApp ??
|
|
462
|
+
((scope, appOptions) => createStandaloneSessionApp(scope, appOptions));
|
|
463
|
+
const activeGit = new AbortController();
|
|
464
|
+
let app;
|
|
465
|
+
let stopRefreshKeypress;
|
|
466
|
+
const shutdown = createShutdownController({
|
|
467
|
+
signalSource: dependencies.signalSource,
|
|
468
|
+
abortActiveWork: () => {
|
|
469
|
+
activeGit.abort();
|
|
470
|
+
},
|
|
471
|
+
closeListener: async () => {
|
|
472
|
+
stopRefreshKeypress?.();
|
|
473
|
+
if (app !== undefined) {
|
|
474
|
+
await app.close();
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
setExitStatus: dependencies.setExitStatus,
|
|
478
|
+
reportError: (error) => {
|
|
479
|
+
console.error(`${RENAME_MAP.displayName} shutdown failed.`, error);
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
try {
|
|
483
|
+
const runner = createGitRunner();
|
|
484
|
+
const repository = await discoverGitRepository(options.cwd, runner, activeGit.signal);
|
|
485
|
+
const objectFormat = await resolveUncommittedObjectFormat(repository.root, runner, activeGit.signal);
|
|
486
|
+
const resolvedPaths = await resolveStandaloneArguments(repository.root, paths, { runner });
|
|
487
|
+
const gitStates = await classifyStandaloneGitState(repository.root, resolvedPaths, { runner });
|
|
488
|
+
const changedFiles = await createStandaloneChangedFileInventory(repository.root, resolvedPaths, gitStates, objectFormat, { runner, signal: activeGit.signal });
|
|
489
|
+
const scope = UncommittedScopeSchema.parse({
|
|
490
|
+
kind: 'uncommitted',
|
|
491
|
+
sourceKind: 'standalone',
|
|
492
|
+
repositoryRoot: repository.root,
|
|
493
|
+
objectFormat,
|
|
494
|
+
standaloneArguments: paths,
|
|
495
|
+
changedFiles,
|
|
496
|
+
});
|
|
497
|
+
if (shutdown.isShuttingDown) {
|
|
498
|
+
await shutdown.shutdown();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const token = randomBytes(32).toString('base64url');
|
|
502
|
+
app = await createApp(scope, {
|
|
503
|
+
webRoot: dependencies.webRoot,
|
|
504
|
+
support,
|
|
505
|
+
sessionToken: token,
|
|
506
|
+
revealDraftFile,
|
|
507
|
+
diagnostics: ({ correlationId, reason }) => {
|
|
508
|
+
console.error(`${RENAME_MAP.displayName} request denied [${correlationId}]: ${reason}.`);
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
await app.listen({ host: '127.0.0.1', port: 0 });
|
|
512
|
+
if (shutdown.isShuttingDown) {
|
|
513
|
+
await shutdown.shutdown();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const address = app.server.address();
|
|
517
|
+
if (address === null ||
|
|
518
|
+
typeof address === 'string' ||
|
|
519
|
+
address.address !== '127.0.0.1' ||
|
|
520
|
+
address.port === 0) {
|
|
521
|
+
throw new Error('Fastify did not bind the required IPv4 loopback address');
|
|
522
|
+
}
|
|
523
|
+
const authority = `127.0.0.1:${address.port}`;
|
|
524
|
+
app.bindSessionSecurity({
|
|
525
|
+
expectedHost: authority,
|
|
526
|
+
expectedOrigin: `http://${authority}`,
|
|
527
|
+
});
|
|
528
|
+
const url = `http://${authority}/#token=${token}`;
|
|
529
|
+
output(url);
|
|
530
|
+
output(browserFallback);
|
|
531
|
+
try {
|
|
532
|
+
await openBrowser(url);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
output('Browser did not open automatically. Open the URL above manually.');
|
|
536
|
+
}
|
|
537
|
+
stopRefreshKeypress = registerUncommittedRefreshKeypress(authority, token, output, shutdown);
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
if (shutdown.isShuttingDown) {
|
|
541
|
+
await shutdown.shutdown();
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
await shutdown.shutdown(1);
|
|
545
|
+
if (error instanceof Error) {
|
|
546
|
+
output(error.message);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function writeStdout(bytes) {
|
|
553
|
+
const { promise, reject, resolve } = Promise.withResolvers();
|
|
554
|
+
process.stdout.write(bytes, (error) => {
|
|
555
|
+
if (error === undefined || error === null) {
|
|
556
|
+
resolve();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
reject(error);
|
|
560
|
+
});
|
|
561
|
+
return promise;
|
|
562
|
+
}
|
|
563
|
+
function waitForAttachedOutcome(coordinator, signalSource) {
|
|
564
|
+
const source = signalSource ?? process;
|
|
565
|
+
const signal = Promise.withResolvers();
|
|
566
|
+
const onSignal = () => {
|
|
567
|
+
signal.resolve(undefined);
|
|
568
|
+
};
|
|
569
|
+
source.on('SIGINT', onSignal);
|
|
570
|
+
source.on('SIGTERM', onSignal);
|
|
571
|
+
return Promise.race([coordinator.delivery, signal.promise]).finally(() => {
|
|
572
|
+
source.off('SIGINT', onSignal);
|
|
573
|
+
source.off('SIGTERM', onSignal);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
async function launchAttachedSession(dependencies, support, activeGit, createApp) {
|
|
577
|
+
const output = dependencies.output ?? console.error;
|
|
578
|
+
const openBrowser = dependencies.openBrowser ?? createBrowserUrlOpener();
|
|
579
|
+
const stdout = dependencies.stdout ?? writeStdout;
|
|
580
|
+
const revealDraftFile = dependencies.revealDraftFile ??
|
|
581
|
+
(async (canonicalPath) => {
|
|
582
|
+
await open(canonicalPath);
|
|
583
|
+
});
|
|
584
|
+
const coordinator = createAttachedCompletionCoordinator();
|
|
585
|
+
let app;
|
|
586
|
+
const shutdown = createShutdownController({
|
|
587
|
+
signalSource: dependencies.signalSource,
|
|
588
|
+
abortActiveWork: () => {
|
|
589
|
+
coordinator.cancel();
|
|
590
|
+
activeGit.abort();
|
|
591
|
+
},
|
|
592
|
+
closeListener: async () => {
|
|
593
|
+
await app?.close();
|
|
594
|
+
},
|
|
595
|
+
setExitStatus: dependencies.setExitStatus,
|
|
596
|
+
reportError: () => {
|
|
597
|
+
output('Attached review shutdown failed.');
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
try {
|
|
601
|
+
const token = randomBytes(32).toString('base64url');
|
|
602
|
+
const storageScope = `agent-${randomBytes(16).toString('hex')}`;
|
|
603
|
+
app = await createApp(token, {
|
|
604
|
+
coordinator,
|
|
605
|
+
storageScope,
|
|
606
|
+
deliver: async (bytes) => await coordinator.runDelivery(async () => await stdout(bytes)),
|
|
607
|
+
}, revealDraftFile);
|
|
608
|
+
await app.listen({ host: '127.0.0.1', port: 0 });
|
|
609
|
+
if (shutdown.isShuttingDown)
|
|
610
|
+
return;
|
|
611
|
+
const address = app.server.address();
|
|
612
|
+
if (address === null
|
|
613
|
+
|| typeof address === 'string'
|
|
614
|
+
|| address.address !== '127.0.0.1'
|
|
615
|
+
|| address.port === 0) {
|
|
616
|
+
throw new Error('Fastify did not bind the required IPv4 loopback address');
|
|
617
|
+
}
|
|
618
|
+
const authority = `127.0.0.1:${address.port}`;
|
|
619
|
+
app.bindSessionSecurity({
|
|
620
|
+
expectedHost: authority,
|
|
621
|
+
expectedOrigin: `http://${authority}`,
|
|
622
|
+
});
|
|
623
|
+
const url = `http://${authority}/#token=${token}`;
|
|
624
|
+
output(url);
|
|
625
|
+
output(browserFallback);
|
|
626
|
+
await openBrowser(url);
|
|
627
|
+
const result = await waitForAttachedOutcome(coordinator, dependencies.signalSource);
|
|
628
|
+
if (result === undefined || result.kind !== 'completed') {
|
|
629
|
+
if (result !== undefined)
|
|
630
|
+
output('Attached review result could not be delivered.');
|
|
631
|
+
await shutdown.shutdown(1);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
await coordinator.responseSettled;
|
|
635
|
+
await shutdown.shutdown();
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
output('Attached review could not be completed.');
|
|
639
|
+
await shutdown.shutdown(1);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
async function launchExactPatchSession(grounded, dependencies, support, activeGit) {
|
|
643
|
+
await launchAttachedSession(dependencies, support, activeGit, async (sessionToken, attachedCompletion, revealDraftFile) => {
|
|
644
|
+
return await (dependencies.createExactPatchSessionApp ?? createExactPatchSessionApp)(grounded, {
|
|
645
|
+
webRoot: dependencies.webRoot,
|
|
646
|
+
sessionToken,
|
|
647
|
+
revealDraftFile,
|
|
648
|
+
support,
|
|
649
|
+
attachedCompletion,
|
|
650
|
+
diagnostics: () => {
|
|
651
|
+
(dependencies.output ?? console.error)('Exact patch review request denied.');
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
function mapStandaloneResolutionError(error) {
|
|
657
|
+
if (error instanceof StandaloneResolutionError) {
|
|
658
|
+
throw new AgentRequestError(error.kind);
|
|
659
|
+
}
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
function mapGitPathspecError(error) {
|
|
663
|
+
if (error instanceof GitRunnerError && error.kind === 'exit' && error.exitCode === 128) {
|
|
664
|
+
throw new AgentRequestError('path-outside-repository');
|
|
665
|
+
}
|
|
666
|
+
throw error;
|
|
667
|
+
}
|
|
668
|
+
async function launchUncommittedAgentSession(scope, requestPathspecs, dependencies, support, activeGit) {
|
|
669
|
+
await launchAttachedSession(dependencies, support, activeGit, async (sessionToken, attachedCompletion, revealDraftFile) => {
|
|
670
|
+
return await (dependencies.createUncommittedSessionApp ?? createUncommittedSessionApp)(scope, {
|
|
671
|
+
webRoot: dependencies.webRoot,
|
|
672
|
+
sessionToken,
|
|
673
|
+
revealDraftFile,
|
|
674
|
+
support,
|
|
675
|
+
attachedCompletion,
|
|
676
|
+
requestPathspecs,
|
|
677
|
+
diagnostics: () => {
|
|
678
|
+
(dependencies.output ?? console.error)('Uncommitted review request denied.');
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
async function launchStandaloneAgentSession(scope, dependencies, support, activeGit) {
|
|
684
|
+
await launchAttachedSession(dependencies, support, activeGit, async (sessionToken, attachedCompletion, revealDraftFile) => {
|
|
685
|
+
return await (dependencies.createStandaloneSessionApp ?? createStandaloneSessionApp)(scope, {
|
|
686
|
+
webRoot: dependencies.webRoot,
|
|
687
|
+
sessionToken,
|
|
688
|
+
revealDraftFile,
|
|
689
|
+
support,
|
|
690
|
+
attachedCompletion,
|
|
691
|
+
diagnostics: () => {
|
|
692
|
+
(dependencies.output ?? console.error)('Standalone review request denied.');
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
export async function runOrdinaryAction(options, dependencies = {}) {
|
|
698
|
+
if (dependencies.isTTY ?? process.stdin.isTTY === true) {
|
|
699
|
+
await (dependencies.runCli ?? runCli)(options);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const readRequest = dependencies.readRequest ?? readAgentReviewRequest;
|
|
703
|
+
const createRangeComparison = dependencies.createRangeComparison ?? createPinnedRangeComparison;
|
|
704
|
+
const output = dependencies.output ?? console.error;
|
|
705
|
+
const setExitStatus = dependencies.setExitStatus ??
|
|
706
|
+
((status) => {
|
|
707
|
+
process.exitCode = status;
|
|
708
|
+
});
|
|
709
|
+
const activeGit = new AbortController();
|
|
710
|
+
try {
|
|
711
|
+
const request = await readRequest(dependencies.input ?? process.stdin);
|
|
712
|
+
const support = dependencies.support ?? createConfiguredSupportCapability();
|
|
713
|
+
if (request.mode === 'patch') {
|
|
714
|
+
const grounded = await (dependencies.createGroundedExactPatch ?? createGroundedExactPatch)({
|
|
715
|
+
cwd: options.cwd,
|
|
716
|
+
patchContent: request.patch.content,
|
|
717
|
+
target: request.patch.target,
|
|
718
|
+
signal: activeGit.signal,
|
|
719
|
+
});
|
|
720
|
+
await launchExactPatchSession(grounded, dependencies, support, activeGit);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (request.mode === 'uncommitted') {
|
|
724
|
+
const runner = createGitRunner();
|
|
725
|
+
const repository = await discoverGitRepository(options.cwd, runner, activeGit.signal);
|
|
726
|
+
const objectFormat = await resolveUncommittedObjectFormat(repository.root, runner, activeGit.signal);
|
|
727
|
+
const baseCommitOid = request.uncommitted.source === 'unstaged'
|
|
728
|
+
? undefined
|
|
729
|
+
: await resolveUncommittedBaseCommitOid(request.uncommitted.base, repository, runner, activeGit.signal);
|
|
730
|
+
const mergeBaseOid = baseCommitOid === undefined
|
|
731
|
+
? undefined
|
|
732
|
+
: await resolveUncommittedMergeBaseOid(baseCommitOid, repository.headOid, repository.root, runner, activeGit.signal);
|
|
733
|
+
const changedFiles = await createWorktreeChangedFileInventory({
|
|
734
|
+
sourceKind: request.uncommitted.source,
|
|
735
|
+
repositoryRoot: repository.root,
|
|
736
|
+
objectFormat,
|
|
737
|
+
signal: activeGit.signal,
|
|
738
|
+
pathspecs: request.uncommitted.pathspecs,
|
|
739
|
+
...(mergeBaseOid === undefined ? {} : { mergeBaseOid }),
|
|
740
|
+
}, { runner }).catch(mapGitPathspecError);
|
|
741
|
+
const scope = UncommittedScopeSchema.parse({
|
|
742
|
+
kind: 'uncommitted',
|
|
743
|
+
sourceKind: request.uncommitted.source,
|
|
744
|
+
repositoryRoot: repository.root,
|
|
745
|
+
objectFormat,
|
|
746
|
+
changedFiles,
|
|
747
|
+
...(baseCommitOid === undefined ? {} : { baseCommitOid }),
|
|
748
|
+
...(mergeBaseOid === undefined ? {} : { mergeBaseOid }),
|
|
749
|
+
});
|
|
750
|
+
await launchUncommittedAgentSession(scope, request.uncommitted.pathspecs, dependencies, support, activeGit);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (request.mode === 'files') {
|
|
754
|
+
const runner = createGitRunner();
|
|
755
|
+
const repository = await discoverGitRepository(options.cwd, runner, activeGit.signal);
|
|
756
|
+
const objectFormat = await resolveUncommittedObjectFormat(repository.root, runner, activeGit.signal);
|
|
757
|
+
const resolvedPaths = await resolveStandaloneArguments(repository.root, request.files.paths, { runner }).catch(mapStandaloneResolutionError);
|
|
758
|
+
const gitStates = await classifyStandaloneGitState(repository.root, resolvedPaths, { runner });
|
|
759
|
+
const changedFiles = await createStandaloneChangedFileInventory(repository.root, resolvedPaths, gitStates, objectFormat, { runner, signal: activeGit.signal });
|
|
760
|
+
const scope = UncommittedScopeSchema.parse({
|
|
761
|
+
kind: 'uncommitted',
|
|
762
|
+
sourceKind: 'standalone',
|
|
763
|
+
repositoryRoot: repository.root,
|
|
764
|
+
objectFormat,
|
|
765
|
+
standaloneArguments: request.files.paths,
|
|
766
|
+
changedFiles,
|
|
767
|
+
});
|
|
768
|
+
await launchStandaloneAgentSession(scope, dependencies, support, activeGit);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const comparison = await createRangeComparison({
|
|
772
|
+
cwd: options.cwd,
|
|
773
|
+
baseRevision: request.revisions.base,
|
|
774
|
+
headRevision: request.revisions.head,
|
|
775
|
+
pathspecs: request.revisions.pathspecs,
|
|
776
|
+
});
|
|
777
|
+
await launchAttachedSession(dependencies, support, activeGit, async (sessionToken, attachedCompletion, revealDraftFile) => {
|
|
778
|
+
return (dependencies.createSessionApp ?? createSessionApp)(comparison, {
|
|
779
|
+
webRoot: dependencies.webRoot,
|
|
780
|
+
sessionToken,
|
|
781
|
+
revealDraftFile,
|
|
782
|
+
attachedCompletion,
|
|
783
|
+
support,
|
|
784
|
+
diagnostics: ({ correlationId, reason }) => {
|
|
785
|
+
output(`${RENAME_MAP.displayName} request denied [${correlationId}]: ${reason}.`);
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
catch (error) {
|
|
791
|
+
if (error instanceof AgentRequestError
|
|
792
|
+
|| error instanceof ExactPatchGroundingError
|
|
793
|
+
|| error instanceof PatchSnapshotError
|
|
794
|
+
|| (isLaunchError(error) && error.recovery.kind === 'exit')) {
|
|
795
|
+
output(error instanceof ExactPatchGroundingError || error instanceof PatchSnapshotError
|
|
796
|
+
? 'Exact patch review could not be prepared.'
|
|
797
|
+
: error.message);
|
|
798
|
+
setExitStatus(1);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
throw error;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
export async function runCli(options, dependencies = {}) {
|
|
805
|
+
const discoverCandidates = dependencies.discoverCandidates ?? discoverSourceCandidates;
|
|
806
|
+
const pickSources = dependencies.pickSources ?? ((pickOptions) => pickSource(pickOptions));
|
|
807
|
+
const createDescriptor = dependencies.createDescriptor ?? createComparisonLaunchDescriptor;
|
|
808
|
+
const confirmComparison = dependencies.confirmComparison ?? confirmPinnedComparison;
|
|
809
|
+
const launchComparison = dependencies.launchComparison ?? launchPinnedComparison;
|
|
810
|
+
const output = dependencies.output ?? console.error;
|
|
811
|
+
const setExitStatus = dependencies.setExitStatus ??
|
|
812
|
+
((status) => {
|
|
813
|
+
process.exitCode = status;
|
|
814
|
+
});
|
|
815
|
+
let discovery;
|
|
816
|
+
try {
|
|
817
|
+
discovery = await discoverCandidates(options);
|
|
818
|
+
}
|
|
819
|
+
catch (error) {
|
|
820
|
+
if (!isLaunchError(error) || error.recovery.kind !== 'exit') {
|
|
821
|
+
throw error;
|
|
822
|
+
}
|
|
823
|
+
reportFatalLaunchError(error, output, setExitStatus);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
let candidates = discovery.initialCandidates;
|
|
827
|
+
let searchBranches = discovery.searchBranches;
|
|
828
|
+
let initialBase;
|
|
829
|
+
let initialHead;
|
|
830
|
+
let recovery;
|
|
831
|
+
while (true) {
|
|
832
|
+
const suggestedHead = candidates.find((candidate) => candidate.kind === 'worktree' &&
|
|
833
|
+
candidate.isCurrentCheckout &&
|
|
834
|
+
candidate.availability !== 'unavailable');
|
|
835
|
+
const picked = await pickSources({
|
|
836
|
+
candidates,
|
|
837
|
+
searchBranches,
|
|
838
|
+
candidateEnrichment: discovery.candidateEnrichment,
|
|
839
|
+
startCandidateEnrichment: discovery.startCandidateEnrichment,
|
|
840
|
+
...(suggestedHead === undefined
|
|
841
|
+
? {}
|
|
842
|
+
: {
|
|
843
|
+
suggestedHeadId: suggestedHead.id,
|
|
844
|
+
preselectedBaseId: suggestedHead.id,
|
|
845
|
+
}),
|
|
846
|
+
...(initialBase === undefined ? {} : { initialBase }),
|
|
847
|
+
...(initialHead === undefined ? {} : { initialHead }),
|
|
848
|
+
...(recovery === undefined ? {} : { recovery }),
|
|
849
|
+
});
|
|
850
|
+
if (picked.kind === 'uncommitted') {
|
|
851
|
+
await launchUncommittedSession({
|
|
852
|
+
sourceKind: picked.sourceKind,
|
|
853
|
+
...(picked.base === undefined
|
|
854
|
+
? {}
|
|
855
|
+
: { baseRef: selectionForCandidate(picked.base).revision }),
|
|
856
|
+
}, dependencies, options);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const selected = picked;
|
|
860
|
+
let comparison;
|
|
861
|
+
try {
|
|
862
|
+
comparison = await createDescriptor({
|
|
863
|
+
cwd: options.cwd,
|
|
864
|
+
base: selectionForCandidate(selected.base),
|
|
865
|
+
head: selectionForCandidate(selected.head),
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
catch (error) {
|
|
869
|
+
if (!isLaunchError(error)) {
|
|
870
|
+
throw error;
|
|
871
|
+
}
|
|
872
|
+
output(error.message);
|
|
873
|
+
if (error.recovery.kind === 'exit') {
|
|
874
|
+
setExitStatus(1);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
try {
|
|
878
|
+
discovery = await discoverCandidates(options);
|
|
879
|
+
}
|
|
880
|
+
catch (discoveryError) {
|
|
881
|
+
if (!isLaunchError(discoveryError) ||
|
|
882
|
+
discoveryError.recovery.kind !== 'exit') {
|
|
883
|
+
throw discoveryError;
|
|
884
|
+
}
|
|
885
|
+
reportFatalLaunchError(discoveryError, output, setExitStatus);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const failedCandidate = selected[error.recovery.role];
|
|
889
|
+
const eagerCandidate = discovery.initialCandidates.find((candidate) => candidate.id === failedCandidate.id);
|
|
890
|
+
let recoveredCandidate = eagerCandidate;
|
|
891
|
+
if (recoveredCandidate === undefined && failedCandidate.kind === 'branch') {
|
|
892
|
+
const branches = await discovery.searchBranches(failedCandidate.label);
|
|
893
|
+
recoveredCandidate = branches.find((candidate) => candidate.id === failedCandidate.id);
|
|
894
|
+
}
|
|
895
|
+
candidates =
|
|
896
|
+
recoveredCandidate === undefined || eagerCandidate !== undefined
|
|
897
|
+
? discovery.initialCandidates
|
|
898
|
+
: Object.freeze([...discovery.initialCandidates, recoveredCandidate]);
|
|
899
|
+
searchBranches = discovery.searchBranches;
|
|
900
|
+
initialBase =
|
|
901
|
+
error.recovery.preserve === 'base' ? selected.base : undefined;
|
|
902
|
+
initialHead =
|
|
903
|
+
error.recovery.preserve === 'head' ? selected.head : undefined;
|
|
904
|
+
recovery = {
|
|
905
|
+
role: error.recovery.role,
|
|
906
|
+
focusedCandidateId: recoveredCandidate?.id,
|
|
907
|
+
searchTerm: failedCandidate.kind === 'branch' ? failedCandidate.label : '',
|
|
908
|
+
};
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
const action = await confirmComparison(comparison);
|
|
912
|
+
if (action === 'back') {
|
|
913
|
+
initialBase = selected.base;
|
|
914
|
+
initialHead = undefined;
|
|
915
|
+
recovery = undefined;
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
await launchComparison(comparison);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
export async function run(options) {
|
|
923
|
+
if (options !== undefined) {
|
|
924
|
+
return await createComparisonLaunchDescriptor(options);
|
|
925
|
+
}
|
|
926
|
+
await new Command()
|
|
927
|
+
.name(RENAME_MAP.binCommand)
|
|
928
|
+
.description('Local-first review for any agent-produced artifact: code, uncommitted work, or standalone files')
|
|
929
|
+
.version(await readPackageVersionFromManifest())
|
|
930
|
+
.option('--staged', 'Review staged changes (HEAD→index) against a base')
|
|
931
|
+
.option('--unstaged', 'Review unstaged changes (index→working tree)')
|
|
932
|
+
.option('--worktree', 'Review all working-tree changes (HEAD→working tree), including untracked files')
|
|
933
|
+
.option('--base <ref>', 'Base ref or registered worktree to compare against (default: HEAD); rejected with --unstaged')
|
|
934
|
+
.argument('[paths...]', 'Files or globs to review directly, with no Base/Head selection')
|
|
935
|
+
.action(async (paths, cliOptions) => {
|
|
936
|
+
if (paths.length > 0) {
|
|
937
|
+
try {
|
|
938
|
+
validateStandaloneExclusivity(paths, cliOptions);
|
|
939
|
+
}
|
|
940
|
+
catch (error) {
|
|
941
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
942
|
+
process.exitCode = 1;
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
await launchStandaloneSession(paths, {}, { cwd: process.cwd() });
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
validateUncommittedFlags(cliOptions);
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
953
|
+
process.exitCode = 1;
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const uncommittedSourceKind = resolveUncommittedFlagSelection(cliOptions);
|
|
957
|
+
if (uncommittedSourceKind !== undefined) {
|
|
958
|
+
await launchUncommittedSession({ sourceKind: uncommittedSourceKind, baseRef: cliOptions.base }, {}, { cwd: process.cwd() });
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const serializedLaunchOptions = process.env[`${RENAME_MAP.envPrefix}LAUNCH_OPTIONS`];
|
|
962
|
+
if (serializedLaunchOptions === undefined) {
|
|
963
|
+
await runOrdinaryAction({ cwd: process.cwd() });
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const launchOptions = packagedLaunchOptionsSchema.parse(JSON.parse(serializedLaunchOptions));
|
|
967
|
+
await launchPinnedSession(launchOptions);
|
|
968
|
+
})
|
|
969
|
+
.parseAsync(process.argv);
|
|
970
|
+
}
|