From 677afb8b55a1651942cc0dacb980fb37cc72a0e9 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 29 Apr 2026 21:32:49 +0200 Subject: [PATCH] Preview agent starts without side effects The agents start command needed a no-mutation planning path before full launch support. This adds a dry-run-only task path that validates the agent id, derives the task slug, branch, worktree path, and launch command, and leaves the existing no-task review/cleanup bot start behavior untouched. Constraint: Only dry-run behavior is implemented in this slice Rejected: Reusing the bot-start state path for task previews | it would read and write unrelated repo agent metadata Confidence: high Scope-risk: narrow Directive: Do not enable non-dry-run task launching until branch/worktree/session metadata writes are implemented and tested as a separate path Tested: node --test test/agents-start-dry-run.test.js test/cli-args-dispatch.test.js Tested: node --test test/agents.test.js Tested: openspec validate agent-codex-agents-start-dry-run-2026-04-29-21-23 --type change --strict Tested: openspec validate --specs Not-tested: real agent launch, intentionally out of scope for dry-run --- .../.openspec.yaml | 2 + .../proposal.md | 12 +++ .../specs/agents-start/spec.md | 13 +++ .../tasks.md | 34 +++++++ src/agents/launch.js | 2 +- src/agents/registry.js | 25 +++++ src/agents/start.js | 94 +++++++++++++++++++ src/cli/args.js | 39 ++++++++ src/cli/main.js | 7 ++ test/agents-start-dry-run.test.js | 72 ++++++++++++++ test/cli-args-dispatch.test.js | 19 ++++ 11 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/.openspec.yaml create mode 100644 openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/proposal.md create mode 100644 openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/specs/agents-start/spec.md create mode 100644 openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/tasks.md create mode 100644 src/agents/start.js create mode 100644 test/agents-start-dry-run.test.js diff --git a/openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/.openspec.yaml b/openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/.openspec.yaml new file mode 100644 index 00000000..5f23b852 --- /dev/null +++ b/openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-29 diff --git a/openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/proposal.md b/openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/proposal.md new file mode 100644 index 00000000..5079a946 --- /dev/null +++ b/openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/proposal.md @@ -0,0 +1,12 @@ +## Why + +- Users need to preview `gx agents start` branch, worktree, and launch details before allowing Guardex to mutate the repo or start an agent process. + +## What Changes + +- Add a dry-run-only task start path for `gx agents start --agent [--base ] --dry-run`. +- Validate known agent ids, derive the task slug, branch, worktree path, and launch command, then print the plan without creating runtime state. + +## Impact + +- Scope is limited to dry-run behavior. Existing no-task `gx agents start` review/cleanup bot behavior stays unchanged. diff --git a/openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/specs/agents-start/spec.md b/openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/specs/agents-start/spec.md new file mode 100644 index 00000000..85f7a7bd --- /dev/null +++ b/openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/specs/agents-start/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Dry-run agent start planning +`gx agents start --dry-run` SHALL print the planned agent start details without mutating repository or session state. + +#### Scenario: Previewing a Codex agent launch +- **WHEN** a user runs `gx agents start "fix auth tests" --agent codex --base main --dry-run` +- **THEN** Guardex prints the inferred task slug, planned `agent/codex/...` branch, planned `.omx/agent-worktrees/...` worktree path, and planned Codex launch command +- **AND** it does not create the branch, create the worktree, write session metadata, or launch an agent process. + +#### Scenario: Rejecting an unknown agent id +- **WHEN** a user runs `gx agents start "update docs" --agent bogus --dry-run` +- **THEN** Guardex rejects the command before planning or mutation. diff --git a/openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/tasks.md b/openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/tasks.md new file mode 100644 index 00000000..4ebff7a4 --- /dev/null +++ b/openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/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-agents-start-dry-run-2026-04-29-21-23`; branch=`agent/codex/agents-start-dry-run-2026-04-29-21-23`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-codex-agents-start-dry-run-2026-04-29-21-23` on branch `agent/codex/agents-start-dry-run-2026-04-29-21-23`. Work inside the existing sandbox, review `openspec/changes/agent-codex-agents-start-dry-run-2026-04-29-21-23/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/agents-start-dry-run-2026-04-29-21-23 --base main --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-agents-start-dry-run-2026-04-29-21-23`. +- [x] 1.2 Define normative requirements in `specs/agents-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. +- [x] 3.2 Run `openspec validate agent-codex-agents-start-dry-run-2026-04-29-21-23 --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/codex/agents-start-dry-run-2026-04-29-21-23 --base main --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/src/agents/launch.js b/src/agents/launch.js index 7f9008b1..b2f93c6a 100644 --- a/src/agents/launch.js +++ b/src/agents/launch.js @@ -50,7 +50,7 @@ const FALLBACK_AGENTS = { }, }; -const SUPPORTED_PROMPT_MODES = new Set(['positional', 'option', 'stdin']); +const SUPPORTED_PROMPT_MODES = new Set(['positional', 'option', 'stdin', 'argument']); function shellQuote(value) { const stringValue = String(value); diff --git a/src/agents/registry.js b/src/agents/registry.js index eab90b4b..57fcdcab 100644 --- a/src/agents/registry.js +++ b/src/agents/registry.js @@ -9,6 +9,7 @@ const AGENT_DEFINITIONS = [ defaultEnabled: true, promptMode: 'argument', resumeCommandTemplate: 'codex resume {sessionId}', + worktreeRoot: '.omx/agent-worktrees', }, { id: 'claude', @@ -20,6 +21,7 @@ const AGENT_DEFINITIONS = [ defaultEnabled: true, promptMode: 'argument', resumeCommandTemplate: 'claude --resume {sessionId}', + worktreeRoot: '.omc/agent-worktrees', }, { id: 'opencode', @@ -30,6 +32,7 @@ const AGENT_DEFINITIONS = [ detectCommand: 'opencode --version', defaultEnabled: true, promptMode: 'argument', + worktreeRoot: '.omx/agent-worktrees', }, { id: 'cursor', @@ -40,6 +43,7 @@ const AGENT_DEFINITIONS = [ detectCommand: 'cursor-agent --version', defaultEnabled: true, promptMode: 'argument', + worktreeRoot: '.omx/agent-worktrees', }, { id: 'gemini', @@ -50,6 +54,7 @@ const AGENT_DEFINITIONS = [ detectCommand: 'gemini --version', defaultEnabled: true, promptMode: 'argument', + worktreeRoot: '.omx/agent-worktrees', }, ]; @@ -82,6 +87,10 @@ const AGENT_REGISTRY = Object.freeze( ), ); +function normalizeAgentId(rawAgentId) { + return String(rawAgentId || 'codex').trim().toLowerCase(); +} + function isAgentId(value) { return Object.prototype.hasOwnProperty.call(AGENT_REGISTRY, value); } @@ -98,6 +107,19 @@ function getDefaultEnabledAgents() { return getAgentDefinitions().filter((definition) => definition.defaultEnabled); } +function listAgentIds() { + return [...AGENT_IDS]; +} + +function resolveAgent(rawAgentId) { + const agentId = normalizeAgentId(rawAgentId); + const agent = getAgentDefinition(agentId); + if (!agent) { + throw new Error(`Unknown agent id: ${rawAgentId || '(empty)'} (expected one of: ${listAgentIds().join(', ')})`); + } + return agent; +} + module.exports = { AGENT_IDS, AGENT_REGISTRY, @@ -105,4 +127,7 @@ module.exports = { getAgentDefinition, getAgentDefinitions, getDefaultEnabledAgents, + listAgentIds, + normalizeAgentId, + resolveAgent, }; diff --git a/src/agents/start.js b/src/agents/start.js new file mode 100644 index 00000000..8b1b1068 --- /dev/null +++ b/src/agents/start.js @@ -0,0 +1,94 @@ +const { path } = require('../context'); +const { currentBranchName } = require('../git'); +const { buildAgentLaunchCommand } = require('./launch'); +const { resolveAgent } = require('./registry'); + +function sanitizeSlug(value, fallback = 'task') { + const slug = String(value || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+/, '') + .replace(/-+$/, '') + .replace(/-{2,}/g, '-'); + return slug || fallback; +} + +function normalizePositiveInt(value, fallback) { + const parsed = Number.parseInt(String(value || ''), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function shortenSlug(slug, maxLength) { + if (slug.length <= maxLength) return slug; + const shortened = slug.slice(0, maxLength).replace(/-+$/, ''); + return shortened || slug.slice(0, maxLength); +} + +function branchTimestamp(env = process.env, now = new Date()) { + if (env.GUARDEX_BRANCH_TIMESTAMP) { + return env.GUARDEX_BRANCH_TIMESTAMP; + } + const pad = (value) => String(value).padStart(2, '0'); + return [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + pad(now.getHours()), + pad(now.getMinutes()), + ].join('-'); +} + +function worktreeLeaf(repoRoot, branchName) { + const repoPrefix = path.basename(repoRoot); + const withoutPrefix = branchName.startsWith('agent/') ? branchName.slice('agent/'.length) : branchName; + return `${repoPrefix}__${withoutPrefix.replace(/\//g, '__')}`; +} + +function buildStartPlan(options, repoRoot, env = process.env) { + const task = String(options.task || '').trim(); + if (!task) { + throw new Error('gx agents start --dry-run requires a task'); + } + const agent = resolveAgent(options.agent || 'codex'); + const taskSlug = sanitizeSlug(task, 'task'); + const taskSlugMax = normalizePositiveInt(env.GUARDEX_BRANCH_TASK_SLUG_MAX, 40); + const branchDescriptor = `${shortenSlug(taskSlug, taskSlugMax)}-${branchTimestamp(env)}`; + const branchName = `agent/${agent.id}/${branchDescriptor}`; + const worktreeRoot = agent.worktreeRoot || (agent.id === 'claude' ? '.omc/agent-worktrees' : '.omx/agent-worktrees'); + const worktreePath = path.join(repoRoot, worktreeRoot, worktreeLeaf(repoRoot, branchName)); + const base = options.base || currentBranchName(repoRoot) || 'main'; + return { + task, + taskSlug, + agent, + base, + branchName, + worktreePath, + launchCommand: buildAgentLaunchCommand({ agentId: agent.id, prompt: task, worktreePath }), + }; +} + +function renderDryRunPlan(plan) { + return [ + '[gitguardex] Agents start dry-run:', + ` task: ${plan.task}`, + ` agent: ${plan.agent.id}`, + ` base: ${plan.base}`, + ` task slug: ${plan.taskSlug}`, + ` branch: ${plan.branchName}`, + ` worktree: ${plan.worktreePath}`, + ` launch: ${plan.launchCommand}`, + '[gitguardex] No branch, worktree, session metadata, or agent process was created.', + ].join('\n'); +} + +function dryRunStart(options, repoRoot) { + return renderDryRunPlan(buildStartPlan(options, repoRoot)); +} + +module.exports = { + buildStartPlan, + dryRunStart, + renderDryRunPlan, + sanitizeSlug, +}; diff --git a/src/cli/args.js b/src/cli/args.js index e0529801..c3163e31 100644 --- a/src/cli/args.js +++ b/src/cli/args.js @@ -268,6 +268,10 @@ function parseAgentsArgs(rawArgs) { const options = { target: parsed.target, subcommand, + task: '', + agent: '', + base: '', + dryRun: false, reviewIntervalSeconds: 30, cleanupIntervalSeconds: 60, idleMinutes: DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES, @@ -328,6 +332,32 @@ function parseAgentsArgs(rawArgs) { index += 1; continue; } + if (arg === '--agent') { + const next = rest[index + 1]; + if (!next || next.startsWith('-')) { + throw new Error('--agent requires an agent id'); + } + options.agent = next; + index += 1; + continue; + } + if (arg === '--base') { + const next = rest[index + 1]; + if (!next || next.startsWith('-')) { + throw new Error('--base requires a branch name'); + } + options.base = next; + index += 1; + continue; + } + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + if (!arg.startsWith('-') && options.subcommand === 'start' && !options.task) { + options.task = arg; + continue; + } throw new Error(`Unknown option: ${arg}`); } @@ -337,6 +367,15 @@ function parseAgentsArgs(rawArgs) { if (options.pid !== null && options.subcommand !== 'stop') { throw new Error('--pid is only supported with `gx agents stop`'); } + if ((options.task || options.agent || options.base || options.dryRun) && options.subcommand !== 'start') { + throw new Error('--task, --agent, --base, and --dry-run are only supported with `gx agents start`'); + } + if (options.task && !options.dryRun) { + throw new Error('gx agents start is currently supported only with --dry-run'); + } + if (options.dryRun && !options.task) { + throw new Error('gx agents start --dry-run requires a task'); + } return options; } diff --git a/src/cli/main.js b/src/cli/main.js index 12e1224e..525cfc7a 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -7,6 +7,7 @@ const finishCommands = require('../finish'); const doctorModule = require('../doctor'); const sessionSeverityReport = require('../report/session-severity'); const cockpitModule = require('../cockpit'); +const agentsStart = require('../agents/start'); const { fs, path, @@ -2647,6 +2648,12 @@ function agents(rawArgs) { const statePath = agentsStatePathForRepo(repoRoot); if (options.subcommand === 'start') { + if (options.dryRun) { + console.log(agentsStart.dryRunStart(options, repoRoot)); + process.exitCode = 0; + return; + } + const existingState = readAgentsState(repoRoot); const existingReviewPid = Number.parseInt(String(existingState?.review?.pid || ''), 10); const existingCleanupPid = Number.parseInt(String(existingState?.cleanup?.pid || ''), 10); diff --git a/test/agents-start-dry-run.test.js b/test/agents-start-dry-run.test.js new file mode 100644 index 00000000..c779d432 --- /dev/null +++ b/test/agents-start-dry-run.test.js @@ -0,0 +1,72 @@ +const { + test, + assert, + fs, + path, + runNode, + runNodeWithEnv, + runCmd, + initRepo, + seedCommit, +} = require('./helpers/install-test-helpers'); + +test('gx agents start dry-run prints the planned codex branch, worktree, and launch without side effects', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + + const result = runNodeWithEnv( + ['agents', 'start', 'fix auth tests', '--agent', 'codex', '--base', 'main', '--dry-run'], + repoDir, + { GUARDEX_BRANCH_TIMESTAMP: '2026-04-29-21-30' }, + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /\[gitguardex\] Agents start dry-run:/); + assert.match(result.stdout, /task slug: fix-auth-tests/); + assert.match(result.stdout, /branch: agent\/codex\/fix-auth-tests-2026-04-29-21-30/); + assert.match( + result.stdout, + new RegExp(`worktree: ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\.omx/agent-worktrees/[^\\n]*codex__fix-auth-tests-2026-04-29-21-30`), + ); + assert.match(result.stdout, /launch: cd '.*' && 'codex' 'fix auth tests'/); + assert.match(result.stdout, /No branch, worktree, session metadata, or agent process was created\./); + + const branchCheck = runCmd( + 'git', + ['show-ref', '--verify', '--quiet', 'refs/heads/agent/codex/fix-auth-tests-2026-04-29-21-30'], + repoDir, + ); + assert.notEqual(branchCheck.status, 0, 'dry-run must not create a branch'); + assert.equal( + fs.existsSync(path.join(repoDir, '.omx', 'agent-worktrees', 'repo__codex__fix-auth-tests-2026-04-29-21-30')), + false, + 'dry-run must not create a worktree', + ); + assert.equal( + fs.existsSync(path.join(repoDir, '.omx', 'state', 'agents-bots.json')), + false, + 'dry-run must not write session metadata', + ); +}); + +test('gx agents start dry-run supports claude worktree planning and rejects unknown agents', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + + const claudeResult = runNodeWithEnv( + ['agents', 'start', 'update docs', '--agent', 'claude', '--dry-run'], + repoDir, + { GUARDEX_BRANCH_TIMESTAMP: '2026-04-29-21-31' }, + ); + assert.equal(claudeResult.status, 0, claudeResult.stderr || claudeResult.stdout); + assert.match(claudeResult.stdout, /branch: agent\/claude\/update-docs-2026-04-29-21-31/); + assert.match(claudeResult.stdout, /\.omc\/agent-worktrees\/[^ \n]*claude__update-docs-2026-04-29-21-31/); + assert.match(claudeResult.stdout, /launch: cd '.*' && 'claude' 'update docs'/); + + const invalidResult = runNode( + ['agents', 'start', 'update docs', '--agent', 'bogus', '--dry-run'], + repoDir, + ); + assert.notEqual(invalidResult.status, 0); + assert.match(invalidResult.stderr, /Unknown agent id: bogus/); +}); diff --git a/test/cli-args-dispatch.test.js b/test/cli-args-dispatch.test.js index 84c95483..789a322d 100644 --- a/test/cli-args-dispatch.test.js +++ b/test/cli-args-dispatch.test.js @@ -105,11 +105,30 @@ test('parseAgentsArgs applies interval overrides and validates the subcommand', assert.deepEqual(options, { target: '/tmp/guardex-repo', subcommand: 'start', + task: '', + agent: '', + base: '', + dryRun: false, reviewIntervalSeconds: 15, cleanupIntervalSeconds: 45, idleMinutes: 12, pid: null, }); + + const dryRunOptions = parseAgentsArgs([ + 'start', + 'fix auth tests', + '--agent', + 'codex', + '--base', + 'main', + '--dry-run', + ]); + + assert.equal(dryRunOptions.task, 'fix auth tests'); + assert.equal(dryRunOptions.agent, 'codex'); + assert.equal(dryRunOptions.base, 'main'); + assert.equal(dryRunOptions.dryRun, true); }); test('parseReportArgs accepts the session-severity flag set', () => {