@spotpatch/agent 1.1.0 → 1.2.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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/engine/execute-agent-change.ts
2
2
  import {
3
- ERROR_CODES as ERROR_CODES18,
4
- SpotPatchError as SpotPatchError18
3
+ ERROR_CODES as ERROR_CODES19,
4
+ SpotPatchError as SpotPatchError19
5
5
  } from "@spotpatch/shared";
6
6
 
7
7
  // src/provider/openai-compatible-provider.ts
@@ -716,7 +716,7 @@ var PROTECTED_FILE_NAMES = /* @__PURE__ */ new Set([
716
716
  "yarn.lock"
717
717
  ]);
718
718
  function deny() {
719
- throw new SpotPatchError8(ERROR_CODES8.TOOL_DENIED);
719
+ throw new SpotPatchError8(ERROR_CODES8.TOOL_PATH_DENIED);
720
720
  }
721
721
  function hasControlCharacter(value) {
722
722
  for (let index = 0; index < value.length; index += 1) {
@@ -821,13 +821,13 @@ async function readAgentTextFile(root, relativePath, maximumBytes) {
821
821
  }
822
822
  const bytes = await readFile(absolutePath);
823
823
  if (bytes.includes(0)) {
824
- throw new SpotPatchError9(ERROR_CODES9.TOOL_DENIED);
824
+ throw new SpotPatchError9(ERROR_CODES9.TOOL_PATH_DENIED);
825
825
  }
826
826
  let content;
827
827
  try {
828
828
  content = utf8Decoder.decode(bytes);
829
829
  } catch {
830
- throw new SpotPatchError9(ERROR_CODES9.TOOL_DENIED);
830
+ throw new SpotPatchError9(ERROR_CODES9.TOOL_PATH_DENIED);
831
831
  }
832
832
  return Object.freeze({ content, relativePath, size: metadata.size });
833
833
  }
@@ -840,7 +840,7 @@ function encodeUtf8Text(content, includeByteOrderMark) {
840
840
  }
841
841
  async function writeAgentTextFileIfContentMatches(root, relativePath, expectedContent, nextContent, maximumBytes) {
842
842
  if (nextContent.includes("\0")) {
843
- throw new SpotPatchError9(ERROR_CODES9.TOOL_DENIED);
843
+ throw new SpotPatchError9(ERROR_CODES9.TOOL_INPUT_INVALID);
844
844
  }
845
845
  const absolutePath = await resolveExistingAgentPath(root, relativePath);
846
846
  const [metadata, currentBytes] = await Promise.all([
@@ -854,7 +854,7 @@ async function writeAgentTextFileIfContentMatches(root, relativePath, expectedCo
854
854
  try {
855
855
  currentContent = utf8Decoder.decode(currentBytes);
856
856
  } catch {
857
- throw new SpotPatchError9(ERROR_CODES9.TOOL_DENIED);
857
+ throw new SpotPatchError9(ERROR_CODES9.TOOL_PATH_DENIED);
858
858
  }
859
859
  if (currentBytes.includes(0) || currentContent !== expectedContent) {
860
860
  throw new SpotPatchError9(ERROR_CODES9.PATCH_REJECTED);
@@ -1113,7 +1113,7 @@ async function runConfiguredCheck(options) {
1113
1113
  function requireConfiguredCheck(checkId, checks) {
1114
1114
  const check = checks[checkId];
1115
1115
  if (check === void 0) {
1116
- throw new SpotPatchError10(ERROR_CODES10.TOOL_DENIED);
1116
+ throw new SpotPatchError10(ERROR_CODES10.TOOL_INPUT_INVALID);
1117
1117
  }
1118
1118
  return check;
1119
1119
  }
@@ -1308,9 +1308,16 @@ async function applyAgentPatch(worktreeRoot, patch, limits, signal) {
1308
1308
  throw new SpotPatchError13(ERROR_CODES13.AGENT_LIMIT_EXCEEDED);
1309
1309
  }
1310
1310
  await Promise.all(
1311
- files.map(
1312
- async (file) => resolveWritableAgentPath(worktreeRoot, file.relativePath)
1313
- )
1311
+ files.map(async (file) => {
1312
+ await resolveWritableAgentPath(worktreeRoot, file.relativePath);
1313
+ if (file.kind !== "added") {
1314
+ await readAgentTextFile(
1315
+ worktreeRoot,
1316
+ file.relativePath,
1317
+ limits.maxReadBytesPerFile
1318
+ );
1319
+ }
1320
+ })
1314
1321
  );
1315
1322
  await runGitCommand({
1316
1323
  cwd: worktreeRoot,
@@ -1424,7 +1431,7 @@ var MAX_DISCOVERED_FILES = 2e4;
1424
1431
  var TEXT_SAMPLE_BYTES = 8192;
1425
1432
  function compileGlob(glob) {
1426
1433
  if (glob.length === 0 || glob.length > 256 || glob.includes("\0") || glob.includes("\\") || glob.startsWith("/") || ["[", "]", "{", "}", "(", ")", "!"].some((character) => glob.includes(character)) || glob.split("/").some((segment) => segment === "..")) {
1427
- throw new SpotPatchError14(ERROR_CODES14.TOOL_DENIED);
1434
+ throw new SpotPatchError14(ERROR_CODES14.TOOL_INPUT_INVALID);
1428
1435
  }
1429
1436
  let expression = "^";
1430
1437
  for (let index = 0; index < glob.length; index += 1) {
@@ -1663,7 +1670,7 @@ var runCheckSchema = z.strictObject({
1663
1670
  checkId: z.string().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u)
1664
1671
  });
1665
1672
  function invalidTool() {
1666
- throw new SpotPatchError15(ERROR_CODES15.TOOL_DENIED);
1673
+ throw new SpotPatchError15(ERROR_CODES15.TOOL_INPUT_INVALID);
1667
1674
  }
1668
1675
  function parseArguments(schema, value) {
1669
1676
  const parsed = schema.safeParse(value);
@@ -1960,67 +1967,354 @@ function createAgentToolExecutor(options) {
1960
1967
  }
1961
1968
 
1962
1969
  // src/worktree/git-worktree.ts
1963
- import { lstat as lstat3, mkdtemp, realpath as realpath2, rm as rm2 } from "fs/promises";
1970
+ import {
1971
+ copyFile,
1972
+ lstat as lstat4,
1973
+ mkdir,
1974
+ mkdtemp,
1975
+ readFile as readFile2,
1976
+ realpath as realpath3,
1977
+ rm as rm2
1978
+ } from "fs/promises";
1979
+ import { createHash as createHash2 } from "crypto";
1964
1980
  import os from "os";
1981
+ import path6 from "path";
1982
+ import {
1983
+ ERROR_CODES as ERROR_CODES17,
1984
+ SpotPatchError as SpotPatchError17
1985
+ } from "@spotpatch/shared";
1986
+
1987
+ // src/worktree/workspace-health.ts
1988
+ import { lstat as lstat3, realpath as realpath2 } from "fs/promises";
1965
1989
  import path5 from "path";
1966
- import { ERROR_CODES as ERROR_CODES16, SpotPatchError as SpotPatchError16 } from "@spotpatch/shared";
1967
- async function assertCleanGitBaseline(options) {
1968
- const root = await realpath2(options.root).catch(() => {
1969
- throw new SpotPatchError16(ERROR_CODES16.WORKTREE_DIRTY);
1990
+ import {
1991
+ AGENT_WORKSPACE_SNAPSHOT_LIMITS,
1992
+ ERROR_CODES as ERROR_CODES16,
1993
+ SpotPatchError as SpotPatchError16
1994
+ } from "@spotpatch/shared";
1995
+ var CONFLICTED_STATUSES = /* @__PURE__ */ new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
1996
+ var OPERATION_MARKERS = Object.freeze([
1997
+ "MERGE_HEAD",
1998
+ "CHERRY_PICK_HEAD",
1999
+ "REVERT_HEAD",
2000
+ "rebase-apply",
2001
+ "rebase-merge"
2002
+ ]);
2003
+ function emptyChanges() {
2004
+ return Object.freeze({
2005
+ staged: 0,
2006
+ unstaged: 0,
2007
+ untracked: 0,
2008
+ conflicted: 0,
2009
+ total: 0
2010
+ });
2011
+ }
2012
+ function blockedHealth(errorCode) {
2013
+ return Object.freeze({
2014
+ state: "blocked",
2015
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
2016
+ changes: emptyChanges(),
2017
+ canIncludeLocalChanges: false,
2018
+ errorCode
1970
2019
  });
1971
- const topLevel = (await runGitCommand({
2020
+ }
2021
+ function parsePorcelainStatus(value) {
2022
+ const records = value.split("\0");
2023
+ const untrackedPaths = [];
2024
+ let staged = 0;
2025
+ let unstaged = 0;
2026
+ let conflicted = 0;
2027
+ let total = 0;
2028
+ for (let index = 0; index < records.length; index += 1) {
2029
+ const record = records[index] ?? "";
2030
+ if (record.length === 0) {
2031
+ continue;
2032
+ }
2033
+ if (record.length < 4 || record[2] !== " ") {
2034
+ throw new SpotPatchError16(ERROR_CODES16.WORKTREE_LOCAL_CHANGES_UNSUPPORTED);
2035
+ }
2036
+ const status = record.slice(0, 2);
2037
+ const relativePath = record.slice(3);
2038
+ if (relativePath.length === 0) {
2039
+ throw new SpotPatchError16(ERROR_CODES16.WORKTREE_LOCAL_CHANGES_UNSUPPORTED);
2040
+ }
2041
+ total += 1;
2042
+ if (status === "??") {
2043
+ untrackedPaths.push(relativePath);
2044
+ continue;
2045
+ }
2046
+ const indexStatus = status[0] ?? " ";
2047
+ const worktreeStatus = status[1] ?? " ";
2048
+ if (CONFLICTED_STATUSES.has(status) || indexStatus === "U" || worktreeStatus === "U") {
2049
+ conflicted += 1;
2050
+ }
2051
+ if (indexStatus !== " ") {
2052
+ staged += 1;
2053
+ }
2054
+ if (worktreeStatus !== " ") {
2055
+ unstaged += 1;
2056
+ }
2057
+ if (indexStatus === "R" || indexStatus === "C") {
2058
+ index += 1;
2059
+ if ((records[index] ?? "").length === 0) {
2060
+ throw new SpotPatchError16(ERROR_CODES16.WORKTREE_LOCAL_CHANGES_UNSUPPORTED);
2061
+ }
2062
+ }
2063
+ }
2064
+ return Object.freeze({
2065
+ changes: Object.freeze({
2066
+ staged,
2067
+ unstaged,
2068
+ untracked: untrackedPaths.length,
2069
+ conflicted,
2070
+ total
2071
+ }),
2072
+ untrackedPaths: Object.freeze(untrackedPaths)
2073
+ });
2074
+ }
2075
+ async function operationInProgress(root, signal) {
2076
+ for (const marker of OPERATION_MARKERS) {
2077
+ const markerPath = (await runGitCommand({
2078
+ cwd: root,
2079
+ args: ["rev-parse", "--git-path", marker],
2080
+ errorCode: ERROR_CODES16.WORKTREE_NOT_REPOSITORY,
2081
+ ...signal === void 0 ? {} : { signal }
2082
+ })).trim();
2083
+ if (await lstat3(path5.resolve(root, markerPath)).catch(() => void 0) !== void 0) {
2084
+ return true;
2085
+ }
2086
+ }
2087
+ return false;
2088
+ }
2089
+ async function inspectUntrackedFiles(root, relativePaths) {
2090
+ if (relativePaths.length > AGENT_WORKSPACE_SNAPSHOT_LIMITS.maxUntrackedFiles) {
2091
+ return ERROR_CODES16.WORKTREE_LOCAL_CHANGES_TOO_LARGE;
2092
+ }
2093
+ let totalBytes = 0;
2094
+ for (const relativePath of relativePaths) {
2095
+ const absolutePath = path5.resolve(root, relativePath);
2096
+ const relative = path5.relative(root, absolutePath);
2097
+ if (relative === ".." || relative.startsWith(`..${path5.sep}`) || path5.isAbsolute(relative)) {
2098
+ return ERROR_CODES16.WORKTREE_UNTRACKED_UNSUPPORTED;
2099
+ }
2100
+ const metadata = await lstat3(absolutePath).catch(() => void 0);
2101
+ if (metadata === void 0 || !metadata.isFile() || metadata.isSymbolicLink()) {
2102
+ return ERROR_CODES16.WORKTREE_UNTRACKED_UNSUPPORTED;
2103
+ }
2104
+ totalBytes += metadata.size;
2105
+ if (totalBytes > AGENT_WORKSPACE_SNAPSHOT_LIMITS.maxUntrackedBytes) {
2106
+ return ERROR_CODES16.WORKTREE_LOCAL_CHANGES_TOO_LARGE;
2107
+ }
2108
+ }
2109
+ return void 0;
2110
+ }
2111
+ async function inspectGitWorkspace(rootValue, signal) {
2112
+ const root = await realpath2(rootValue).catch(() => {
2113
+ throw new SpotPatchError16(ERROR_CODES16.WORKTREE_NOT_REPOSITORY);
2114
+ });
2115
+ const topLevelResult = await runRawGitCommand({
1972
2116
  cwd: root,
1973
2117
  args: ["rev-parse", "--show-toplevel"],
1974
- errorCode: ERROR_CODES16.WORKTREE_DIRTY,
1975
- ...options.signal === void 0 ? {} : { signal: options.signal }
1976
- })).trim();
2118
+ ...signal === void 0 ? {} : { signal }
2119
+ });
2120
+ if (topLevelResult.exitCode !== 0 || topLevelResult.cancelled || topLevelResult.timedOut) {
2121
+ throw new SpotPatchError16(ERROR_CODES16.WORKTREE_NOT_REPOSITORY);
2122
+ }
2123
+ const topLevel = topLevelResult.stdout.trim();
1977
2124
  if (!samePath(root, topLevel)) {
1978
- throw new SpotPatchError16(ERROR_CODES16.WORKTREE_DIRTY);
2125
+ throw new SpotPatchError16(ERROR_CODES16.WORKTREE_NOT_REPOSITORY);
1979
2126
  }
1980
2127
  const head = (await runGitCommand({
1981
2128
  cwd: root,
1982
2129
  args: ["rev-parse", "--verify", "HEAD"],
1983
- errorCode: ERROR_CODES16.WORKTREE_DIRTY,
1984
- ...options.signal === void 0 ? {} : { signal: options.signal }
2130
+ errorCode: ERROR_CODES16.WORKTREE_NOT_REPOSITORY,
2131
+ ...signal === void 0 ? {} : { signal }
1985
2132
  })).trim();
1986
- if (options.expectedHead !== void 0 && head !== options.expectedHead) {
1987
- throw new SpotPatchError16(ERROR_CODES16.APPLY_CONFLICT);
2133
+ const parsed = parsePorcelainStatus(
2134
+ await runGitCommand({
2135
+ cwd: root,
2136
+ args: ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
2137
+ errorCode: ERROR_CODES16.WORKTREE_NOT_REPOSITORY,
2138
+ ...signal === void 0 ? {} : { signal }
2139
+ })
2140
+ );
2141
+ const operationActive = await operationInProgress(root, signal);
2142
+ const untrackedError = await inspectUntrackedFiles(root, parsed.untrackedPaths);
2143
+ const errorCode = operationActive ? ERROR_CODES16.WORKTREE_OPERATION_IN_PROGRESS : parsed.changes.conflicted > 0 ? ERROR_CODES16.WORKTREE_CONFLICTED : untrackedError;
2144
+ const health = Object.freeze({
2145
+ state: errorCode !== void 0 ? "blocked" : parsed.changes.total === 0 ? "ready" : "consent-required",
2146
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
2147
+ changes: parsed.changes,
2148
+ canIncludeLocalChanges: errorCode === void 0 && parsed.changes.total > 0,
2149
+ ...errorCode === void 0 ? {} : { errorCode }
2150
+ });
2151
+ return Object.freeze({
2152
+ root,
2153
+ head,
2154
+ health,
2155
+ untrackedPaths: parsed.untrackedPaths
2156
+ });
2157
+ }
2158
+ async function inspectAgentWorkspace(root, signal) {
2159
+ try {
2160
+ return (await inspectGitWorkspace(root, signal)).health;
2161
+ } catch (error) {
2162
+ if (error instanceof SpotPatchError16) {
2163
+ return blockedHealth(error.code);
2164
+ }
2165
+ return blockedHealth(ERROR_CODES16.INTERNAL_ERROR);
1988
2166
  }
1989
- const status = await runGitCommand({
1990
- cwd: root,
1991
- args: ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
1992
- errorCode: ERROR_CODES16.WORKTREE_DIRTY,
1993
- ...options.signal === void 0 ? {} : { signal: options.signal }
2167
+ }
2168
+
2169
+ // src/worktree/git-worktree.ts
2170
+ function workspacePath(root, relativePath) {
2171
+ const candidate = path6.resolve(root, relativePath);
2172
+ const relative = path6.relative(root, candidate);
2173
+ if (relative === ".." || relative.startsWith(`..${path6.sep}`) || path6.isAbsolute(relative)) {
2174
+ throw new SpotPatchError17(ERROR_CODES17.WORKTREE_LOCAL_CHANGES_UNSUPPORTED);
2175
+ }
2176
+ return candidate;
2177
+ }
2178
+ async function fileDigest(filePath) {
2179
+ return createHash2("sha256").update(await readFile2(filePath)).digest("hex");
2180
+ }
2181
+ async function copyUntrackedFiles(sourceRoot, worktreeRoot, relativePaths) {
2182
+ for (const relativePath of relativePaths) {
2183
+ const sourcePath = workspacePath(sourceRoot, relativePath);
2184
+ const targetPath = workspacePath(worktreeRoot, relativePath);
2185
+ const metadata = await lstat4(sourcePath).catch(() => void 0);
2186
+ if (metadata === void 0 || !metadata.isFile() || metadata.isSymbolicLink()) {
2187
+ throw new SpotPatchError17(ERROR_CODES17.WORKTREE_LOCAL_CHANGES_UNSUPPORTED);
2188
+ }
2189
+ await mkdir(path6.dirname(targetPath), { recursive: true });
2190
+ await copyFile(sourcePath, targetPath);
2191
+ const [sourceDigest, targetDigest] = await Promise.all([
2192
+ fileDigest(sourcePath),
2193
+ fileDigest(targetPath)
2194
+ ]).catch(() => {
2195
+ throw new SpotPatchError17(ERROR_CODES17.APPLY_CONFLICT);
2196
+ });
2197
+ if (sourceDigest !== targetDigest) {
2198
+ throw new SpotPatchError17(ERROR_CODES17.APPLY_CONFLICT);
2199
+ }
2200
+ }
2201
+ }
2202
+ async function assertUntrackedFilesUnchanged(sourceRoot, worktreeRoot, relativePaths) {
2203
+ for (const relativePath of relativePaths) {
2204
+ const sourcePath = workspacePath(sourceRoot, relativePath);
2205
+ const targetPath = workspacePath(worktreeRoot, relativePath);
2206
+ const [sourceDigest, targetDigest] = await Promise.all([
2207
+ fileDigest(sourcePath),
2208
+ fileDigest(targetPath)
2209
+ ]).catch(() => {
2210
+ throw new SpotPatchError17(ERROR_CODES17.APPLY_CONFLICT);
2211
+ });
2212
+ if (sourceDigest !== targetDigest) {
2213
+ throw new SpotPatchError17(ERROR_CODES17.APPLY_CONFLICT);
2214
+ }
2215
+ }
2216
+ }
2217
+ async function materializeLocalBaseline(sourceRoot, worktreeRoot, expectedHead, untrackedPaths, signal) {
2218
+ const sourceDiff = await runGitCommand({
2219
+ cwd: sourceRoot,
2220
+ args: [
2221
+ "diff",
2222
+ "--binary",
2223
+ "--full-index",
2224
+ "--no-ext-diff",
2225
+ "--no-color",
2226
+ "--no-renames",
2227
+ "HEAD",
2228
+ "--"
2229
+ ],
2230
+ signal,
2231
+ errorCode: ERROR_CODES17.WORKTREE_LOCAL_CHANGES_UNSUPPORTED
1994
2232
  });
1995
- if (status.length > 0) {
1996
- throw new SpotPatchError16(
1997
- options.expectedHead === void 0 ? ERROR_CODES16.WORKTREE_DIRTY : ERROR_CODES16.APPLY_CONFLICT
1998
- );
2233
+ if (sourceDiff.length > 0) {
2234
+ await runGitCommand({
2235
+ cwd: worktreeRoot,
2236
+ args: ["apply", "--binary", "--whitespace=nowarn", "-"],
2237
+ stdin: sourceDiff,
2238
+ signal,
2239
+ errorCode: ERROR_CODES17.WORKTREE_LOCAL_CHANGES_UNSUPPORTED
2240
+ });
1999
2241
  }
2000
- return Object.freeze({ root, head });
2242
+ await copyUntrackedFiles(sourceRoot, worktreeRoot, untrackedPaths);
2243
+ const confirmation = await inspectGitWorkspace(sourceRoot, signal);
2244
+ const confirmationDiff = await runGitCommand({
2245
+ cwd: sourceRoot,
2246
+ args: [
2247
+ "diff",
2248
+ "--binary",
2249
+ "--full-index",
2250
+ "--no-ext-diff",
2251
+ "--no-color",
2252
+ "--no-renames",
2253
+ "HEAD",
2254
+ "--"
2255
+ ],
2256
+ signal,
2257
+ errorCode: ERROR_CODES17.APPLY_CONFLICT
2258
+ });
2259
+ if (confirmation.head !== expectedHead || confirmationDiff !== sourceDiff || confirmation.untrackedPaths.length !== untrackedPaths.length || confirmation.untrackedPaths.some((value, index) => value !== untrackedPaths[index])) {
2260
+ throw new SpotPatchError17(ERROR_CODES17.APPLY_CONFLICT);
2261
+ }
2262
+ await assertUntrackedFilesUnchanged(sourceRoot, worktreeRoot, untrackedPaths);
2263
+ await runGitCommand({
2264
+ cwd: worktreeRoot,
2265
+ args: ["add", "--all"],
2266
+ signal,
2267
+ errorCode: ERROR_CODES17.WORKTREE_LOCAL_CHANGES_UNSUPPORTED
2268
+ });
2269
+ await runGitCommand({
2270
+ cwd: worktreeRoot,
2271
+ args: [
2272
+ "-c",
2273
+ "user.name=SpotPatch Agent",
2274
+ "-c",
2275
+ "user.email=spotpatch-agent@example.invalid",
2276
+ "commit",
2277
+ "--quiet",
2278
+ "-m",
2279
+ "SpotPatch local workspace baseline"
2280
+ ],
2281
+ signal,
2282
+ errorCode: ERROR_CODES17.WORKTREE_LOCAL_CHANGES_UNSUPPORTED
2283
+ });
2001
2284
  }
2002
2285
  async function defaultTemporaryBase(root) {
2003
- const dependencyDirectory = path5.join(root, "node_modules");
2286
+ const dependencyDirectory = path6.join(root, "node_modules");
2004
2287
  try {
2005
- const stats = await lstat3(dependencyDirectory);
2288
+ const stats = await lstat4(dependencyDirectory);
2006
2289
  if (!stats.isDirectory() || stats.isSymbolicLink()) {
2007
2290
  return os.tmpdir();
2008
2291
  }
2009
- return await realpath2(dependencyDirectory);
2292
+ return await realpath3(dependencyDirectory);
2010
2293
  } catch {
2011
2294
  return os.tmpdir();
2012
2295
  }
2013
2296
  }
2014
2297
  async function createIsolatedGitWorktree(options) {
2015
- const baseline = await assertCleanGitBaseline({
2016
- root: options.root,
2017
- signal: options.signal
2298
+ const workingTreeMode = options.workingTreeMode ?? "require-clean";
2299
+ const inspection = await inspectGitWorkspace(options.root, options.signal);
2300
+ if (inspection.health.state === "blocked") {
2301
+ throw new SpotPatchError17(
2302
+ inspection.health.errorCode ?? ERROR_CODES17.WORKTREE_LOCAL_CHANGES_UNSUPPORTED
2303
+ );
2304
+ }
2305
+ if (inspection.health.state === "consent-required" && workingTreeMode === "require-clean") {
2306
+ throw new SpotPatchError17(ERROR_CODES17.WORKTREE_DIRTY);
2307
+ }
2308
+ const baseline = Object.freeze({
2309
+ root: inspection.root,
2310
+ head: inspection.head,
2311
+ workingTreeMode
2018
2312
  });
2019
2313
  const temporaryBase = options.temporaryBase ?? await defaultTemporaryBase(baseline.root);
2020
2314
  const temporaryDirectory = await mkdtemp(
2021
- path5.join(temporaryBase, "spotpatch-agent-")
2315
+ path6.join(temporaryBase, "spotpatch-agent-")
2022
2316
  );
2023
- const worktreePath = path5.join(temporaryDirectory, "worktree");
2317
+ const worktreePath = path6.join(temporaryDirectory, "worktree");
2024
2318
  let registered = false;
2025
2319
  let cleaned = false;
2026
2320
  const cleanup = async () => {
@@ -2035,7 +2329,7 @@ async function createIsolatedGitWorktree(options) {
2035
2329
  timeoutMs: 3e4
2036
2330
  }).catch(() => void 0);
2037
2331
  }
2038
- if (path5.basename(temporaryDirectory).startsWith("spotpatch-agent-")) {
2332
+ if (path6.basename(temporaryDirectory).startsWith("spotpatch-agent-")) {
2039
2333
  await rm2(temporaryDirectory, { recursive: true, force: true }).catch(
2040
2334
  () => void 0
2041
2335
  );
@@ -2045,12 +2339,12 @@ async function createIsolatedGitWorktree(options) {
2045
2339
  await runGitCommand({
2046
2340
  cwd: baseline.root,
2047
2341
  args: ["worktree", "add", "--detach", worktreePath, baseline.head],
2048
- errorCode: ERROR_CODES16.INTERNAL_ERROR,
2342
+ errorCode: ERROR_CODES17.INTERNAL_ERROR,
2049
2343
  signal: options.signal,
2050
2344
  timeoutMs: 3e4
2051
2345
  });
2052
2346
  registered = true;
2053
- const worktreeRoot = await realpath2(worktreePath);
2347
+ const worktreeRoot = await realpath3(worktreePath);
2054
2348
  const actualHead = (await runGitCommand({
2055
2349
  cwd: worktreeRoot,
2056
2350
  args: ["rev-parse", "--verify", "HEAD"],
@@ -2062,7 +2356,16 @@ async function createIsolatedGitWorktree(options) {
2062
2356
  signal: options.signal
2063
2357
  })).trim();
2064
2358
  if (actualHead !== baseline.head || !samePath(actualRoot, worktreeRoot)) {
2065
- throw new SpotPatchError16(ERROR_CODES16.INTERNAL_ERROR);
2359
+ throw new SpotPatchError17(ERROR_CODES17.INTERNAL_ERROR);
2360
+ }
2361
+ if (inspection.health.state === "consent-required") {
2362
+ await materializeLocalBaseline(
2363
+ baseline.root,
2364
+ worktreeRoot,
2365
+ baseline.head,
2366
+ inspection.untrackedPaths,
2367
+ options.signal
2368
+ );
2066
2369
  }
2067
2370
  return Object.freeze({ baseline, root: worktreeRoot, cleanup });
2068
2371
  } catch (error) {
@@ -2072,17 +2375,19 @@ async function createIsolatedGitWorktree(options) {
2072
2375
  }
2073
2376
 
2074
2377
  // src/worktree/prepared-change.ts
2075
- import { createHash as createHash2 } from "crypto";
2076
- import { lstat as lstat4, readFile as readFile2 } from "fs/promises";
2077
- import { ERROR_CODES as ERROR_CODES17, SpotPatchError as SpotPatchError17 } from "@spotpatch/shared";
2378
+ import { createHash as createHash3 } from "crypto";
2379
+ import { lstat as lstat5, readFile as readFile3 } from "fs/promises";
2380
+ import { ERROR_CODES as ERROR_CODES18, SpotPatchError as SpotPatchError18 } from "@spotpatch/shared";
2078
2381
  var privateChanges = /* @__PURE__ */ new WeakMap();
2079
2382
  var DELETED_HASH = "<deleted>";
2080
2383
  function createPreparedAgentChange(options) {
2081
2384
  const touchedPaths = Object.freeze(
2082
2385
  options.result.files.map((file) => file.relativePath)
2083
2386
  );
2084
- if (options.expectedHashes.size !== touchedPaths.length || touchedPaths.some((relativePath) => !options.expectedHashes.has(relativePath))) {
2085
- throw new SpotPatchError17(ERROR_CODES17.INTERNAL_ERROR);
2387
+ if (options.baselineHashes.size !== touchedPaths.length || options.expectedHashes.size !== touchedPaths.length || touchedPaths.some(
2388
+ (relativePath) => !options.baselineHashes.has(relativePath) || !options.expectedHashes.has(relativePath)
2389
+ )) {
2390
+ throw new SpotPatchError18(ERROR_CODES18.INTERNAL_ERROR);
2086
2391
  }
2087
2392
  const change = Object.freeze({
2088
2393
  kind: "prepared-agent-change",
@@ -2092,6 +2397,7 @@ function createPreparedAgentChange(options) {
2092
2397
  });
2093
2398
  privateChanges.set(change, {
2094
2399
  baselineHead: options.baselineHead,
2400
+ baselineHashes: new Map(options.baselineHashes),
2095
2401
  diff: options.result.diff,
2096
2402
  expectedHashes: new Map(options.expectedHashes),
2097
2403
  root: options.root,
@@ -2103,28 +2409,30 @@ function createPreparedAgentChange(options) {
2103
2409
  function requirePrivateChange(change) {
2104
2410
  const privateChange = privateChanges.get(change);
2105
2411
  if (privateChange === void 0) {
2106
- throw new SpotPatchError17(ERROR_CODES17.INTERNAL_ERROR);
2412
+ throw new SpotPatchError18(ERROR_CODES18.INTERNAL_ERROR);
2107
2413
  }
2108
2414
  return privateChange;
2109
2415
  }
2110
- async function currentHead(root) {
2111
- return (await runGitCommand({
2112
- cwd: root,
2113
- args: ["rev-parse", "--verify", "HEAD"],
2114
- errorCode: ERROR_CODES17.APPLY_CONFLICT
2115
- })).trim();
2416
+ async function assertWorkspaceOperationSafe(root, expectedHead) {
2417
+ const inspection = await inspectGitWorkspace(root).catch(() => {
2418
+ throw new SpotPatchError18(ERROR_CODES18.APPLY_CONFLICT);
2419
+ });
2420
+ const blockingOperation = inspection.health.errorCode === ERROR_CODES18.WORKTREE_OPERATION_IN_PROGRESS || inspection.health.errorCode === ERROR_CODES18.WORKTREE_CONFLICTED;
2421
+ if (inspection.head !== expectedHead || blockingOperation) {
2422
+ throw new SpotPatchError18(ERROR_CODES18.APPLY_CONFLICT);
2423
+ }
2116
2424
  }
2117
2425
  async function fileHash(root, relativePath) {
2118
2426
  const normalized = assertAgentPathAllowed(relativePath);
2119
2427
  const absolutePath = await resolveWritableAgentPath(root, normalized);
2120
- const metadata = await lstat4(absolutePath).catch(() => void 0);
2428
+ const metadata = await lstat5(absolutePath).catch(() => void 0);
2121
2429
  if (metadata === void 0) {
2122
2430
  return DELETED_HASH;
2123
2431
  }
2124
2432
  if (!metadata.isFile() || metadata.isSymbolicLink()) {
2125
- throw new SpotPatchError17(ERROR_CODES17.APPLY_CONFLICT);
2433
+ throw new SpotPatchError18(ERROR_CODES18.APPLY_CONFLICT);
2126
2434
  }
2127
- return createHash2("sha256").update(await readFile2(absolutePath)).digest("hex");
2435
+ return createHash3("sha256").update(await readFile3(absolutePath)).digest("hex");
2128
2436
  }
2129
2437
  async function captureAgentFileHashes(root, paths) {
2130
2438
  const entries = await Promise.all(
@@ -2140,34 +2448,38 @@ function hashesMatch(expected, actual) {
2140
2448
  async function applyPreparedAgentChange(change) {
2141
2449
  const privateChange = requirePrivateChange(change);
2142
2450
  if (privateChange.state !== "prepared" || !change.validationPassed || privateChange.diff.length === 0) {
2143
- throw new SpotPatchError17(
2144
- change.validationPassed ? ERROR_CODES17.APPLY_CONFLICT : ERROR_CODES17.VALIDATION_FAILED
2451
+ throw new SpotPatchError18(
2452
+ change.validationPassed ? ERROR_CODES18.APPLY_CONFLICT : ERROR_CODES18.VALIDATION_FAILED
2145
2453
  );
2146
2454
  }
2147
2455
  privateChange.state = "applying";
2148
2456
  try {
2149
- await assertCleanGitBaseline({
2150
- root: privateChange.root,
2151
- expectedHead: privateChange.baselineHead
2152
- });
2457
+ await assertWorkspaceOperationSafe(privateChange.root, privateChange.baselineHead);
2458
+ const currentHashes = await captureAgentFileHashes(
2459
+ privateChange.root,
2460
+ privateChange.touchedPaths
2461
+ );
2462
+ if (!hashesMatch(privateChange.baselineHashes, currentHashes)) {
2463
+ throw new SpotPatchError18(ERROR_CODES18.APPLY_CONFLICT);
2464
+ }
2153
2465
  await runGitCommand({
2154
2466
  cwd: privateChange.root,
2155
2467
  args: ["apply", "--check", "--whitespace=error-all", "-"],
2156
2468
  stdin: privateChange.diff,
2157
- errorCode: ERROR_CODES17.APPLY_CONFLICT
2469
+ errorCode: ERROR_CODES18.APPLY_CONFLICT
2158
2470
  });
2159
2471
  await runGitCommand({
2160
2472
  cwd: privateChange.root,
2161
2473
  args: ["apply", "--whitespace=error-all", "-"],
2162
2474
  stdin: privateChange.diff,
2163
- errorCode: ERROR_CODES17.APPLY_CONFLICT
2475
+ errorCode: ERROR_CODES18.APPLY_CONFLICT
2164
2476
  });
2165
2477
  const appliedHashes = await captureAgentFileHashes(
2166
2478
  privateChange.root,
2167
2479
  privateChange.touchedPaths
2168
2480
  );
2169
2481
  if (!hashesMatch(privateChange.expectedHashes, appliedHashes)) {
2170
- throw new SpotPatchError17(ERROR_CODES17.APPLY_CONFLICT);
2482
+ throw new SpotPatchError18(ERROR_CODES18.APPLY_CONFLICT);
2171
2483
  }
2172
2484
  privateChange.appliedHashes = appliedHashes;
2173
2485
  privateChange.state = "applied";
@@ -2179,34 +2491,39 @@ async function applyPreparedAgentChange(change) {
2179
2491
  async function revertPreparedAgentChange(change) {
2180
2492
  const privateChange = requirePrivateChange(change);
2181
2493
  if (privateChange.state !== "applied" || privateChange.appliedHashes === void 0) {
2182
- throw new SpotPatchError17(ERROR_CODES17.APPLY_CONFLICT);
2494
+ throw new SpotPatchError18(ERROR_CODES18.APPLY_CONFLICT);
2183
2495
  }
2184
2496
  privateChange.state = "reverting";
2185
2497
  try {
2186
- if (await currentHead(privateChange.root) !== privateChange.baselineHead) {
2187
- throw new SpotPatchError17(ERROR_CODES17.APPLY_CONFLICT);
2188
- }
2498
+ await assertWorkspaceOperationSafe(privateChange.root, privateChange.baselineHead);
2189
2499
  const currentHashes = await captureAgentFileHashes(
2190
2500
  privateChange.root,
2191
2501
  privateChange.touchedPaths
2192
2502
  );
2193
2503
  for (const [relativePath, expectedHash] of privateChange.appliedHashes) {
2194
2504
  if (currentHashes.get(relativePath) !== expectedHash) {
2195
- throw new SpotPatchError17(ERROR_CODES17.APPLY_CONFLICT);
2505
+ throw new SpotPatchError18(ERROR_CODES18.APPLY_CONFLICT);
2196
2506
  }
2197
2507
  }
2198
2508
  await runGitCommand({
2199
2509
  cwd: privateChange.root,
2200
2510
  args: ["apply", "--reverse", "--check", "--whitespace=error-all", "-"],
2201
2511
  stdin: privateChange.diff,
2202
- errorCode: ERROR_CODES17.APPLY_CONFLICT
2512
+ errorCode: ERROR_CODES18.APPLY_CONFLICT
2203
2513
  });
2204
2514
  await runGitCommand({
2205
2515
  cwd: privateChange.root,
2206
2516
  args: ["apply", "--reverse", "--whitespace=error-all", "-"],
2207
2517
  stdin: privateChange.diff,
2208
- errorCode: ERROR_CODES17.APPLY_CONFLICT
2518
+ errorCode: ERROR_CODES18.APPLY_CONFLICT
2209
2519
  });
2520
+ const revertedHashes = await captureAgentFileHashes(
2521
+ privateChange.root,
2522
+ privateChange.touchedPaths
2523
+ );
2524
+ if (!hashesMatch(privateChange.baselineHashes, revertedHashes)) {
2525
+ throw new SpotPatchError18(ERROR_CODES18.APPLY_CONFLICT);
2526
+ }
2210
2527
  privateChange.state = "reverted";
2211
2528
  } catch (error) {
2212
2529
  privateChange.state = "applied";
@@ -2388,11 +2705,11 @@ function isRetryableToolFailure(result) {
2388
2705
  return false;
2389
2706
  }
2390
2707
  const candidate = output;
2391
- return candidate.errorCode === ERROR_CODES18.PATCH_REJECTED && candidate.retryable === true;
2708
+ return candidate.errorCode === ERROR_CODES19.PATCH_REJECTED && candidate.retryable === true;
2392
2709
  }
2393
2710
  function throwIfCancelled(signal) {
2394
2711
  if (signal.aborted) {
2395
- throw new SpotPatchError18(ERROR_CODES18.AGENT_CANCELLED);
2712
+ throw new SpotPatchError19(ERROR_CODES19.AGENT_CANCELLED);
2396
2713
  }
2397
2714
  }
2398
2715
  function linkSignal(source, target) {
@@ -2430,6 +2747,7 @@ async function executeAgentChange(options) {
2430
2747
  worktree = await createIsolatedGitWorktree({
2431
2748
  root: options.root,
2432
2749
  signal: controller.signal,
2750
+ workingTreeMode: options.workingTreeMode ?? "require-clean",
2433
2751
  ...options.temporaryBase === void 0 ? {} : { temporaryBase: options.temporaryBase }
2434
2752
  });
2435
2753
  options.callbacks?.onPhase?.(
@@ -2471,7 +2789,7 @@ async function executeAgentChange(options) {
2471
2789
  }
2472
2790
  toolCallCount += response.toolCalls.length;
2473
2791
  if (toolCallCount > options.execution.limits.maxToolCalls) {
2474
- throw new SpotPatchError18(ERROR_CODES18.AGENT_LIMIT_EXCEEDED);
2792
+ throw new SpotPatchError19(ERROR_CODES19.AGENT_LIMIT_EXCEEDED);
2475
2793
  }
2476
2794
  const results = [];
2477
2795
  for (const call of response.toolCalls) {
@@ -2506,7 +2824,7 @@ async function executeAgentChange(options) {
2506
2824
  pendingResults = Object.freeze(results);
2507
2825
  }
2508
2826
  if (summary === void 0) {
2509
- throw new SpotPatchError18(ERROR_CODES18.AGENT_LIMIT_EXCEEDED);
2827
+ throw new SpotPatchError19(ERROR_CODES19.AGENT_LIMIT_EXCEEDED);
2510
2828
  }
2511
2829
  options.callbacks?.onPhase?.(
2512
2830
  Object.freeze({
@@ -2539,7 +2857,7 @@ async function executeAgentChange(options) {
2539
2857
  controller.signal
2540
2858
  );
2541
2859
  if (afterCheck.diff !== initialChangeSet.diff) {
2542
- throw new SpotPatchError18(ERROR_CODES18.VALIDATION_FAILED);
2860
+ throw new SpotPatchError19(ERROR_CODES19.VALIDATION_FAILED);
2543
2861
  }
2544
2862
  }
2545
2863
  const validationPassed = finalChecks.every((check) => check.status === "passed");
@@ -2555,9 +2873,14 @@ async function executeAgentChange(options) {
2555
2873
  worktree.root,
2556
2874
  initialChangeSet.touchedPaths
2557
2875
  );
2876
+ const baselineHashes = await captureAgentFileHashes(
2877
+ worktree.baseline.root,
2878
+ initialChangeSet.touchedPaths
2879
+ );
2558
2880
  return createPreparedAgentChange({
2559
2881
  autoApplyEligible,
2560
2882
  baselineHead: worktree.baseline.head,
2883
+ baselineHashes,
2561
2884
  expectedHashes,
2562
2885
  result,
2563
2886
  root: worktree.baseline.root,
@@ -2565,15 +2888,15 @@ async function executeAgentChange(options) {
2565
2888
  });
2566
2889
  } catch (error) {
2567
2890
  if (options.signal.aborted) {
2568
- throw new SpotPatchError18(ERROR_CODES18.AGENT_CANCELLED);
2891
+ throw new SpotPatchError19(ERROR_CODES19.AGENT_CANCELLED);
2569
2892
  }
2570
2893
  if (hasJobTimedOut()) {
2571
- throw new SpotPatchError18(ERROR_CODES18.AGENT_LIMIT_EXCEEDED);
2894
+ throw new SpotPatchError19(ERROR_CODES19.AGENT_LIMIT_EXCEEDED);
2572
2895
  }
2573
- if (error instanceof SpotPatchError18) {
2896
+ if (error instanceof SpotPatchError19) {
2574
2897
  throw error;
2575
2898
  }
2576
- throw new SpotPatchError18(ERROR_CODES18.INTERNAL_ERROR);
2899
+ throw new SpotPatchError19(ERROR_CODES19.INTERNAL_ERROR);
2577
2900
  } finally {
2578
2901
  clearTimeout(timeout);
2579
2902
  unlink();
@@ -2583,15 +2906,15 @@ async function executeAgentChange(options) {
2583
2906
 
2584
2907
  // src/provider/capability-probe.ts
2585
2908
  import {
2586
- ERROR_CODES as ERROR_CODES19,
2587
- SpotPatchError as SpotPatchError19
2909
+ ERROR_CODES as ERROR_CODES20,
2910
+ SpotPatchError as SpotPatchError20
2588
2911
  } from "@spotpatch/shared";
2589
2912
  var PROBE_TOOL_NAME = "spotpatch_capability_probe";
2590
2913
  var PROBE_TOKEN = "spotpatch-ready-v1";
2591
2914
  async function probeProviderCapability(options) {
2592
2915
  const model = options.provider.models[options.modelProfileId];
2593
2916
  if (model === void 0) {
2594
- throw new SpotPatchError19(ERROR_CODES19.MODEL_NOT_ALLOWED);
2917
+ throw new SpotPatchError20(ERROR_CODES20.MODEL_NOT_ALLOWED);
2595
2918
  }
2596
2919
  const credential = options.credential ?? resolveProviderCredential(options.provider.apiKeyEnv, options.environment);
2597
2920
  const session = createOpenAICompatibleProviderSession({
@@ -2620,7 +2943,7 @@ async function probeProviderCapability(options) {
2620
2943
  const first = await session.next(void 0, options.signal);
2621
2944
  const probeCall = first.toolCalls[0];
2622
2945
  if (first.toolCalls.length !== 1 || probeCall?.name !== PROBE_TOOL_NAME || probeCall.arguments.token !== PROBE_TOKEN) {
2623
- throw new SpotPatchError19(ERROR_CODES19.MODEL_TOOL_CALL_UNSUPPORTED);
2946
+ throw new SpotPatchError20(ERROR_CODES20.MODEL_TOOL_CALL_UNSUPPORTED);
2624
2947
  }
2625
2948
  const second = await session.next(
2626
2949
  Object.freeze([
@@ -2632,7 +2955,7 @@ async function probeProviderCapability(options) {
2632
2955
  options.signal
2633
2956
  );
2634
2957
  if (second.toolCalls.length !== 0 || second.finalText.trim().length === 0) {
2635
- throw new SpotPatchError19(ERROR_CODES19.MODEL_TOOL_CALL_UNSUPPORTED);
2958
+ throw new SpotPatchError20(ERROR_CODES20.MODEL_TOOL_CALL_UNSUPPORTED);
2636
2959
  }
2637
2960
  return Object.freeze({
2638
2961
  providerProfileId: options.provider.id,
@@ -2654,6 +2977,7 @@ export {
2654
2977
  createOpenAICompatibleProviderSession,
2655
2978
  createProviderCredential,
2656
2979
  executeAgentChange,
2980
+ inspectAgentWorkspace,
2657
2981
  probeProviderCapability,
2658
2982
  resolveProviderCredential,
2659
2983
  revertPreparedAgentChange