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-06-05
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
## Why

Multiple agents (Claude, Codex) run in parallel across repos and step on each
other: they don't know who is on which branch, which PR is in flight, or who
already claimed a file. Two concrete failure modes: an agent edits the PRIMARY
checkout (not an isolated worktree) and a later branch switch auto-stashes the
work; and two agents edit the same file because lock ownership isn't visible
across worktrees. gitguardex already KNOWS all of this (worktrees, branches,
locks, PRs) but only exposes it to a human via `gx cockpit`. Agents need it
programmatically.

## What Changes

- New `gx mcp` command with a hand-rolled, dependency-free stdio JSON-RPC MCP
server (no `@modelcontextprotocol/sdk` — keeps gx at 2 deps).
- Four READ-ONLY tools, derived automatically from git/worktree/lock/PR state
(no manual bookkeeping; complements, not replaces, Colony):
- `list_agents` — every active agent lane across all discovered repos:
repo, branch, worktree, task, the PR it's shipping, held locks, last
commit, and a warning when a lane is editing the primary checkout.
- `repo_state(repo)` — the same for a single repo.
- `who_owns(file)` — which agent/branch holds the lock on a path, aggregated
across ALL worktrees (lock files are per-worktree on disk), for a real
cross-agent collision check before editing.
- `my_context` — the current session's repo, branch, worktree, whether it's
the protected primary checkout, held locks, and PR.
- `gx mcp list-agents` / `who-owns` CLI views (human/debug) and `gx mcp register`
(prints the `claude mcp add` one-liner + `.mcp.json` snippet).

## Impact

- **New surfaces**: `src/mcp/collect.js`, `src/mcp/server.js`,
`src/cli/commands/mcp.js`; one dispatch line in `src/cli/main.js`. No new
dependency. No changes to any guard or mutating path.
- **Read-only**: the server never writes to a repo. PR lookups are best-effort
(skip un-pushed branches; gh missing/unauthed → null, never throws).
- **Registration is opt-in**: the server isn't wired into any harness
automatically; users run `claude mcp add gx -s user -- gx mcp serve`.
- **Does not by itself prevent collisions** — it gives the visibility + a
`who_owns` pre-check. The primary-checkout-edit root cause still needs the
separate worktree-discipline work; the `warning` field surfaces it.
- Verified by `test/mcp-collect.test.js` and `test/mcp-server.test.js`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
## ADDED Requirements

### Requirement: Read-only cross-repo agent observability over MCP
The system SHALL provide a `gx mcp serve` stdio MCP server that exposes
read-only tools reflecting current git, worktree, file-lock, and PR state. The
server SHALL NOT mutate any repository.

#### Scenario: Protocol handshake and tool discovery
- **WHEN** an MCP client sends `initialize` then `tools/list`
- **THEN** the server returns its `serverInfo` and tools capability
- **AND** `tools/list` returns exactly `list_agents`, `repo_state`, `who_owns`, and `my_context`, each with a description and an object input schema.

#### Scenario: List active agent lanes across repos
- **WHEN** `list_agents` is called
- **THEN** it returns one record per active agent lane (a worktree on a non-protected branch) across all discovered repos
- **AND** each record carries repo, branch, worktree, task, the files it is editing right now (`dirty`), held locks, last commit, and the open PR for the branch
- **AND** a repo and its linked worktrees are counted as a single repo (deduped by main root)
- **AND** PR lookups are opt-in for `list_agents` (default off) to bound the cross-repo `gh` fan-out, while single-repo `repo_state`/`my_context` include PRs by default.

#### Scenario: Live in-progress edits independent of locks
- **WHEN** a lane has uncommitted changes that have not been lock-claimed (locks materialize at commit time)
- **THEN** the lane's `dirty` field lists those changed files (excluding gitguardex runtime state under `.omx/`/`.omc/`), giving a live "currently editing" signal.

#### Scenario: Cross-worktree lock ownership
- **WHEN** `who_owns(file)` is called for a path locked by another agent in a different worktree
- **THEN** it returns that branch/agent as the owner, aggregating per-worktree lock files across the whole repo
- **AND** returns `owner: null` for an unclaimed path.

#### Scenario: Surface unsafe primary-checkout editing
- **WHEN** a lane is the primary checkout sitting on a non-protected branch
- **THEN** its record includes a warning that edits there risk auto-stash/revert.

#### Scenario: Best-effort, never-throwing PR lookup
- **WHEN** a branch has no upstream, or `gh` is missing/unauthenticated
- **THEN** the PR field is `null` and the call still succeeds (no throw, no partial failure).

### Requirement: Dependency-free server and opt-in registration
The MCP server SHALL be implemented without adding a third-party MCP SDK
dependency, and SHALL NOT be wired into any agent harness automatically.

#### Scenario: Registration guidance
- **WHEN** the user runs `gx mcp register`
- **THEN** the command prints how to register the server (`claude mcp add` and a `.mcp.json` snippet) without modifying any configuration file.
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-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33`; branch=`agent/<your-name>/<branch-slug>`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
- Copy prompt: Continue `agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33` on branch `agent/<your-name>/<branch-slug>`. Work inside the existing sandbox, review `openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/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/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33`.
- [x] 1.2 Define normative requirements in `specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/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-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33 --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/<your-name>/<branch-slug> --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).
119 changes: 119 additions & 0 deletions src/cli/commands/mcp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
'use strict';

// `gx mcp` — cross-repo, read-only multi-agent observability over MCP.
// gx mcp serve run the stdio MCP server (for agent harnesses)
// gx mcp list-agents one-shot human/debug view of all agent lanes
// gx mcp who-owns <file> who holds the lock on a file
// gx mcp register print how to wire the server into Claude Code / Codex

const collect = require('../../mcp/collect');
const server = require('../../mcp/server');
const { SHORT_TOOL_NAME } = require('../../context');

function printUsage() {
process.stdout.write(
[
`Usage: ${SHORT_TOOL_NAME} mcp <serve|list-agents|who-owns|register>`,
'',
' serve Run the read-only MCP server over stdio.',
' list-agents [--json] [--no-prs]',
' Show every active agent lane across all repos.',
' who-owns <file> [--json]',
' Which agent/branch holds the lock on <file>.',
' register Print how to register the server with an agent.',
'',
].join('\n') + '\n',
);
}

function fmtAgent(a) {
const pr = a.pr ? `PR #${a.pr.number} (${a.pr.state}${a.pr.isDraft ? ', draft' : ''})` : a.pushed ? 'pushed, no open PR' : 'local only';
const locks = a.locks && a.locks.length ? `${a.locks.length} lock(s)` : 'no locks';
const dirty = a.dirty && a.dirty.length ? `editing ${a.dirty.length} file(s)` : 'clean';
const when = a.lastCommit && a.lastCommit.date ? a.lastCommit.date.replace('T', ' ').replace(/\..*$/, '') : '?';
const warn = a.warning ? ' ⚠ ON PRIMARY CHECKOUT' : '';
return [
`• ${a.repo} ${a.branch}${warn}`,
` agent=${a.agent || '?'} task=${a.task}`,
` ${dirty} ${locks} ${pr} last=${when}`,
` worktree=${a.worktree}`,
].join('\n');
}

function listAgents(rest) {
const includePrs = !rest.includes('--no-prs');
const data = collect.collectAllAgents({ includePrs });
if (rest.includes('--json')) {
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
return;
}
const header = `gx agents — ${data.agents.length} active lane(s) across ${data.scannedRepos} repo(s)`;
const body = data.agents.length ? data.agents.map(fmtAgent).join('\n') : ' (no active agent lanes found)';
process.stdout.write(`${header}\n\n${body}\n`);
if (data.errors && data.errors.length) {
process.stdout.write(`\n${data.errors.length} repo(s) errored during scan (run with --json for detail)\n`);
}
}

function whoOwns(rest) {
const file = rest.find((a) => !a.startsWith('--'));
const result = collect.whoOwns(file, {});
if (rest.includes('--json')) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
if (!result.owner) {
process.stdout.write(`${result.file || file}: unclaimed${result.error ? ` (${result.error})` : ''}\n`);
return;
}
process.stdout.write(`${result.file}: locked by ${result.owner.agent || result.owner.branch} (branch ${result.owner.branch})\n`);
}

function register() {
process.stdout.write(
[
'Register the read-only gx agent-observability MCP with your harness:',
'',
'Claude Code (user scope — available in every repo):',
` claude mcp add gx -s user -- ${SHORT_TOOL_NAME} mcp serve`,
'',
'Or add to a repo-root .mcp.json:',
' {',
' "mcpServers": {',
` "gx": { "command": "${SHORT_TOOL_NAME}", "args": ["mcp", "serve"] }`,
' }',
' }',
'',
'Codex / other MCP clients: run `gx mcp serve` as a stdio MCP server.',
'Tools exposed (all read-only): list_agents, repo_state, who_owns, my_context.',
'',
].join('\n') + '\n',
);
}

function mcp(rawArgs = []) {
const [subcommand, ...rest] = rawArgs;
if (subcommand === 'serve') {
server.serve(); // long-running: readline keeps the process alive
return;
}
if (subcommand === 'list-agents' || subcommand === 'list' || subcommand === 'agents') {
listAgents(rest);
process.exitCode = 0;
return;
}
if (subcommand === 'who-owns' || subcommand === 'who') {
whoOwns(rest);
process.exitCode = 0;
return;
}
if (subcommand === 'register' || subcommand === 'print-config') {
register();
process.exitCode = 0;
return;
}
printUsage();
process.exitCode = subcommand ? 1 : 0;
}

module.exports = { mcp };
2 changes: 2 additions & 0 deletions src/cli/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const { review, prReview } = require('./commands/review');
const { pr: prCommand } = require('./commands/pr');
const { claude: claudeCommand } = require('./commands/claude');
const { agents } = require('./commands/agents');
const { mcp } = require('./commands/mcp');
const { report } = require('./commands/report');
const { release } = require('./commands/release');
const { watch } = require('./commands/watch');
Expand Down Expand Up @@ -222,6 +223,7 @@ async function main() {
if (command === 'install-agent-skills') return installAgentSkills(rest);
if (command === 'internal') return internal(rest);
if (command === 'agents') return agents(rest);
if (command === 'mcp') return mcp(rest);
if (command === 'cockpit') return cockpit(rest);
if (command === 'merge') return merge(rest);
if (command === 'finish') return finish(rest);
Expand Down
Loading
Loading