diff --git a/openspec/changes/agent-codex-codex-task-2026-05-13-11-56/.openspec.yaml b/openspec/changes/agent-codex-codex-task-2026-05-13-11-56/.openspec.yaml new file mode 100644 index 0000000..93831bd --- /dev/null +++ b/openspec/changes/agent-codex-codex-task-2026-05-13-11-56/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-13 diff --git a/openspec/changes/agent-codex-codex-task-2026-05-13-11-56/proposal.md b/openspec/changes/agent-codex-codex-task-2026-05-13-11-56/proposal.md new file mode 100644 index 0000000..87ed3f4 --- /dev/null +++ b/openspec/changes/agent-codex-codex-task-2026-05-13-11-56/proposal.md @@ -0,0 +1,16 @@ +## Why + +- The terminal fleet/cockpit view was too transcript-like: live agent state was mixed with verbose raw details, making it hard to scan which lanes are working, thinking, blocked, done, or stale. +- Operators need a compact board that highlights lane state, finish readiness, branch/task context, and the files/PR evidence needed for follow-up. + +## What Changes + +- Update the cockpit renderer to present a fleet-style board with state buckets, a summary header, action hints, compact lane rows, and per-lane progress/readiness text. +- Keep the existing text render surface and command flow intact; this change only improves the rendered terminal output. +- Add focused regression coverage for the grouped fleet buckets and retained cockpit details. + +## Impact + +- Affected surface: `gx cockpit` / default interactive fleet rendering. +- Risk is narrow: output text changes for cockpit snapshots, with no runtime/session schema changes. +- Existing status payload and cockpit state readers remain unchanged. diff --git a/openspec/changes/agent-codex-codex-task-2026-05-13-11-56/specs/codex-task/spec.md b/openspec/changes/agent-codex-codex-task-2026-05-13-11-56/specs/codex-task/spec.md new file mode 100644 index 0000000..81a53fe --- /dev/null +++ b/openspec/changes/agent-codex-codex-task-2026-05-13-11-56/specs/codex-task/spec.md @@ -0,0 +1,14 @@ +## ADDED Requirements + +### Requirement: Fleet cockpit scan view +The system SHALL render cockpit sessions as a compact fleet board that is easy to scan by agent state. + +#### Scenario: Active sessions are grouped by operator state +- **WHEN** the cockpit renderer receives sessions with working, thinking, blocked, done, and stale states +- **THEN** it SHALL include a summary count for each state +- **AND** it SHALL render non-empty state groups with clear headings. + +#### Scenario: Session rows preserve follow-up evidence +- **WHEN** a session is rendered in the fleet board +- **THEN** its row SHALL include branch, progress, worktree, lock, changed-file, task, Colony metadata, PR, and heartbeat details when available +- **AND** existing cockpit text output consumers SHALL still receive a plain terminal string. diff --git a/openspec/changes/agent-codex-codex-task-2026-05-13-11-56/tasks.md b/openspec/changes/agent-codex-codex-task-2026-05-13-11-56/tasks.md new file mode 100644 index 0000000..a920c01 --- /dev/null +++ b/openspec/changes/agent-codex-codex-task-2026-05-13-11-56/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-13-11-56`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-codex-codex-task-2026-05-13-11-56` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-codex-codex-task-2026-05-13-11-56/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// --base dev --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-codex-task-2026-05-13-11-56`. +- [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-13-11-56 --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/src/cockpit/render.js b/src/cockpit/render.js index 09e8e71..64f00a9 100644 --- a/src/cockpit/render.js +++ b/src/cockpit/render.js @@ -2,6 +2,14 @@ function line(label, value) { return `${label}: ${value || '-'}`; } +const BUCKETS = [ + { key: 'working', title: 'WORKING NOW' }, + { key: 'thinking', title: 'THINKING' }, + { key: 'blocked', title: 'BLOCKED' }, + { key: 'done', title: 'DONE' }, + { key: 'stale', title: 'STALE' }, +]; + function lockSummary(locks) { if (!Array.isArray(locks) || locks.length === 0) { return 'none'; @@ -20,6 +28,16 @@ function lockCountSummary(session) { return Number.isFinite(session.lockCount) ? String(session.lockCount) : 'none'; } +function filePreview(files) { + if (!Array.isArray(files) || files.length === 0) { + return 'none'; + } + + const preview = files.slice(0, 3).join(', '); + const suffix = files.length > 3 ? `, +${files.length - 3} more` : ''; + return `${files.length} (${preview}${suffix})`; +} + function metadataSummary(metadata) { if (!metadata || typeof metadata !== 'object') return ''; return Object.entries(metadata) @@ -39,14 +57,96 @@ function worktreeSummary(session) { return worktreePath; } +function normalizeState(value) { + return String(value || '').trim().toLowerCase(); +} + +function sessionBucket(session) { + if (session.worktreeExists === false) { + return 'stale'; + } + + const status = normalizeState(session.status); + const activity = normalizeState(session.activity); + const prState = normalizeState(session.prState); + const state = `${status} ${activity}`.trim(); + + if (prState === 'merged' || /\b(done|complete|completed|merged)\b/.test(state)) { + return 'done'; + } + if (/\b(blocked|failed|failing|error|errored|stalled|dead)\b/.test(state)) { + return 'blocked'; + } + if (/\b(thinking|pending|queued|idle|waiting)\b/.test(state)) { + return 'thinking'; + } + if (/\b(working|running|active|orbiting|symbioting|advising)\b/.test(state)) { + return 'working'; + } + return 'thinking'; +} + +function groupSessions(sessions) { + const grouped = new Map(BUCKETS.map((bucket) => [bucket.key, []])); + sessions.forEach((session) => { + grouped.get(sessionBucket(session)).push(session); + }); + return grouped; +} + +function summaryLine(grouped) { + return BUCKETS + .map((bucket) => `${bucket.key}=${grouped.get(bucket.key).length}`) + .join(' '); +} + +function stage(value) { + return value ? 'ok' : 'todo'; +} + +function progressSummary(session) { + const metadata = session.metadata && typeof session.metadata === 'object' ? session.metadata : {}; + const hasSpec = Boolean(metadata['colony.plan'] || metadata['colony.subtask'] || metadata['colony.task_id']); + const hasCode = ( + (Array.isArray(session.changedFiles) && session.changedFiles.length > 0) || + (Array.isArray(session.locks) && session.locks.length > 0) || + Number(session.lockCount || 0) > 0 + ); + const hasPr = Boolean(session.prUrl || session.prState); + const merged = normalizeState(session.prState) === 'merged'; + + return [ + `Spec ${stage(hasSpec)}`, + `Code ${hasCode ? 'active' : 'todo'}`, + `Tests ${metadata['colony.verification'] ? 'ok' : 'todo'}`, + `PR ${stage(hasPr)}`, + `Merge ${stage(merged)}`, + `Cleanup ${merged ? 'ready' : 'todo'}`, + ].join(' | '); +} + +function readinessSummary(session) { + const bucket = sessionBucket(session); + if (bucket === 'stale') return 'STALE'; + if (bucket === 'blocked') return 'BLOCKED'; + if (normalizeState(session.prState) === 'merged') return 'MERGED'; + if (session.prUrl) return 'PR OPEN'; + if (Array.isArray(session.changedFiles) && session.changedFiles.length > 0) return 'CHANGED'; + return 'OPEN'; +} + function renderSession(session, index) { const lines = [ - `${index + 1}. ${session.agentName || 'agent'} | ${session.status || 'unknown'}`, + `${index + 1}. ${session.agentName || 'agent'} | ${readinessSummary(session)} | ${session.status || 'unknown'}`, ` branch: ${session.branch || '-'}`, + ` progress: ${progressSummary(session)}`, ` worktree: ${worktreeSummary(session)}`, ` locks: ${lockCountSummary(session)}`, ]; + if (Array.isArray(session.changedFiles)) { + lines.push(` changed: ${filePreview(session.changedFiles)}`); + } if (session.task) { lines.push(` task: ${session.task}`); } @@ -66,23 +166,35 @@ function renderSession(session, index) { function renderCockpit(state) { const sessions = Array.isArray(state && state.sessions) ? state.sessions : []; + const grouped = groupSessions(sessions); const lines = [ - 'GitGuardex Cockpit', + 'GitGuardex Cockpit Fleet', line('repo', state && state.repoPath), line('base', state && state.baseBranch), line('active sessions', String(sessions.length)), + line('summary', summaryLine(grouped)), + 'actions: Enter inspect | f finish | h handoff | r refresh', '', ]; if (sessions.length === 0) { lines.push('No active agent sessions.'); } else { - sessions.forEach((session, index) => { - if (index > 0) { + let displayIndex = 0; + BUCKETS.forEach((bucket) => { + const bucketSessions = grouped.get(bucket.key); + if (bucketSessions.length === 0) return; + if (displayIndex > 0) { lines.push(''); } - lines.push(renderSession(session, index)); + lines.push(`${bucket.title} (${bucketSessions.length})`); + bucketSessions.forEach((session) => { + lines.push(renderSession(session, displayIndex)); + displayIndex += 1; + }); }); + lines.push(''); + lines.push('detail: selected lane shows branch, progress, claims, changed files, PR, heartbeat, and Colony metadata.'); } return `${lines.join('\n')}\n`; @@ -91,6 +203,11 @@ function renderCockpit(state) { module.exports = { renderCockpit, renderSession, + filePreview, + groupSessions, + progressSummary, + readinessSummary, + sessionBucket, lockSummary, lockCountSummary, metadataSummary, diff --git a/test/cockpit-render.test.js b/test/cockpit-render.test.js index 0b31ad8..38a0151 100644 --- a/test/cockpit-render.test.js +++ b/test/cockpit-render.test.js @@ -44,6 +44,9 @@ test('renderCockpit returns a readable terminal string', () => { assert.match(output, /repo: \/repo\/example/); assert.match(output, /base: main/); assert.match(output, /active sessions: 1/); + assert.match(output, /summary: working=1 thinking=0 blocked=0 done=0 stale=0/); + assert.match(output, /WORKING NOW \(1\)/); + assert.match(output, /progress: Spec ok \| Code active \| Tests todo \| PR todo \| Merge todo \| Cleanup todo/); assert.match(output, /branch: agent\/codex\/example/); assert.match(output, /worktree: \/repo\/example\/\.omx\/agent-worktrees\/example \(present\)/); assert.match(output, /locks: 4 \(src\/cockpit\/render\.js, src\/cockpit\/state\.js, test\/cockpit-render\.test\.js, \+1 more\)/); @@ -51,6 +54,68 @@ test('renderCockpit returns a readable terminal string', () => { assert.match(output, /colony: colony\.plan=queen-plan colony\.subtask=1/); }); +test('renderCockpit groups sessions into fleet buckets', () => { + const output = renderCockpit({ + repoPath: '/repo/example', + baseBranch: 'main', + sessions: [ + { + agentName: 'codex', + branch: 'agent/codex/working', + worktreePath: '/repo/example/.omx/agent-worktrees/working', + worktreeExists: true, + status: 'working', + task: 'implement UI', + changedFiles: ['src/cockpit/render.js'], + }, + { + agentName: 'claude', + branch: 'agent/claude/pending', + worktreePath: '/repo/example/.omx/agent-worktrees/pending', + worktreeExists: true, + status: 'thinking', + task: 'review spec', + }, + { + agentName: 'codex', + branch: 'agent/codex/blocked', + worktreePath: '/repo/example/.omx/agent-worktrees/blocked', + worktreeExists: true, + status: 'blocked', + task: 'fix test', + }, + { + agentName: 'codex', + branch: 'agent/codex/merged', + worktreePath: '/repo/example/.omx/agent-worktrees/merged', + worktreeExists: true, + status: 'complete', + prState: 'MERGED', + prUrl: 'https://github.com/example/repo/pull/1', + }, + { + agentName: 'codex', + branch: 'agent/codex/stale', + worktreePath: '/repo/example/.omx/agent-worktrees/stale', + worktreeExists: false, + status: 'working', + }, + ], + }); + + assert.match(output, /summary: working=1 thinking=1 blocked=1 done=1 stale=1/); + assert.match(output, /WORKING NOW \(1\)/); + assert.match(output, /THINKING \(1\)/); + assert.match(output, /BLOCKED \(1\)/); + assert.match(output, /DONE \(1\)/); + assert.match(output, /STALE \(1\)/); + assert.match(output, /codex \| CHANGED \| working/); + assert.match(output, /codex \| MERGED \| complete/); + assert.match(output, /codex \| STALE \| working/); + assert.match(output, /changed: 1 \(src\/cockpit\/render\.js\)/); + assert.match(output, /detail: selected lane shows branch, progress, claims, changed files, PR, heartbeat, and Colony metadata\./); +}); + test('agents status payload and cockpit state see the same session', () => { const repoPath = initRepo(); const worktreePath = path.join(repoPath, '.omx', 'agent-worktrees', 'example');