From 7767ee002b8b50ba9c40a073d2ad45b8a659b355 Mon Sep 17 00:00:00 2001 From: Oleksandr Zabolotnyi Date: Mon, 31 Aug 2026 14:59:04 +0200 Subject: [PATCH] feat(server): warn when the agent leaves the thread worktree Agents can set cwd to a sibling clone while the UI still shows the selected worktree. Tell the provider the bound checkout, surface a warning in the thread, and render that warning as an aligned row with the agent icon. Co-authored-by: Cursor --- .../Layers/ProviderCommandReactor.ts | 10 ++ .../Layers/ProviderRuntimeIngestion.ts | 17 +++ .../workspaceScopePrompt.test.ts | 28 +++++ .../src/orchestration/workspaceScopePrompt.ts | 24 ++++ .../workspaceScopeWarning.test.ts | 86 +++++++++++++ .../orchestration/workspaceScopeWarning.ts | 73 +++++++++++ .../src/components/chat/MessagesTimeline.tsx | 14 +++ apps/web/src/workspaceScopeWarningUi.test.ts | 43 +++++++ apps/web/src/workspaceScopeWarningUi.tsx | 107 ++++++++++++++++ docs/user/permission-modes.md | 3 +- docs/user/thread-sidebar.md | 4 + packages/shared/package.json | 4 + packages/shared/src/workspaceScope.test.ts | 67 ++++++++++ packages/shared/src/workspaceScope.ts | 117 ++++++++++++++++++ 14 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/orchestration/workspaceScopePrompt.test.ts create mode 100644 apps/server/src/orchestration/workspaceScopePrompt.ts create mode 100644 apps/server/src/orchestration/workspaceScopeWarning.test.ts create mode 100644 apps/server/src/orchestration/workspaceScopeWarning.ts create mode 100644 apps/web/src/workspaceScopeWarningUi.test.ts create mode 100644 apps/web/src/workspaceScopeWarningUi.tsx create mode 100644 packages/shared/src/workspaceScope.test.ts create mode 100644 packages/shared/src/workspaceScope.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ca96d8a8210d..ff8ff092882f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -42,6 +42,10 @@ import { appendProjectBoardToTurnInput, formatProjectBoardPromptBlock, } from "../projectBoardPrompt.ts"; +import { + formatWorkspaceScopePromptBlock, + prependWorkspaceScopeToTurnInput, +} from "../workspaceScopePrompt.ts"; import { ProviderCommandReactor, type ProviderCommandReactorShape, @@ -863,6 +867,12 @@ const make = Effect.gen(function* () { if (!rulesAlreadySent) threadsWithBoardRulesSent.add(input.threadId); turnInput = appendProjectBoardToTurnInput(turnInput, boardBlock); } + if (turnInput && thread.worktreePath) { + turnInput = prependWorkspaceScopeToTurnInput( + turnInput, + formatWorkspaceScopePromptBlock({ cwd: thread.worktreePath, branch: thread.branch }), + ); + } return { threadId: input.threadId, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7ec3a7e64243..860cc3e55834 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -42,6 +42,7 @@ import { type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; +import { maybeWorkspaceScopeWarningActivity } from "../workspaceScopeWarning.ts"; import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { canReplaceThreadTitle } from "../threadTitles.ts"; @@ -900,6 +901,7 @@ const make = Effect.gen(function* () { crypto.randomUUIDv4.pipe( Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)), ); + const warnedWorkspaceScopeKeys = new Set(); const turnMessageIdsByTurnKey = yield* Cache.make>({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, @@ -2041,6 +2043,21 @@ const make = Effect.gen(function* () { ), ), ).pipe(Effect.asVoid); + const workspaceScopeWarning = maybeWorkspaceScopeWarningActivity({ + event, + worktreePath: thread.worktreePath, + warnedKeys: warnedWorkspaceScopeKeys, + }); + if (workspaceScopeWarning) { + const commandId = yield* providerCommandId(event, "thread-activity-append-workspace-scope"); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId, + threadId: thread.id, + activity: workspaceScopeWarning, + createdAt: workspaceScopeWarning.createdAt, + }); + } }); const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void; diff --git a/apps/server/src/orchestration/workspaceScopePrompt.test.ts b/apps/server/src/orchestration/workspaceScopePrompt.test.ts new file mode 100644 index 000000000000..8884adb891ed --- /dev/null +++ b/apps/server/src/orchestration/workspaceScopePrompt.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + formatWorkspaceScopePromptBlock, + prependWorkspaceScopeToTurnInput, +} from "./workspaceScopePrompt.ts"; + +describe("workspaceScopePrompt", () => { + it("names the assigned worktree path and branch", () => { + const block = formatWorkspaceScopePromptBlock({ + cwd: "/tmp/worktree", + branch: "t3code/update-main-from-github", + }); + expect(block).toContain(""); + expect(block).toContain("Path: /tmp/worktree"); + expect(block).toContain("Branch: t3code/update-main-from-github"); + expect(block).toContain("Do not switch to another clone"); + }); + + it("prepends the block so the constraint is visible before the user prompt", () => { + expect( + prependWorkspaceScopeToTurnInput( + "fix the bug", + "\nscoped\n", + ), + ).toBe("\nscoped\n\n\nfix the bug"); + }); +}); diff --git a/apps/server/src/orchestration/workspaceScopePrompt.ts b/apps/server/src/orchestration/workspaceScopePrompt.ts new file mode 100644 index 000000000000..4a3570738bf6 --- /dev/null +++ b/apps/server/src/orchestration/workspaceScopePrompt.ts @@ -0,0 +1,24 @@ +export function formatWorkspaceScopePromptBlock(input: { + readonly cwd: string; + readonly branch: string | null; +}): string { + const branchLine = input.branch ? `Branch: ${input.branch}` : "Branch: (unknown)"; + return [ + "", + "This thread is attached to a Git worktree. All file edits, commits, git commands, and dev servers must use this directory as the working directory. Do not switch to another clone of the same repository.", + `Path: ${input.cwd}`, + branchLine, + "If you need a different path, stop and ask the user to confirm that exact path first.", + "", + ].join("\n"); +} + +export function prependWorkspaceScopeToTurnInput( + input: string | undefined, + block: string | null, +): string | undefined { + if (!block) return input; + const trimmed = input?.trim(); + if (!trimmed) return block; + return `${block}\n\n${trimmed}`; +} diff --git a/apps/server/src/orchestration/workspaceScopeWarning.test.ts b/apps/server/src/orchestration/workspaceScopeWarning.test.ts new file mode 100644 index 000000000000..e1b429f1ea4e --- /dev/null +++ b/apps/server/src/orchestration/workspaceScopeWarning.test.ts @@ -0,0 +1,86 @@ +import { + EventId, + ProviderDriverKind, + ThreadId, + TurnId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { maybeWorkspaceScopeWarningActivity } from "./workspaceScopeWarning.ts"; + +const worktree = "/Users/me/.t3-jcode/worktrees/marswalk/t3code-dae6736c"; +const otherClone = "/Users/me/Documents/Project/marswalk"; + +const base = { + provider: ProviderDriverKind.make("cursor"), + createdAt: "2026-08-31T12:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: TurnId.make("turn-1"), +}; + +function toolEvent(eventId: string, data: Record): ProviderRuntimeEvent { + return { + ...base, + type: "item.completed", + eventId: EventId.make(eventId), + payload: { + itemType: "command_execution", + title: "Terminal", + data, + }, + } satisfies ProviderRuntimeEvent; +} + +describe("workspaceScopeWarning", () => { + it("warns when a command's working directory is another clone", () => { + const warnedKeys = new Set(); + const activity = maybeWorkspaceScopeWarningActivity({ + event: toolEvent("event-1", { + command: "git status", + rawInput: { working_directory: otherClone }, + }), + worktreePath: worktree, + warnedKeys, + }); + expect(activity?.kind).toBe("runtime.warning"); + expect(activity?.summary).toContain("different checkout"); + const payload = activity?.payload as { detail?: string }; + expect(payload.detail).toContain(otherClone); + expect(payload.detail).toContain(worktree); + expect(payload.detail).toContain("Provider: cursor"); + }); + + it("warns only once per escaped path on a thread", () => { + const warnedKeys = new Set(); + const first = maybeWorkspaceScopeWarningActivity({ + event: toolEvent("event-1", { rawInput: { cwd: otherClone } }), + worktreePath: worktree, + warnedKeys, + }); + const second = maybeWorkspaceScopeWarningActivity({ + event: toolEvent("event-2", { rawInput: { cwd: otherClone } }), + worktreePath: worktree, + warnedKeys, + }); + expect(first).not.toBeNull(); + expect(second).toBeNull(); + }); + + it("stays quiet when the thread has no worktree or the command stays in it", () => { + expect( + maybeWorkspaceScopeWarningActivity({ + event: toolEvent("event-1", { rawInput: { working_directory: otherClone } }), + worktreePath: null, + warnedKeys: new Set(), + }), + ).toBeNull(); + expect( + maybeWorkspaceScopeWarningActivity({ + event: toolEvent("event-1", { rawInput: { working_directory: worktree } }), + worktreePath: worktree, + warnedKeys: new Set(), + }), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/orchestration/workspaceScopeWarning.ts b/apps/server/src/orchestration/workspaceScopeWarning.ts new file mode 100644 index 000000000000..31206bf03aba --- /dev/null +++ b/apps/server/src/orchestration/workspaceScopeWarning.ts @@ -0,0 +1,73 @@ +import { + EventId, + type OrchestrationThreadActivity, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import { findOutOfWorkspaceScopePath } from "@t3tools/shared/workspaceScope"; + +const WARNABLE_EVENT_TYPES = new Set([ + "item.started", + "item.updated", + "item.completed", + "request.opened", +]); + +const FILE_PATH_ITEM_TYPES = new Set([ + "command_execution", + "file_change", + "file_edit", + "create_file", + "edit", +]); + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function payloadLooksLikeFileMutation(payload: Record): boolean { + const itemType = typeof payload.itemType === "string" ? payload.itemType : undefined; + if (itemType && FILE_PATH_ITEM_TYPES.has(itemType)) { + return true; + } + const requestType = typeof payload.requestType === "string" ? payload.requestType : undefined; + return requestType === "command" || requestType === "file-change"; +} + +export function maybeWorkspaceScopeWarningActivity(input: { + readonly event: ProviderRuntimeEvent; + readonly worktreePath: string | null; + readonly warnedKeys: Set; +}): OrchestrationThreadActivity | null { + const worktreePath = input.worktreePath?.trim() ?? ""; + if (!worktreePath || !WARNABLE_EVENT_TYPES.has(input.event.type)) { + return null; + } + const payload = asRecord(input.event.payload) ?? {}; + const outOfScopePath = findOutOfWorkspaceScopePath({ + workspaceRoot: worktreePath, + data: payload, + includeFilePaths: payloadLooksLikeFileMutation(payload), + }); + if (!outOfScopePath) { + return null; + } + const warningKey = `${input.event.threadId}:${outOfScopePath}`; + if (input.warnedKeys.has(warningKey)) { + return null; + } + input.warnedKeys.add(warningKey); + return { + id: EventId.make(`workspace-scope:${input.event.eventId}`), + createdAt: input.event.createdAt, + tone: "info", + kind: "runtime.warning", + summary: "Agent used a different checkout than this thread's worktree", + payload: { + message: "Agent used a different checkout than this thread's worktree", + detail: `Thread worktree: ${worktreePath}\nUsed path: ${outOfScopePath}\nProvider: ${input.event.provider}`, + }, + turnId: input.event.turnId ?? null, + }; +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e5b34ddbe19c..c8da4fb77074 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -128,6 +128,10 @@ import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; +import { + parseWorkspaceScopeWarning, + WorkspaceScopeWarningRow, +} from "../../workspaceScopeWarningUi"; import { buildInlineTerminalContextText, @@ -2718,7 +2722,17 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { isExpandedToolGroupEntry: boolean; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; + const workspaceScopeMismatch = parseWorkspaceScopeWarning(workEntry); const [expanded, setExpanded] = useState(false); + if (workspaceScopeMismatch) { + return ( + + ); + } const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry); diff --git a/apps/web/src/workspaceScopeWarningUi.test.ts b/apps/web/src/workspaceScopeWarningUi.test.ts new file mode 100644 index 000000000000..470301b67094 --- /dev/null +++ b/apps/web/src/workspaceScopeWarningUi.test.ts @@ -0,0 +1,43 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { parseWorkspaceScopeWarning } from "./workspaceScopeWarningUi"; + +describe("parseWorkspaceScopeWarning", () => { + it("reads aligned thread and agent paths from the warning detail", () => { + expect( + parseWorkspaceScopeWarning({ + sourceActivityKind: "runtime.warning", + detail: + "Thread worktree: /Users/me/.t3-jcode/worktrees/marswalk/t3code-dae6736c\nUsed path: /Users/me/Documents/Project/marswalk\nProvider: cursor", + }), + ).toEqual({ + threadWorktree: "/Users/me/.t3-jcode/worktrees/marswalk/t3code-dae6736c", + usedPath: "/Users/me/Documents/Project/marswalk", + provider: ProviderDriverKind.make("cursor"), + }); + }); + + it("ignores other runtime warnings", () => { + expect( + parseWorkspaceScopeWarning({ + sourceActivityKind: "runtime.warning", + detail: "Model rerouted to a fallback", + }), + ).toBeNull(); + }); + + it("keeps older warnings that have no provider line", () => { + expect( + parseWorkspaceScopeWarning({ + sourceActivityKind: "runtime.warning", + detail: + "Thread worktree: /Users/me/.t3-jcode/worktrees/marswalk/t3code-dae6736c\nUsed path: /Users/me/Documents/Project/marswalk", + }), + ).toEqual({ + threadWorktree: "/Users/me/.t3-jcode/worktrees/marswalk/t3code-dae6736c", + usedPath: "/Users/me/Documents/Project/marswalk", + provider: null, + }); + }); +}); diff --git a/apps/web/src/workspaceScopeWarningUi.tsx b/apps/web/src/workspaceScopeWarningUi.tsx new file mode 100644 index 000000000000..9edec210d2d5 --- /dev/null +++ b/apps/web/src/workspaceScopeWarningUi.tsx @@ -0,0 +1,107 @@ +import { isProviderDriverKind, type ProviderDriverKind } from "@t3tools/contracts"; +import { BotIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { ProviderInstanceIcon } from "./components/chat/ProviderInstanceIcon"; +import { formatWorktreePathForDisplay } from "./worktreeCleanup"; + +export type WorkspaceScopeMismatch = { + readonly threadWorktree: string; + readonly usedPath: string; + readonly provider: ProviderDriverKind | null; +}; + +const THREAD_PREFIX = "Thread worktree: "; +const USED_PREFIX = "Used path: "; +const PROVIDER_PREFIX = "Provider: "; + +export function parseWorkspaceScopeWarning(entry: { + readonly sourceActivityKind?: string; + readonly detail?: string; +}): WorkspaceScopeMismatch | null { + if (entry.sourceActivityKind !== "runtime.warning") { + return null; + } + const detail = entry.detail?.trim() ?? ""; + if (!detail.startsWith(THREAD_PREFIX)) { + return null; + } + let threadWorktree = ""; + let usedPath = ""; + let provider: ProviderDriverKind | null = null; + for (const rawLine of detail.split("\n")) { + const line = rawLine.replace(/\r$/, ""); + if (line.startsWith(THREAD_PREFIX)) { + threadWorktree = line.slice(THREAD_PREFIX.length).trim(); + } else if (line.startsWith(USED_PREFIX)) { + usedPath = line.slice(USED_PREFIX.length).trim(); + } else if (line.startsWith(PROVIDER_PREFIX)) { + const raw = line.slice(PROVIDER_PREFIX.length).trim(); + provider = isProviderDriverKind(raw) ? raw : null; + } + } + if (!threadWorktree || !usedPath) { + return null; + } + return { threadWorktree, usedPath, provider }; +} + +export function WorkspaceScopeWarningRow(props: { + readonly label: string; + readonly mismatch: WorkspaceScopeMismatch; + readonly isExpandedToolGroupEntry: boolean; +}) { + const threadLabel = formatWorktreePathForDisplay(props.mismatch.threadWorktree); + const usedLabel = formatWorktreePathForDisplay(props.mismatch.usedPath); + const providerName = props.mismatch.provider ?? "Agent"; + + return ( +
+
+ + {props.mismatch.provider ? ( + + ) : ( + + )} + +
+

{props.label}

+
+
Thread
+
+ {threadLabel} +
+
Agent
+
+ {usedLabel} +
+
+
+
+
+ ); +} diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 9bf9c10b20f5..96481844ebed 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -21,7 +21,8 @@ permission mode, and providers without an equivalent (such as OpenCode) fall bac Supervised. **Full access**: allow commands and edits without prompts. The default. The agent runs -unattended until it finishes or asks a question of its own. +unattended until it finishes or asks a question of its own. If a thread is attached to a worktree +and the agent still runs in a different checkout, T3 Code shows a warning in the thread. Approvals appear inline in the conversation. Approve or reject one and the agent continues from there. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index d1b612248ee8..807d93ad2fa9 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -18,6 +18,10 @@ When you start a new thread in a Git project, select its workspace in the compos Model selection remains independent of the workspace choice, so a new thread can use a different model in any existing worktree. A started thread keeps its selected workspace. +The agent is told to edit files, run commands, and use Git only in that workspace. If it uses a +different folder — for example another clone of the same repository — T3 Code shows a warning in +the thread. + A project with a long settled history shows recent chats first. Select **Show more** inside that project to reveal older settled chats. diff --git a/packages/shared/package.json b/packages/shared/package.json index 8263a1e3c8f2..6eb05ff42084 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -242,6 +242,10 @@ "./claudeCompaction": { "types": "./src/claudeCompaction.ts", "import": "./src/claudeCompaction.ts" + }, + "./workspaceScope": { + "types": "./src/workspaceScope.ts", + "import": "./src/workspaceScope.ts" } }, "scripts": { diff --git a/packages/shared/src/workspaceScope.test.ts b/packages/shared/src/workspaceScope.test.ts new file mode 100644 index 000000000000..b4896974a352 --- /dev/null +++ b/packages/shared/src/workspaceScope.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + findOutOfWorkspaceScopePath, + isPathInsideWorkspaceScope, + normalizeWorkspaceScopePath, +} from "./workspaceScope.ts"; + +describe("workspaceScope", () => { + const worktree = "/Users/me/.t3-jcode/worktrees/marswalk/t3code-dae6736c"; + const otherClone = "/Users/me/Documents/Project/marswalk"; + + it("treats the assigned worktree and its files as in scope", () => { + expect(isPathInsideWorkspaceScope(worktree, worktree)).toBe(true); + expect(isPathInsideWorkspaceScope(`${worktree}/apps/web/src/App.tsx`, worktree)).toBe(true); + expect(isPathInsideWorkspaceScope("src/App.tsx", worktree)).toBe(true); + }); + + it("treats a sibling clone as out of scope", () => { + expect(isPathInsideWorkspaceScope(otherClone, worktree)).toBe(false); + expect(isPathInsideWorkspaceScope(`${otherClone}/package.json`, worktree)).toBe(false); + }); + + it("extracts Cursor working_directory from tool payloads", () => { + expect( + findOutOfWorkspaceScopePath({ + workspaceRoot: worktree, + data: { + command: "git status", + rawInput: { working_directory: otherClone }, + }, + }), + ).toBe(otherClone); + }); + + it("does not warn when the tool stays in the assigned worktree", () => { + expect( + findOutOfWorkspaceScopePath({ + workspaceRoot: worktree, + data: { + rawInput: { working_directory: worktree, command: "vp test run" }, + }, + }), + ).toBeUndefined(); + }); + + it("ignores file paths unless asked, so config reads stay quiet", () => { + expect( + findOutOfWorkspaceScopePath({ + workspaceRoot: worktree, + data: { path: "/Users/me/.cursor/skills/foo/SKILL.md" }, + }), + ).toBeUndefined(); + expect( + findOutOfWorkspaceScopePath({ + workspaceRoot: worktree, + data: { path: `${otherClone}/src/index.ts` }, + includeFilePaths: true, + }), + ).toBe(`${otherClone}/src/index.ts`); + }); + + it("normalizes trailing slashes", () => { + expect(normalizeWorkspaceScopePath(`${worktree}/`)).toBe(worktree); + expect(isPathInsideWorkspaceScope(`${worktree}/`, worktree)).toBe(true); + }); +}); diff --git a/packages/shared/src/workspaceScope.ts b/packages/shared/src/workspaceScope.ts new file mode 100644 index 000000000000..6ee0de623c99 --- /dev/null +++ b/packages/shared/src/workspaceScope.ts @@ -0,0 +1,117 @@ +const CWD_KEYS = new Set([ + "cwd", + "workdir", + "working_directory", + "workingDirectory", + "workingDir", + "directory", +]); + +const FILE_PATH_KEYS = new Set(["path", "filePath", "newPath", "oldPath"]); + +const NESTED_KEYS = ["rawInput", "input", "data", "item", "locations", "changes", "payload"]; + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function normalizeWorkspaceScopePath(path: string): string { + const unified = path.trim().replaceAll("\\", "/"); + if (unified.length > 1 && unified.endsWith("/")) { + return unified.slice(0, -1); + } + return unified; +} + +export function isAbsoluteWorkspaceScopePath(path: string): boolean { + const normalized = normalizeWorkspaceScopePath(path); + return normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized); +} + +export function isPathInsideWorkspaceScope(path: string, workspaceRoot: string): boolean { + if (!isAbsoluteWorkspaceScopePath(path)) { + return true; + } + const root = normalizeWorkspaceScopePath(workspaceRoot); + if (root.length === 0) { + return true; + } + const caseInsensitive = /^[A-Za-z]:\//u.test(root); + const candidate = caseInsensitive + ? normalizeWorkspaceScopePath(path).toLowerCase() + : normalizeWorkspaceScopePath(path); + const normalizedRoot = caseInsensitive ? root.toLowerCase() : root; + return candidate === normalizedRoot || candidate.startsWith(`${normalizedRoot}/`); +} + +function collectCandidates( + value: unknown, + paths: string[], + seen: Set, + depth: number, + includeFilePaths: boolean, +): void { + if (depth > 5 || paths.length >= 8) { + return; + } + if (Array.isArray(value)) { + for (const entry of value) { + collectCandidates(entry, paths, seen, depth + 1, includeFilePaths); + if (paths.length >= 8) { + return; + } + } + return; + } + const record = asRecord(value); + if (!record) { + return; + } + for (const [key, raw] of Object.entries(record)) { + if (!CWD_KEYS.has(key) && !(includeFilePaths && FILE_PATH_KEYS.has(key))) { + continue; + } + const candidate = asTrimmedString(raw); + if (!candidate || seen.has(candidate) || !isAbsoluteWorkspaceScopePath(candidate)) { + continue; + } + seen.add(candidate); + paths.push(candidate); + if (paths.length >= 8) { + return; + } + } + for (const nestedKey of NESTED_KEYS) { + if (!(nestedKey in record)) { + continue; + } + collectCandidates(record[nestedKey], paths, seen, depth + 1, includeFilePaths); + if (paths.length >= 8) { + return; + } + } +} + +export function findOutOfWorkspaceScopePath(input: { + readonly workspaceRoot: string | null | undefined; + readonly data: unknown; + readonly includeFilePaths?: boolean; +}): string | undefined { + const workspaceRoot = input.workspaceRoot?.trim(); + if (!workspaceRoot) { + return undefined; + } + const candidates: string[] = []; + collectCandidates(input.data, candidates, new Set(), 0, input.includeFilePaths === true); + return candidates.find((candidate) => !isPathInsideWorkspaceScope(candidate, workspaceRoot)); +}