From db29a3280571a0eab51b483a0afd25a8cfa6ad8e Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 11 May 2026 15:55:23 +0200 Subject: [PATCH] Let Active Agents target nested repos VS Code workspaces can expose a parent repo plus nested storefront/backend repos, and the Start agent command previously defaulted to the workspace root. That made it too easy to start the wrong lane when the user wanted a nested repo branch/worktree while keeping that nested repo's visible main checkout stable. This adds bounded nested Git repo discovery to the Active Agents picker, keeps the installed template copy in sync, and adds a regression proving the terminal cwd follows the selected nested repo. Constraint: VS Code Source Control commonly exposes nested repos in one workspace. Rejected: Change branch-start semantics globally | the CLI already creates isolated worktrees when invoked from the correct repo root. Confidence: high Scope-risk: narrow Directive: Keep the extension source and templates/vscode/guardex-active-agents/extension.js in sync for install parity. Tested: node --test test/vscode-active-agents-session-state.test.js Tested: node --check vscode/guardex-active-agents/extension.js Tested: openspec validate agent-codex-codex-task-2026-05-11-15-20-2 --type change --strict Tested: openspec validate --specs Not-tested: Full npm test; metadata.test.js has unrelated release-lane failures for Cosign v4.1.2 vs expected v4.1.1 and missing README v7.0.43 notes. Co-authored-by: OmX --- .../.openspec.yaml | 2 + .../proposal.md | 17 ++++ .../specs/codex-task/spec.md | 15 +++ .../tasks.md | 34 +++++++ .../vscode/guardex-active-agents/extension.js | 94 +++++++++++++++++-- ...vscode-active-agents-session-state.test.js | 42 +++++++++ vscode/guardex-active-agents/extension.js | 94 +++++++++++++++++-- 7 files changed, 280 insertions(+), 18 deletions(-) create mode 100644 openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/.openspec.yaml create mode 100644 openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/proposal.md create mode 100644 openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/specs/codex-task/spec.md create mode 100644 openspec/changes/agent-codex-codex-task-2026-05-11-15-20-2/tasks.md 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; }