From 56428968784d4e454cc6b80110daa56197cf82ab Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 11 May 2026 16:08:17 +0200 Subject: [PATCH] Target nested SCM repos from Active Agents VS Code can expose the outer monorepo plus nested storefront/backend repositories at the same time, so the Start Agent command now infers the active repo and launches through gx with an explicit target. The ambiguous picker also shows branch and dirty-state cues before launching. Constraint: Start Agent must not switch or mutate the outer checkout when the user is working inside a nested repository. Rejected: Keep scripts/codex-agent.sh and npm run agent:codex launcher fallbacks | they do not provide an explicit nested target and preserve the wrong-root failure mode. Confidence: high Scope-risk: narrow Directive: Keep source and template extension files in parity when changing bundled Active Agents behavior. Tested: node --test test/vscode-active-agents-session-state.test.js Tested: node --check vscode/guardex-active-agents/extension.js && node --check templates/vscode/guardex-active-agents/extension.js && diff -u vscode/guardex-active-agents/extension.js templates/vscode/guardex-active-agents/extension.js Tested: openspec validate agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59 --type change --strict Tested: openspec validate --specs Not-tested: Manual VS Code SCM selection against a real multi-root window. Co-authored-by: OmX --- .../.openspec.yaml | 2 + .../proposal.md | 14 ++++ .../spec.md | 20 ++++++ .../tasks.md | 36 ++++++++++ .../vscode/guardex-active-agents/extension.js | 72 ++++++++++++++----- ...vscode-active-agents-session-state.test.js | 62 +++++++++++++--- vscode/guardex-active-agents/extension.js | 72 ++++++++++++++----- 7 files changed, 230 insertions(+), 48 deletions(-) create mode 100644 openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/.openspec.yaml create mode 100644 openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/proposal.md create mode 100644 openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/specs/active-agents-selected-scm-nested-start/spec.md create mode 100644 openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/tasks.md diff --git a/openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/.openspec.yaml b/openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/.openspec.yaml new file mode 100644 index 0000000..81cd71f --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-11 diff --git a/openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/proposal.md b/openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/proposal.md new file mode 100644 index 0000000..db313ca --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/proposal.md @@ -0,0 +1,14 @@ +## Why + +VS Code Source Control shows the primary repo, nested storefront repo, and nested backend repo as separate SCM roots. The Active Agents Start Agent command must launch in the repo the user is actually working in, instead of silently falling back to the outer workspace root. + +## What Changes + +- Prefer the active SCM/editor repo root when multiple workspace Git repos are discovered. +- Launch agents through the canonical `gx agents start --target ` surface so nested repos do not require changing the outer checkout. +- Add branch and dirty-state cues to the repo picker when the active repo cannot be inferred. +- Cover the selected nested repo and active-editor repo paths with focused VS Code extension tests. + +## Impact + +The change is limited to the bundled Active Agents VS Code extension and its install template. Existing tree rendering and session inspection behavior are unchanged. diff --git a/openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/specs/active-agents-selected-scm-nested-start/spec.md b/openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/specs/active-agents-selected-scm-nested-start/spec.md new file mode 100644 index 0000000..0b2d024 --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/specs/active-agents-selected-scm-nested-start/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Start Agent targets the selected workspace repository +The Active Agents VS Code extension SHALL start new agents in the selected workspace Git repository rather than assuming the outer workspace root. + +#### Scenario: Active editor belongs to a nested Git repository +- **GIVEN** a workspace contains an outer folder and nested Git repositories +- **AND** the active editor is inside one nested Git repository +- **WHEN** the user runs `gitguardex.activeAgents.startAgent` +- **THEN** the extension launches the terminal in that nested repository +- **AND** the command uses `gx agents start --target ` +- **AND** no repository picker is shown. + +#### Scenario: Multiple repositories remain ambiguous +- **GIVEN** a workspace contains multiple Git repositories +- **AND** no active SCM/editor repository can be inferred +- **WHEN** the user runs `gitguardex.activeAgents.startAgent` +- **THEN** the extension shows a repository picker +- **AND** each pick shows the repository path plus branch and dirty-state cues +- **AND** the selected repository is passed to `gx agents start --target `. diff --git a/openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/tasks.md b/openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/tasks.md new file mode 100644 index 0000000..d21fd13 --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59/tasks.md @@ -0,0 +1,36 @@ +## 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-active-agents-selected-scm-nested-start-2026-05-11-15-59`; branch=`agent/codex/active-agents-selected-scm-nested-start-2026-05-11-15-59`; scope=`VS Code Active Agents Start Agent nested SCM targeting`; action=`finish cleanup after verification if resumed`. +- Copy prompt: Continue `agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59` on branch `agent/codex/active-agents-selected-scm-nested-start-2026-05-11-15-59`. Work inside the existing sandbox, review this tasks file, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/active-agents-selected-scm-nested-start-2026-05-11-15-59 --base main --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59`. +- [x] 1.2 Define normative requirements in `specs/active-agents-selected-scm-nested-start/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. + - `node --test test/vscode-active-agents-session-state.test.js` -> pass, 61/61. + - `node --check vscode/guardex-active-agents/extension.js && node --check templates/vscode/guardex-active-agents/extension.js && diff -u vscode/guardex-active-agents/extension.js templates/vscode/guardex-active-agents/extension.js` -> pass. +- [x] 3.2 Run `openspec validate agent-codex-active-agents-selected-scm-nested-start-2026-05-11-15-59 --type change --strict` -> valid. +- [x] 3.3 Run `openspec validate --specs` -> no items found to validate. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [x] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/active-agents-selected-scm-nested-start-2026-05-11-15-59 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [x] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [x] 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 276c23c..6154f83 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -1523,14 +1523,6 @@ function shellQuote(value) { return `'${normalized.replace(/'/g, "'\"'\"'")}'`; } -function readPackageJson(repoRoot) { - const packageJsonPath = path.join(repoRoot, 'package.json'); - try { - return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - } catch (_error) { - return null; - } -} function hasGitMarker(dirPath) { return fs.existsSync(path.join(dirPath, '.git')); @@ -1608,20 +1600,56 @@ function repoPickLabel(repoRoot) { return parent ? `${parent}/${base}` : base; } -function resolveStartAgentCommand(repoRoot, details) { - const taskArg = shellQuote(details.taskName); - const agentArg = shellQuote(details.agentName); - const localCodexAgentPath = path.join(repoRoot, 'scripts', 'codex-agent.sh'); - if (fs.existsSync(localCodexAgentPath)) { - return `bash ./scripts/codex-agent.sh ${taskArg} ${agentArg}`; +function readGitOutput(repoRoot, args) { + try { + return cp.execFileSync('git', ['-C', repoRoot, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return null; } +} + +function repoGitSummary(repoRoot) { + const branch = readGitOutput(repoRoot, ['branch', '--show-current']) || 'unknown'; + const status = readGitOutput(repoRoot, ['status', '--porcelain']); + return { + branch, + dirty: status === null ? 'unknown' : status.length > 0 ? 'dirty' : 'clean', + }; +} + +function repoPickDescription(repoRoot) { + const summary = repoGitSummary(repoRoot); + return `${summary.branch} · ${summary.dirty}`; +} - const agentCodexScript = readPackageJson(repoRoot)?.scripts?.['agent:codex']; - if (typeof agentCodexScript === 'string' && agentCodexScript.trim().length > 0) { - return `npm run agent:codex -- ${taskArg} ${agentArg}`; +function findRepoRootForPath(repoRoots, candidatePath) { + const normalizedCandidatePath = normalizeAbsolutePath(candidatePath); + if (!normalizedCandidatePath) { + return null; } - return `gx branch start ${taskArg} ${agentArg}`; + return repoRoots + .filter((repoRoot) => isPathWithin(repoRoot, normalizedCandidatePath)) + .sort((left, right) => right.length - left.length)[0] || null; +} + +function activeScmRootPath() { + const sourceControl = vscode.scm?.activeSourceControl; + return sourceControl?.rootUri?.fsPath || sourceControl?.rootUri?.path || ''; +} + +function preferredRepoRoot(repoRoots) { + return findRepoRootForPath(repoRoots, activeScmRootPath()) + || findRepoRootForPath(repoRoots, vscode.window.activeTextEditor?.document?.uri?.fsPath); +} + +function resolveStartAgentCommand(repoRoot, details) { + const taskArg = shellQuote(details.taskName); + const agentArg = shellQuote(details.agentName); + return `gx agents start ${taskArg} --agent ${agentArg} --target ${shellQuote(repoRoot)}`; } function sessionTaskLabel(session) { @@ -2908,9 +2936,15 @@ async function pickRepoRoot() { return repoRoots[0]; } + const selectedRepoRoot = preferredRepoRoot(repoRoots); + if (selectedRepoRoot) { + return selectedRepoRoot; + } + const picks = repoRoots.map((repoRoot) => ({ label: repoPickLabel(repoRoot), - description: repoRoot, + description: repoPickDescription(repoRoot), + detail: repoRoot, repoRoot, })); const selection = await vscode.window.showQuickPick?.(picks, { diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index e8e6d36..f074c5d 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -1799,10 +1799,8 @@ test('active-agents extension self-heals managed repo-scan ignores on activation } }); -test('active-agents extension startAgent command prefers the Guardex launcher in a terminal', async () => { +test('active-agents extension startAgent command uses gx agents start with a target repo', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-')); - fs.mkdirSync(path.join(tempRoot, 'scripts'), { recursive: true }); - fs.writeFileSync(path.join(tempRoot, 'scripts', 'codex-agent.sh'), '#!/usr/bin/env bash\n', 'utf8'); const { registrations, vscode } = createMockVscode(tempRoot); registrations.inputResponses.push('demo task', 'codex'); const extension = loadExtensionWithMockVscode(vscode); @@ -1820,7 +1818,7 @@ test('active-agents extension startAgent command prefers the Guardex launcher in assert.equal(registrations.terminals[0].shown, true); assert.deepEqual(registrations.terminals[0].sentTexts, [ { - text: "bash ./scripts/codex-agent.sh 'demo task' 'codex'", + text: `gx agents start 'demo task' --agent 'codex' --target '${tempRoot}'`, addNewLine: true, }, ]); @@ -1831,7 +1829,7 @@ test('active-agents extension startAgent command prefers the Guardex launcher in } }); -test('active-agents extension startAgent command falls back to gx branch start without a Guardex launcher', async () => { +test('active-agents extension startAgent command uses gx agents start for plain repos', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-fallback-')); const { registrations, vscode } = createMockVscode(tempRoot); registrations.inputResponses.push('demo task', 'codex'); @@ -1850,7 +1848,7 @@ test('active-agents extension startAgent command falls back to gx branch start w assert.equal(registrations.terminals[0].shown, true); assert.deepEqual(registrations.terminals[0].sentTexts, [ { - text: "gx branch start 'demo task' 'codex'", + text: `gx agents start 'demo task' --agent 'codex' --target '${tempRoot}'`, addNewLine: true, }, ]); @@ -1865,12 +1863,13 @@ 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 }); + initGitRepo(storefrontRoot); + initGitRepo(backendRoot); + fs.writeFileSync(path.join(storefrontRoot, 'dirty.txt'), 'changed\n', 'utf8'); const { registrations, vscode } = createMockVscode(tempRoot); registrations.quickPickResponse = { label: 'apps/storefront', - description: storefrontRoot, + description: 'main · dirty', repoRoot: storefrontRoot, }; registrations.inputResponses.push('nested task', 'codex'); @@ -1886,6 +1885,49 @@ test('active-agents extension startAgent can target a nested Git repo', async () registrations.quickPickCalls[0].items.map((item) => item.repoRoot), [tempRoot, backendRoot, storefrontRoot], ); + assert.deepEqual( + registrations.quickPickCalls[0].items.map((item) => item.detail), + [tempRoot, backendRoot, storefrontRoot], + ); + const storefrontPick = registrations.quickPickCalls[0].items.find((item) => item.repoRoot === storefrontRoot); + assert.ok(storefrontPick.description.endsWith(' · dirty')); + 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 agents start 'nested task' --agent 'codex' --target '${storefrontRoot}'`, + addNewLine: true, + }, + ]); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension startAgent defaults to the active editor nested repo', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-active-editor-')); + const storefrontRoot = path.join(tempRoot, 'apps', 'storefront'); + const backendRoot = path.join(tempRoot, 'apps', 'backend'); + const editorPath = path.join(storefrontRoot, 'src', 'home.tsx'); + initGitRepo(storefrontRoot); + initGitRepo(backendRoot); + fs.mkdirSync(path.dirname(editorPath), { recursive: true }); + fs.writeFileSync(editorPath, 'export {};\n', 'utf8'); + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.window.activeTextEditor = { document: { uri: vscode.Uri.file(editorPath) } }; + registrations.inputResponses.push('active editor task', 'codex'); + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + + await registrations.commands.get('gitguardex.activeAgents.startAgent')(); + + assert.deepEqual(registrations.quickPickCalls, []); assert.equal(registrations.terminals.length, 1); assert.deepEqual(registrations.terminals[0].options, { name: `GitGuardex: ${path.basename(storefrontRoot)}`, @@ -1893,7 +1935,7 @@ test('active-agents extension startAgent can target a nested Git repo', async () }); assert.deepEqual(registrations.terminals[0].sentTexts, [ { - text: "gx branch start 'nested task' 'codex'", + text: `gx agents start 'active editor task' --agent 'codex' --target '${storefrontRoot}'`, addNewLine: true, }, ]); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 276c23c..6154f83 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -1523,14 +1523,6 @@ function shellQuote(value) { return `'${normalized.replace(/'/g, "'\"'\"'")}'`; } -function readPackageJson(repoRoot) { - const packageJsonPath = path.join(repoRoot, 'package.json'); - try { - return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - } catch (_error) { - return null; - } -} function hasGitMarker(dirPath) { return fs.existsSync(path.join(dirPath, '.git')); @@ -1608,20 +1600,56 @@ function repoPickLabel(repoRoot) { return parent ? `${parent}/${base}` : base; } -function resolveStartAgentCommand(repoRoot, details) { - const taskArg = shellQuote(details.taskName); - const agentArg = shellQuote(details.agentName); - const localCodexAgentPath = path.join(repoRoot, 'scripts', 'codex-agent.sh'); - if (fs.existsSync(localCodexAgentPath)) { - return `bash ./scripts/codex-agent.sh ${taskArg} ${agentArg}`; +function readGitOutput(repoRoot, args) { + try { + return cp.execFileSync('git', ['-C', repoRoot, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return null; } +} + +function repoGitSummary(repoRoot) { + const branch = readGitOutput(repoRoot, ['branch', '--show-current']) || 'unknown'; + const status = readGitOutput(repoRoot, ['status', '--porcelain']); + return { + branch, + dirty: status === null ? 'unknown' : status.length > 0 ? 'dirty' : 'clean', + }; +} + +function repoPickDescription(repoRoot) { + const summary = repoGitSummary(repoRoot); + return `${summary.branch} · ${summary.dirty}`; +} - const agentCodexScript = readPackageJson(repoRoot)?.scripts?.['agent:codex']; - if (typeof agentCodexScript === 'string' && agentCodexScript.trim().length > 0) { - return `npm run agent:codex -- ${taskArg} ${agentArg}`; +function findRepoRootForPath(repoRoots, candidatePath) { + const normalizedCandidatePath = normalizeAbsolutePath(candidatePath); + if (!normalizedCandidatePath) { + return null; } - return `gx branch start ${taskArg} ${agentArg}`; + return repoRoots + .filter((repoRoot) => isPathWithin(repoRoot, normalizedCandidatePath)) + .sort((left, right) => right.length - left.length)[0] || null; +} + +function activeScmRootPath() { + const sourceControl = vscode.scm?.activeSourceControl; + return sourceControl?.rootUri?.fsPath || sourceControl?.rootUri?.path || ''; +} + +function preferredRepoRoot(repoRoots) { + return findRepoRootForPath(repoRoots, activeScmRootPath()) + || findRepoRootForPath(repoRoots, vscode.window.activeTextEditor?.document?.uri?.fsPath); +} + +function resolveStartAgentCommand(repoRoot, details) { + const taskArg = shellQuote(details.taskName); + const agentArg = shellQuote(details.agentName); + return `gx agents start ${taskArg} --agent ${agentArg} --target ${shellQuote(repoRoot)}`; } function sessionTaskLabel(session) { @@ -2908,9 +2936,15 @@ async function pickRepoRoot() { return repoRoots[0]; } + const selectedRepoRoot = preferredRepoRoot(repoRoots); + if (selectedRepoRoot) { + return selectedRepoRoot; + } + const picks = repoRoots.map((repoRoot) => ({ label: repoPickLabel(repoRoot), - description: repoRoot, + description: repoPickDescription(repoRoot), + detail: repoRoot, repoRoot, })); const selection = await vscode.window.showQuickPick?.(picks, {