diff --git a/openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/.openspec.yaml b/openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/.openspec.yaml new file mode 100644 index 00000000..81cd71fe --- /dev/null +++ b/openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-11 diff --git a/openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/proposal.md b/openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/proposal.md new file mode 100644 index 00000000..6620b1e0 --- /dev/null +++ b/openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/proposal.md @@ -0,0 +1,17 @@ +## Why + +- VS Code Source Control can show a parent workspace repo plus nested Git repos such as `apps/storefront` and `apps/backend`. +- Starting a Guardex lane from the Active Agents sidebar previously defaulted to the workspace folder, so users could not choose the nested repo that owns the visible `main` branch they want to keep stable. +- The launcher should make the selected nested repo the command cwd, allowing `gx branch start` to create an isolated `agent/*` branch/worktree for that repo without switching its visible `main` checkout. + +## What Changes + +- Discover nested Git repos under workspace folders with a bounded filesystem scan that skips managed worktrees and build/dependency folders. +- Prompt for the target repo when the workspace contains more than one Git repo, including nested repos. +- Keep the extension template copy in sync and cover nested repo targeting with a focused Active Agents regression. + +## Impact + +- Affects the VS Code Active Agents `Start agent` command only. +- Single-repo workspaces keep the previous no-picker flow. +- The scan is depth-limited to avoid walking large dependency/build trees. diff --git a/openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/specs/codex-task/spec.md b/openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/specs/codex-task/spec.md new file mode 100644 index 00000000..0d94a31a --- /dev/null +++ b/openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/specs/codex-task/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: VS Code Active Agents nested repo targeting +The VS Code Active Agents `Start agent` command SHALL allow users to target a nested Git repository discovered below the workspace root. + +#### Scenario: Workspace has nested storefront and backend repos +- **WHEN** the workspace contains nested Git repositories such as `apps/storefront` and `apps/backend` +- **AND** the user runs `Start agent` from the Active Agents view +- **THEN** the extension prompts for the target Git repo +- **AND** the spawned terminal uses the selected nested repo as its cwd +- **AND** the launcher command creates a Guardex agent branch/worktree for that nested repo instead of changing the visible nested repo's `main` checkout in place. + +#### Scenario: Workspace has one Git repo +- **WHEN** only one Git repo is available in the workspace +- **THEN** the extension keeps the existing direct start flow without an unnecessary picker. diff --git a/openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/tasks.md b/openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/tasks.md new file mode 100644 index 00000000..70abf6e6 --- /dev/null +++ b/openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/tasks.md @@ -0,0 +1,34 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-codex-codex-task-2026-05-11-15-20-2`; branch=`agent/codex/codex-task-2026-05-11-15-20-2`; scope=`VS Code Active Agents nested repo start picker`; action=`finish cleanup after verification`. +- Copy prompt: Continue `agent-codex-codex-task-2026-05-11-15-20-2` on branch `agent/codex/codex-task-2026-05-11-15-20-2`. Work inside the existing sandbox, review `openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/codex-task-2026-05-11-15-20-2 --base main --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-codex-task-2026-05-11-15-20-2`. +- [x] 1.2 Define normative requirements in `specs/codex-task/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes. +- [x] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. +- [x] 3.2 Run `openspec validate agent-codex-codex-task-2026-05-11-15-20-2 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index f149d772..276c23cc 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -1532,6 +1532,82 @@ function readPackageJson(repoRoot) { } } +function hasGitMarker(dirPath) { + return fs.existsSync(path.join(dirPath, '.git')); +} + +function shouldSkipRepoDiscoveryDir(dirName) { + return new Set([ + '.git', + '.omx', + '.omc', + 'node_modules', + 'dist', + 'build', + '.next', + ]).has(dirName); +} + +function discoverNestedGitRepoRoots(rootPath, maxDepth = 3) { + const discovered = []; + + function visit(dirPath, depth) { + if (depth > maxDepth) return; + let entries = []; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch (_error) { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory() || shouldSkipRepoDiscoveryDir(entry.name)) { + continue; + } + const childPath = path.join(dirPath, entry.name); + if (hasGitMarker(childPath)) { + discovered.push(childPath); + continue; + } + visit(childPath, depth + 1); + } + } + + visit(rootPath, 1); + return discovered; +} + +function discoverWorkspaceRepoRoots() { + const workspaceFolders = vscode.workspace.workspaceFolders || []; + const seen = new Set(); + const roots = []; + + for (const folder of workspaceFolders) { + const rootPath = folder?.uri?.fsPath; + if (!rootPath || seen.has(rootPath)) { + continue; + } + seen.add(rootPath); + roots.push(rootPath); + + for (const nestedRoot of discoverNestedGitRepoRoots(rootPath)) { + if (seen.has(nestedRoot)) { + continue; + } + seen.add(nestedRoot); + roots.push(nestedRoot); + } + } + + return roots; +} + +function repoPickLabel(repoRoot) { + const parent = path.basename(path.dirname(repoRoot)); + const base = path.basename(repoRoot); + return parent ? `${parent}/${base}` : base; +} + function resolveStartAgentCommand(repoRoot, details) { const taskArg = shellQuote(details.taskName); const agentArg = shellQuote(details.agentName); @@ -2822,23 +2898,23 @@ function resolveSessionActivityIconId(activityKind) { } async function pickRepoRoot() { - const workspaceFolders = vscode.workspace.workspaceFolders || []; - if (workspaceFolders.length === 0) { + const repoRoots = discoverWorkspaceRepoRoots(); + if (repoRoots.length === 0) { vscode.window.showInformationMessage?.('Open a Guardex workspace folder to start an agent.'); return null; } - if (workspaceFolders.length === 1) { - return workspaceFolders[0].uri.fsPath; + if (repoRoots.length === 1) { + return repoRoots[0]; } - const picks = workspaceFolders.map((folder) => ({ - label: path.basename(folder.uri.fsPath), - description: folder.uri.fsPath, - repoRoot: folder.uri.fsPath, + const picks = repoRoots.map((repoRoot) => ({ + label: repoPickLabel(repoRoot), + description: repoRoot, + repoRoot, })); const selection = await vscode.window.showQuickPick?.(picks, { - placeHolder: 'Select the Guardex repo where the Start agent launcher should run.', + placeHolder: 'Select the Git repo where the Start agent launcher should run.', }); return selection?.repoRoot || null; } diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index a5a8a836..e8e6d36d 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -1861,6 +1861,48 @@ test('active-agents extension startAgent command falls back to gx branch start w } }); +test('active-agents extension startAgent can target a nested Git repo', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-nested-')); + const storefrontRoot = path.join(tempRoot, 'apps', 'storefront'); + const backendRoot = path.join(tempRoot, 'apps', 'backend'); + fs.mkdirSync(path.join(storefrontRoot, '.git'), { recursive: true }); + fs.mkdirSync(path.join(backendRoot, '.git'), { recursive: true }); + const { registrations, vscode } = createMockVscode(tempRoot); + registrations.quickPickResponse = { + label: 'apps/storefront', + description: storefrontRoot, + repoRoot: storefrontRoot, + }; + registrations.inputResponses.push('nested task', 'codex'); + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + + await registrations.commands.get('gitguardex.activeAgents.startAgent')(); + + assert.equal(registrations.quickPickCalls.length, 1); + assert.deepEqual( + registrations.quickPickCalls[0].items.map((item) => item.repoRoot), + [tempRoot, backendRoot, storefrontRoot], + ); + assert.equal(registrations.terminals.length, 1); + assert.deepEqual(registrations.terminals[0].options, { + name: `GitGuardex: ${path.basename(storefrontRoot)}`, + cwd: storefrontRoot, + }); + assert.deepEqual(registrations.terminals[0].sentTexts, [ + { + text: "gx branch start 'nested task' 'codex'", + addNewLine: true, + }, + ]); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension groups live sessions under a repo node', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-live-view-')); const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index f149d772..276c23cc 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -1532,6 +1532,82 @@ function readPackageJson(repoRoot) { } } +function hasGitMarker(dirPath) { + return fs.existsSync(path.join(dirPath, '.git')); +} + +function shouldSkipRepoDiscoveryDir(dirName) { + return new Set([ + '.git', + '.omx', + '.omc', + 'node_modules', + 'dist', + 'build', + '.next', + ]).has(dirName); +} + +function discoverNestedGitRepoRoots(rootPath, maxDepth = 3) { + const discovered = []; + + function visit(dirPath, depth) { + if (depth > maxDepth) return; + let entries = []; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch (_error) { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory() || shouldSkipRepoDiscoveryDir(entry.name)) { + continue; + } + const childPath = path.join(dirPath, entry.name); + if (hasGitMarker(childPath)) { + discovered.push(childPath); + continue; + } + visit(childPath, depth + 1); + } + } + + visit(rootPath, 1); + return discovered; +} + +function discoverWorkspaceRepoRoots() { + const workspaceFolders = vscode.workspace.workspaceFolders || []; + const seen = new Set(); + const roots = []; + + for (const folder of workspaceFolders) { + const rootPath = folder?.uri?.fsPath; + if (!rootPath || seen.has(rootPath)) { + continue; + } + seen.add(rootPath); + roots.push(rootPath); + + for (const nestedRoot of discoverNestedGitRepoRoots(rootPath)) { + if (seen.has(nestedRoot)) { + continue; + } + seen.add(nestedRoot); + roots.push(nestedRoot); + } + } + + return roots; +} + +function repoPickLabel(repoRoot) { + const parent = path.basename(path.dirname(repoRoot)); + const base = path.basename(repoRoot); + return parent ? `${parent}/${base}` : base; +} + function resolveStartAgentCommand(repoRoot, details) { const taskArg = shellQuote(details.taskName); const agentArg = shellQuote(details.agentName); @@ -2822,23 +2898,23 @@ function resolveSessionActivityIconId(activityKind) { } async function pickRepoRoot() { - const workspaceFolders = vscode.workspace.workspaceFolders || []; - if (workspaceFolders.length === 0) { + const repoRoots = discoverWorkspaceRepoRoots(); + if (repoRoots.length === 0) { vscode.window.showInformationMessage?.('Open a Guardex workspace folder to start an agent.'); return null; } - if (workspaceFolders.length === 1) { - return workspaceFolders[0].uri.fsPath; + if (repoRoots.length === 1) { + return repoRoots[0]; } - const picks = workspaceFolders.map((folder) => ({ - label: path.basename(folder.uri.fsPath), - description: folder.uri.fsPath, - repoRoot: folder.uri.fsPath, + const picks = repoRoots.map((repoRoot) => ({ + label: repoPickLabel(repoRoot), + description: repoRoot, + repoRoot, })); const selection = await vscode.window.showQuickPick?.(picks, { - placeHolder: 'Select the Guardex repo where the Start agent launcher should run.', + placeHolder: 'Select the Git repo where the Start agent launcher should run.', }); return selection?.repoRoot || null; }