diff --git a/openspec/changes/agent-codex-kitty-cockpit-layout-planning-2026-04-30-14-12/proposal.md b/openspec/changes/agent-codex-kitty-cockpit-layout-planning-2026-04-30-14-12/proposal.md new file mode 100644 index 0000000..fb96d73 --- /dev/null +++ b/openspec/changes/agent-codex-kitty-cockpit-layout-planning-2026-04-30-14-12/proposal.md @@ -0,0 +1,15 @@ +## Why + +- Kitty cockpit needs a deterministic command plan before wiring real pane execution. +- The control/welcome area must stay visible while agent terminals launch on the other side. + +## What Changes + +- Add a pure `createKittyCockpitPlan` module for Kitty cockpit layout planning. +- Emit ordered command steps for control launch, agent-area launch, each agent terminal, and final control focus. +- Preserve branch, worktree, and lock creation in existing GitGuardEx flows. + +## Impact + +- Adds planner-only Kitty cockpit behavior under `src/cockpit`. +- Does not execute Kitty commands, create worktrees, claim locks, or change tmux behavior. diff --git a/openspec/changes/agent-codex-kitty-cockpit-layout-planning-2026-04-30-14-12/specs/cockpit-kitty-layout/spec.md b/openspec/changes/agent-codex-kitty-cockpit-layout-planning-2026-04-30-14-12/specs/cockpit-kitty-layout/spec.md new file mode 100644 index 0000000..ad7d45f --- /dev/null +++ b/openspec/changes/agent-codex-kitty-cockpit-layout-planning-2026-04-30-14-12/specs/cockpit-kitty-layout/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Kitty cockpit layout command plans +GitGuardEx SHALL provide a pure Kitty cockpit layout planner that returns a deterministic command plan for a persistent control/welcome area and right-side agent terminals. + +#### Scenario: One agent layout +- **GIVEN** a repo root, session name, control command, welcome command, and one agent with a worktree +- **WHEN** the Kitty cockpit layout planner is called +- **THEN** the plan includes one control launch command +- **AND** the plan includes one agent terminal launch command rooted at that agent worktree. + +#### Scenario: Many agent layout +- **GIVEN** multiple agents in a fixed input order +- **WHEN** the Kitty cockpit layout planner is called more than once with the same input +- **THEN** the plan uses stable ordered titles for the agents +- **AND** both planner calls return identical output. + +#### Scenario: Safety ownership remains external +- **GIVEN** agents that already have cwd or worktree paths +- **WHEN** the Kitty cockpit layout planner builds commands +- **THEN** the plan preserves those cwd values +- **AND** the planner does not create branches, worktrees, locks, or other GitGuardEx ownership state. diff --git a/openspec/changes/agent-codex-kitty-cockpit-layout-planning-2026-04-30-14-12/tasks.md b/openspec/changes/agent-codex-kitty-cockpit-layout-planning-2026-04-30-14-12/tasks.md new file mode 100644 index 0000000..16ff035 --- /dev/null +++ b/openspec/changes/agent-codex-kitty-cockpit-layout-planning-2026-04-30-14-12/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, append a `BLOCKED:` line under section 4 explaining the blocker and stop. + +## 1. Specification + +- [x] 1.1 Capture Kitty cockpit planner behavior. +- [x] 1.2 Define normative requirements in `specs/cockpit-kitty-layout/spec.md`. + +## 2. Implementation + +- [x] 2.1 Add a pure Kitty cockpit layout planner. +- [x] 2.2 Emit deterministic command steps for control, agent area, agent terminals, and focus. +- [x] 2.3 Keep worktree and lock creation outside the planner. +- [x] 2.4 Add focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run focused Node tests for Kitty cockpit layout and tmux cockpit compatibility. + - Evidence: `node --test test/cockpit-kitty-layout.test.js test/cockpit-layout.test.js test/tmux-session.test.js test/cockpit-terminal-backend.test.js` passed 20/20. +- [x] 3.2 Run `openspec validate agent-codex-kitty-cockpit-layout-planning-2026-04-30-14-12 --type change --strict`. + - Evidence: command passed. +- [x] 3.3 Run `openspec validate --specs`. + - Evidence: command passed with `No items found to validate.` + +## 4. Cleanup + +- [ ] 4.1 Run `gx branch finish --branch agent/codex/kitty-cockpit-layout-planning-2026-04-30-14-12 --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record PR URL and final merge state. +- [ ] 4.3 Confirm sandbox worktree cleanup. diff --git a/src/cockpit/kitty-layout.js b/src/cockpit/kitty-layout.js new file mode 100644 index 0000000..65339a0 --- /dev/null +++ b/src/cockpit/kitty-layout.js @@ -0,0 +1,227 @@ +'use strict'; + +const DEFAULT_SESSION_NAME = 'guardex'; +const DEFAULT_COLUMNS = 120; +const DEFAULT_KITTY_BIN = 'kitty'; +const DEFAULT_WELCOME_COMMAND = 'gx'; + +function text(value, fallback = '') { + if (typeof value === 'string') return value.trim() || fallback; + if (value === null || value === undefined) return fallback; + return String(value).trim() || fallback; +} + +function requireText(value, name) { + const normalized = text(value); + if (!normalized) { + throw new TypeError(`${name} must be a non-empty string`); + } + return normalized; +} + +function firstText(...values) { + for (const value of values) { + const normalized = text(value); + if (normalized) return normalized; + } + return ''; +} + +function positiveInteger(value, fallback) { + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +function commandShape(args, kittyBin = DEFAULT_KITTY_BIN) { + return { + cmd: text(kittyBin, DEFAULT_KITTY_BIN), + args, + }; +} + +function appendShellCommand(args, command) { + const normalized = text(command); + if (normalized) { + args.push('--', 'sh', '-lc', normalized); + } + return args; +} + +function launchCommand(window, kittyBin) { + const args = [ + '@', + 'launch', + '--type=window', + ]; + if (window.location) { + args.push(`--location=${window.location}`); + } + args.push( + '--cwd', + window.cwd, + '--title', + window.title, + ); + appendShellCommand(args, window.command); + return commandShape(args, kittyBin); +} + +function focusCommand(window, kittyBin) { + return commandShape(['@', 'focus-window', '--match', window.match], kittyBin); +} + +function matchTitle(title) { + return `title:${title}`; +} + +function agentId(agent, index) { + return firstText( + agent.id, + agent.sessionId, + agent.agentId, + agent.branch, + `agent-${index + 1}`, + ); +} + +function agentLabel(agent, index) { + const explicitTitle = text(agent.title); + if (explicitTitle) return explicitTitle; + const id = agentId(agent, index); + const label = firstText( + agent.label, + agent.agentName, + agent.agent, + agent.name, + ); + if (label && id && label !== id) return `${label} ${id}`; + return firstText( + label, + id, + `agent-${index + 1}`, + ); +} + +function agentTitle(agent, index) { + return `${String(index + 1).padStart(2, '0')}: ${agentLabel(agent, index)}`; +} + +function normalizeAgent(agent, index, repoRoot, total) { + const source = agent && typeof agent === 'object' ? agent : {}; + const cwd = requireText( + firstText(source.cwd, source.worktree, source.worktreePath, source.path, repoRoot), + `agents[${index}].cwd`, + ); + const title = agentTitle(source, index); + return { + id: agentId(source, index), + index, + total, + title, + cwd, + worktree: firstText(source.worktree, source.worktreePath, source.path, source.cwd), + command: firstText(source.command, source.launchCommand, source.shellCommand, 'exec ${SHELL:-bash}'), + branch: text(source.branch), + match: matchTitle(title), + }; +} + +function createKittyCockpitPlan(options = {}) { + const repoRoot = requireText(options.repoRoot, 'repoRoot'); + const sessionName = text(options.sessionName, DEFAULT_SESSION_NAME); + const agents = Array.isArray(options.agents) ? options.agents : []; + const columns = positiveInteger(options.columns, DEFAULT_COLUMNS); + const kittyBin = text(options.kittyBin, DEFAULT_KITTY_BIN); + const controlCommand = text( + options.controlCommand, + `gx cockpit control --target ${shellQuote(repoRoot)}`, + ); + const welcomeCommand = text(options.welcomeCommand, DEFAULT_WELCOME_COMMAND); + + const controlTitle = `${sessionName}: control`; + const agentAreaTitle = `${sessionName}: agents`; + const controlWindow = { + id: 'control', + role: 'control', + title: controlTitle, + cwd: repoRoot, + command: controlCommand, + match: matchTitle(controlTitle), + persistent: true, + }; + const agentAreaWindow = { + id: 'agent-area', + role: 'agent-area', + title: agentAreaTitle, + cwd: repoRoot, + command: welcomeCommand, + match: matchTitle(agentAreaTitle), + location: 'vsplit', + }; + const agentWindows = agents.map((agent, index) => ({ + ...normalizeAgent(agent, index, repoRoot, agents.length), + role: 'agent', + location: 'vsplit', + })); + + const steps = [ + { + id: 'launch-control', + role: 'control', + action: 'launch', + window: controlWindow, + command: launchCommand(controlWindow, kittyBin), + }, + { + id: 'launch-agent-area', + role: 'agent-area', + action: 'launch', + window: agentAreaWindow, + command: launchCommand(agentAreaWindow, kittyBin), + }, + ...agentWindows.map((window) => ({ + id: `launch-agent-${window.index + 1}`, + role: 'agent', + action: 'launch', + agentId: window.id, + window, + command: launchCommand(window, kittyBin), + })), + ]; + + if (options.focusControl !== false) { + steps.push({ + id: 'focus-control', + role: 'control', + action: 'focus', + window: controlWindow, + command: focusCommand(controlWindow, kittyBin), + }); + } + + return { + schemaVersion: 1, + backend: 'kitty', + dryRun: Boolean(options.dryRun), + sessionName, + repoRoot, + columns, + layout: { + control: controlWindow, + agentArea: agentAreaWindow, + agents: agentWindows, + }, + steps, + commands: steps.map((step) => step.command), + }; +} + +module.exports = { + DEFAULT_COLUMNS, + DEFAULT_SESSION_NAME, + createKittyCockpitPlan, +}; diff --git a/test/cockpit-kitty-layout.test.js b/test/cockpit-kitty-layout.test.js new file mode 100644 index 0000000..930d130 --- /dev/null +++ b/test/cockpit-kitty-layout.test.js @@ -0,0 +1,130 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { createKittyCockpitPlan } = require('../src/cockpit/kitty-layout'); + +function agent(id, extra = {}) { + return { + id, + agent: 'codex', + worktree: `/repo/.omx/agent-worktrees/${id}`, + command: `cd /repo/.omx/agent-worktrees/${id} && exec codex`, + ...extra, + }; +} + +test('one agent creates control and agent launch commands', () => { + const plan = createKittyCockpitPlan({ + repoRoot: '/repo/gitguardex', + sessionName: 'guardex-dev', + agents: [agent('alpha')], + controlCommand: "gx cockpit control --target '/repo/gitguardex'", + welcomeCommand: 'gx', + dryRun: true, + }); + + assert.equal(plan.backend, 'kitty'); + assert.equal(plan.dryRun, true); + assert.deepEqual( + plan.steps.map((step) => step.id), + ['launch-control', 'launch-agent-area', 'launch-agent-1', 'focus-control'], + ); + assert.deepEqual(plan.steps[0].command, { + cmd: 'kitty', + args: [ + '@', + 'launch', + '--type=window', + '--cwd', + '/repo/gitguardex', + '--title', + 'guardex-dev: control', + '--', + 'sh', + '-lc', + "gx cockpit control --target '/repo/gitguardex'", + ], + }); + assert.deepEqual(plan.steps[2].command.args, [ + '@', + 'launch', + '--type=window', + '--location=vsplit', + '--cwd', + '/repo/.omx/agent-worktrees/alpha', + '--title', + '01: codex alpha', + '--', + 'sh', + '-lc', + 'cd /repo/.omx/agent-worktrees/alpha && exec codex', + ]); +}); + +test('many agents create stable titles', () => { + const agents = Array.from({ length: 12 }, (_, index) => agent(`agent-${index + 1}`, { + agent: index % 2 === 0 ? 'codex' : 'claude', + })); + const plan = createKittyCockpitPlan({ + repoRoot: '/repo/gitguardex', + sessionName: 'guardex', + agents, + }); + + assert.deepEqual( + plan.layout.agents.map((entry) => entry.title), + [ + '01: codex agent-1', + '02: claude agent-2', + '03: codex agent-3', + '04: claude agent-4', + '05: codex agent-5', + '06: claude agent-6', + '07: codex agent-7', + '08: claude agent-8', + '09: codex agent-9', + '10: claude agent-10', + '11: codex agent-11', + '12: claude agent-12', + ], + ); + assert.equal(new Set(plan.layout.agents.map((entry) => entry.match)).size, 12); +}); + +test('repoRoot and worktree cwd are preserved', () => { + const plan = createKittyCockpitPlan({ + repoRoot: '/repo/gitguardex', + agents: [ + agent('alpha', { + cwd: '/repo/worktrees/alpha', + worktree: '/repo/worktrees/alpha', + title: 'alpha lane', + }), + ], + }); + + assert.equal(plan.layout.control.cwd, '/repo/gitguardex'); + assert.equal(plan.layout.agentArea.cwd, '/repo/gitguardex'); + assert.equal(plan.layout.agents[0].cwd, '/repo/worktrees/alpha'); + assert.equal(plan.layout.agents[0].worktree, '/repo/worktrees/alpha'); + assert.equal(plan.steps[2].command.args[5], '/repo/worktrees/alpha'); +}); + +test('plan is deterministic', () => { + const input = { + repoRoot: '/repo/gitguardex', + sessionName: 'guardex-dev', + agents: [agent('alpha'), agent('beta', { agent: 'claude' })], + controlCommand: 'gx cockpit control', + welcomeCommand: 'gx', + columns: 160, + dryRun: true, + }; + + assert.deepEqual( + createKittyCockpitPlan(JSON.parse(JSON.stringify(input))), + createKittyCockpitPlan(JSON.parse(JSON.stringify(input))), + ); +});