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,14 @@
## Why

- Colony takeover and Queen-plan lanes need machine-readable Guardex surfaces so another agent can resume, inspect, and finish work without scraping human text.

## What Changes

- Extend `gx agents start --dry-run --json` with branch, worktree, launch command, claims, tmux, and Colony metadata.
- Extend `gx agents status --json` and cockpit state with activity, claims, changed files, metadata, launch command, and PR evidence.
- Extend `gx agents finish --json` with PR, merge, cleanup, and status evidence written back to session metadata.

## Impact

- Existing text output remains human-readable.
- JSON output is additive and versioned with `schemaVersion: 1`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## ADDED Requirements

### Requirement: Colony-ready agent start planning
`gx agents start <task> --dry-run --json` SHALL emit a versioned JSON plan that can be consumed by Colony or a cockpit integration without parsing human text.

#### Scenario: Previewing a Colony handoff lane
- **WHEN** a user runs `gx agents start "fix handoff" --agent codex --claim README.md --meta colony.plan=queen-plan --dry-run --json`
- **THEN** Guardex emits `schemaVersion`, `dryRun`, `task`, `agent`, `base`, `branch`, `worktree`, `worktreePath`, `claimedFiles`, `launchCommand`, `tmuxSession`, `tmuxTarget`, and `metadata`
- **AND** the command does not create a branch, worktree, session, file claim, tmux session, or agent process.

### Requirement: Colony-ready agent status
`gx agents status --json` SHALL expose session metadata needed to inspect, adopt, or finish active agent lanes.

#### Scenario: Inspecting a Queen-plan lane
- **WHEN** a session stores Colony metadata, claimed files, changed files, launch command, and PR evidence
- **THEN** the status payload includes `activity`, `claimedFiles`, `changedFiles`, `metadata`, `launchCommand`, `tmux`, `prUrl`, `prState`, and `pr`
- **AND** cockpit state preserves the same fields for rendering.

### Requirement: Finish evidence JSON
`gx agents finish --json` SHALL emit versioned completion evidence and persist that evidence to the session record.

#### Scenario: Finishing a merged lane
- **WHEN** a finish command completes with PR output and cleanup enabled
- **THEN** Guardex emits `schemaVersion`, `sessionId`, `branch`, `prUrl`, `mergeState`, `cleanupResult`, and `status`
- **AND** the matching agent session records the PR state and finish evidence for later status and handoff surfaces.
Original file line number Diff line number Diff line change
@@ -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, append a `BLOCKED:` line under section 4 explaining the blocker and stop.

## Handoff

- Handoff: change=`agent-codex-colony-queen-agent-json-surface-2026-04-30-00-03`; branch=`agent/codex/colony-queen-agent-json-surface-2026-04-30-00-03`; scope=`agents start/status/finish JSON surface plus cockpit state`; action=`finish cleanup after quota takeover`.
- Copy prompt: Continue `agent-codex-colony-queen-agent-json-surface-2026-04-30-00-03` on branch `agent/codex/colony-queen-agent-json-surface-2026-04-30-00-03`. Work inside the existing sandbox, review this 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/colony-queen-agent-json-surface-2026-04-30-00-03 --base main --via-pr --wait-for-merge --cleanup`.

## 1. Specification

- [x] 1.1 Record proposal scope and acceptance criteria.
- [x] 1.2 Define normative JSON surface requirements.

## 2. Implementation

- [x] 2.1 Preserve Colony metadata through `gx agents start`.
- [x] 2.2 Add status/cockpit fields for claims, changed files, metadata, launch command, and PR evidence.
- [x] 2.3 Add finish JSON evidence and persist it on the session.

## 3. Verification

- [x] 3.1 Run focused agent/cockpit tests.
- [x] 3.2 Run `openspec validate agent-codex-colony-queen-agent-json-surface-2026-04-30-00-03 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

## 4. Cleanup

- [ ] 4.1 Run `gx branch finish --branch agent/codex/colony-queen-agent-json-surface-2026-04-30-00-03 --base main --via-pr --wait-for-merge --cleanup`.
- [ ] 4.2 Record PR URL and final merge state (`MERGED`) in the completion handoff.
- [ ] 4.3 Confirm sandbox worktree is gone and no local/remote branch refs remain.
- BLOCKED: `gx branch finish --branch agent/codex/colony-queen-agent-json-surface-2026-04-30-00-03 --base main --via-pr --wait-for-merge --cleanup` auto-synced onto `origin/main` and hit rebase conflicts in `src/agents/start.js`, `src/cli/args.js`, and `test/agents-start-dry-run.test.js`; branch is 11 commits behind `origin/main`.
105 changes: 96 additions & 9 deletions src/agents/finish.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,78 @@ function sessionStatusAfterFinish(finishArgs) {
return finishArgs.includes('--no-wait-for-merge') && !directMode ? 'pr-opened' : 'finished';
}

function cleanupResultAfterFinish(finishArgs, status) {
if (status === 'failed') return 'failed';
if (finishArgs.includes('--no-cleanup')) return 'skipped';
if (finishArgs.includes('--cleanup')) return status === 'finished' ? 'completed' : 'pending';
return 'unknown';
}

function firstMatch(text, patterns) {
for (const pattern of patterns) {
const match = text.match(pattern);
if (match && match[1]) return match[1].trim();
}
return '';
}

function finishOutputText(result, captured = {}) {
return [
captured.stdout,
captured.stderr,
result?.stdout,
result?.stderr,
].map((value) => String(value || '')).join('\n');
}

function buildFinishEvidence(session, finishArgs, status, result, captured = {}) {
const outputText = finishOutputText(result, captured);
const prUrl = firstMatch(outputText, [
/\[agent-branch-finish\] (?:Merged PR|PR):\s+(https?:\/\/\S+)/,
/\b(https?:\/\/\S+\/pull\/\d+)\b/,
]);
const mergeState = status === 'finished' ? 'MERGED' : status === 'pr-opened' ? 'OPEN' : status.toUpperCase();
return {
schemaVersion: 1,
sessionId: session.id || '',
branch: session.branch || '',
prUrl,
mergeState,
cleanupResult: cleanupResultAfterFinish(finishArgs, status),
status,
};
}

function captureProcessOutput(fn) {
let stdout = '';
let stderr = '';
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
process.stdout.write = function captureStdout(chunk, encoding, callback) {
stdout += Buffer.isBuffer(chunk) ? chunk.toString(encoding || 'utf8') : String(chunk || '');
if (typeof encoding === 'function') encoding();
if (typeof callback === 'function') callback();
return true;
};
process.stderr.write = function captureStderr(chunk, encoding, callback) {
stderr += Buffer.isBuffer(chunk) ? chunk.toString(encoding || 'utf8') : String(chunk || '');
if (typeof encoding === 'function') encoding();
if (typeof callback === 'function') callback();
return true;
};
try {
return { result: fn(), captured: { stdout, stderr } };
} finally {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
}

function finishAgentSession(repoRoot, options, deps = {}) {
const finishRunner = deps.finishRunner || finishCommands.finish;
const output = deps.output || process.stdout;
const session = resolveAgentSessionForFinish(repoRoot, options);
const jsonMode = Boolean(options.json);

if (!session.branch) {
throw new Error(`Agent session '${session.id}' has no branch metadata.`);
Expand All @@ -62,24 +130,43 @@ function finishAgentSession(repoRoot, options, deps = {}) {
...options.finishArgs,
];

output.write(`[${TOOL_NAME}] Agent session: ${session.id}\n`);
output.write(`[${TOOL_NAME}] Branch: ${session.branch}\n`);
output.write(`[${TOOL_NAME}] Worktree: ${session.worktreePath || '(unknown)'}\n`);
if (!jsonMode) {
output.write(`[${TOOL_NAME}] Agent session: ${session.id}\n`);
output.write(`[${TOOL_NAME}] Branch: ${session.branch}\n`);
output.write(`[${TOOL_NAME}] Worktree: ${session.worktreePath || '(unknown)'}\n`);
}

try {
const result = finishRunner(finishArgs);
const runnerResult = jsonMode
? captureProcessOutput(() => finishRunner(finishArgs))
: { result: finishRunner(finishArgs), captured: {} };
const result = runnerResult.result;
const status = sessionStatusAfterFinish(finishArgs);
updateAgentSession(repoRoot, session.id, { status });
output.write(`[${TOOL_NAME}] Finish result: ${status}\n`);
return { session, status, result, finishArgs };
const evidence = buildFinishEvidence(session, finishArgs, status, result, runnerResult.captured);
updateAgentSession(repoRoot, session.id, {
status,
pr: { url: evidence.prUrl, state: evidence.mergeState },
finishEvidence: evidence,
});
if (!jsonMode) {
output.write(`[${TOOL_NAME}] Finish result: ${status}\n`);
}
return { session, status, result, finishArgs, evidence };
} catch (error) {
updateAgentSession(repoRoot, session.id, { status: 'failed' });
output.write(`[${TOOL_NAME}] Finish result: failed\n`);
const evidence = buildFinishEvidence(session, finishArgs, 'failed', null);
updateAgentSession(repoRoot, session.id, {
status: 'failed',
finishEvidence: evidence,
});
if (!jsonMode) {
output.write(`[${TOOL_NAME}] Finish result: failed\n`);
}
throw error;
}
}

module.exports = {
buildFinishEvidence,
finishAgentSession,
resolveAgentSessionForFinish,
};
7 changes: 7 additions & 0 deletions src/agents/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const SESSION_FIELDS = [
'worktreePath',
'base',
'status',
'activity',
'claims',
'metadata',
'launchCommand',
'tmux',
'pr',
'finishEvidence',
'claimFailure',
'createdAt',
'updatedAt',
Expand Down
42 changes: 42 additions & 0 deletions src/agents/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ function buildStartPlan(options, repoRoot, env = process.env) {
base,
branchName,
worktreePath,
claimedFiles: Array.isArray(options.claims) ? [...options.claims] : [],
metadata: options.metadata && typeof options.metadata === 'object' ? { ...options.metadata } : {},
tmux: options.tmux && typeof options.tmux === 'object' ? { ...options.tmux } : null,
launchCommand: buildAgentLaunchCommand({ agentId: agent.id, prompt: requestedTask, worktreePath }),
};
}
Expand Down Expand Up @@ -114,6 +117,26 @@ function buildLaunchOptions(options) {
return launchOptions;
}

function buildDryRunPayload(plan) {
const tmux = plan.tmux || {};
return {
schemaVersion: 1,
dryRun: true,
task: plan.task,
prompt: plan.requestedTask,
agent: plan.agent.id,
base: plan.base,
branch: plan.branchName,
worktree: plan.worktreePath,
worktreePath: plan.worktreePath,
claimedFiles: plan.claimedFiles,
launchCommand: plan.launchCommand,
tmuxSession: tmux.session || null,
tmuxTarget: tmux.target || null,
metadata: plan.metadata,
};
}

function renderDryRunPlan(plan) {
return [
'[gitguardex] Agents start dry-run:',
Expand All @@ -132,6 +155,16 @@ function renderDryRunPlan(plan) {
function dryRunStart(options, repoRoot) {
const launchOptions = buildLaunchOptions(options);
const plans = launchOptions.map((launchOption) => buildStartPlan(launchOption, repoRoot));
if (options.json) {
if (plans.length === 1) {
return `${JSON.stringify(buildDryRunPayload(plans[0]), null, 2)}\n`;
}
return `${JSON.stringify({
schemaVersion: 1,
dryRun: true,
launches: plans.map(buildDryRunPayload),
}, null, 2)}\n`;
}
if (plans.length === 1 && !options.panel) {
return renderDryRunPlan(plans[0]);
}
Expand Down Expand Up @@ -183,6 +216,14 @@ function buildSessionPayload(options, metadata, status, extra = {}) {
branch: metadata.branch,
worktreePath: path.resolve(metadata.worktreePath),
base: options.base || null,
claims: Array.isArray(options.claims) ? [...options.claims] : [],
metadata: options.metadata && typeof options.metadata === 'object' ? { ...options.metadata } : {},
launchCommand: buildAgentLaunchCommand({
agentId: options.agent || 'codex',
prompt: options.task,
worktreePath: path.resolve(metadata.worktreePath),
}),
tmux: options.tmux && typeof options.tmux === 'object' ? { ...options.tmux } : null,
status,
...extra,
};
Expand Down Expand Up @@ -343,6 +384,7 @@ function startAgentLane(repoRoot, options, deps = {}) {

module.exports = {
buildBranchStartArgs,
buildDryRunPayload,
buildLaunchOptions,
buildStartPlan,
buildRecoveryLines,
Expand Down
Loading
Loading