Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-29
Original file line number Diff line number Diff line change
@@ -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 <task> --agent <id> [--base <branch>] --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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## ADDED Requirements

### Requirement: Dry-run agent start planning
`gx agents start <task> --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.
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion src/agents/launch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
25 changes: 25 additions & 0 deletions src/agents/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const AGENT_DEFINITIONS = [
defaultEnabled: true,
promptMode: 'argument',
resumeCommandTemplate: 'codex resume {sessionId}',
worktreeRoot: '.omx/agent-worktrees',
},
{
id: 'claude',
Expand All @@ -20,6 +21,7 @@ const AGENT_DEFINITIONS = [
defaultEnabled: true,
promptMode: 'argument',
resumeCommandTemplate: 'claude --resume {sessionId}',
worktreeRoot: '.omc/agent-worktrees',
},
{
id: 'opencode',
Expand All @@ -30,6 +32,7 @@ const AGENT_DEFINITIONS = [
detectCommand: 'opencode --version',
defaultEnabled: true,
promptMode: 'argument',
worktreeRoot: '.omx/agent-worktrees',
},
{
id: 'cursor',
Expand All @@ -40,6 +43,7 @@ const AGENT_DEFINITIONS = [
detectCommand: 'cursor-agent --version',
defaultEnabled: true,
promptMode: 'argument',
worktreeRoot: '.omx/agent-worktrees',
},
{
id: 'gemini',
Expand All @@ -50,6 +54,7 @@ const AGENT_DEFINITIONS = [
detectCommand: 'gemini --version',
defaultEnabled: true,
promptMode: 'argument',
worktreeRoot: '.omx/agent-worktrees',
},
];

Expand Down Expand Up @@ -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);
}
Expand All @@ -98,11 +107,27 @@ 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,
isAgentId,
getAgentDefinition,
getAgentDefinitions,
getDefaultEnabledAgents,
listAgentIds,
normalizeAgentId,
resolveAgent,
};
94 changes: 94 additions & 0 deletions src/agents/start.js
Original file line number Diff line number Diff line change
@@ -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,
};
39 changes: 39 additions & 0 deletions src/cli/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`);
}

Expand All @@ -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 <task> 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;
}
Expand Down
7 changes: 7 additions & 0 deletions src/cli/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading