diff --git a/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/.openspec.yaml b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/.openspec.yaml new file mode 100644 index 0000000..c53ef21 --- /dev/null +++ b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-05 diff --git a/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/proposal.md b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/proposal.md new file mode 100644 index 0000000..bf83bcf --- /dev/null +++ b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/proposal.md @@ -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`. diff --git a/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/spec.md b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/spec.md new file mode 100644 index 0000000..71a0ca2 --- /dev/null +++ b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/specs/gx-mcp-cross-repo-read-only-multi-agent-observability-server/spec.md @@ -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. diff --git a/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/tasks.md b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/tasks.md new file mode 100644 index 0000000..2d6cb45 --- /dev/null +++ b/openspec/changes/agent-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33/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-claude-gx-mcp-cross-repo-read-only-multi-agent-2026-06-05-12-33`; branch=`agent//`; 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//`. 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// --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// --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/cli/commands/mcp.js b/src/cli/commands/mcp.js new file mode 100644 index 0000000..9716c30 --- /dev/null +++ b/src/cli/commands/mcp.js @@ -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 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 Run the read-only MCP server over stdio.', + ' list-agents [--json] [--no-prs]', + ' Show every active agent lane across all repos.', + ' who-owns [--json]', + ' Which agent/branch holds the lock on .', + ' 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 }; diff --git a/src/cli/main.js b/src/cli/main.js index 85d1114..28d436e 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -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'); @@ -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); diff --git a/src/mcp/collect.js b/src/mcp/collect.js new file mode 100644 index 0000000..9a469f2 --- /dev/null +++ b/src/mcp/collect.js @@ -0,0 +1,320 @@ +'use strict'; + +// Read-only collector for the gx MCP server. Assembles a cross-repo picture of +// "which agent is on which branch / worktree / PR, and what file locks they +// hold" purely from git + gitguardex on-disk state — no manual bookkeeping. +// +// Sources (all already maintained by gitguardex): +// - repo discovery : cockpit/projects-finder.findProjects() +// - branches/worktrees: `git worktree list --porcelain` +// - file locks : .omx/state/agent-file-locks.json +// - PR state : pr.findOpenPrForBranch() (gh, best-effort) + +const cp = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { findProjects } = require('../cockpit/projects-finder'); +const { findOpenPrForBranch } = require('../pr'); + +const PROTECTED_BRANCHES = new Set(['main', 'master', 'dev']); +const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json'); + +function git(repoRoot, args) { + // Bounded: a hung git call must not stall the whole MCP request past the + // client timeout. On timeout spawnSync sets status=null -> we return null. + const res = cp.spawnSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + timeout: 7000, + maxBuffer: 8 * 1024 * 1024, + }); + if (!res || res.status !== 0) return null; + return (res.stdout || '').trim(); +} + +// Files an agent is changing RIGHT NOW in a worktree (uncommitted). Unlike +// locks (written at commit time), this reflects in-progress edits — the most +// direct "who is working on what" signal. +function dirtyFiles(worktreePath, cap = 25) { + // NB: parse RAW stdout (not the trimmed git() helper) — porcelain is + // column-sensitive ("XY PATH"); trimming eats the first line's leading + // status space and shifts the path by one. + const res = cp.spawnSync('git', ['status', '--porcelain'], { + cwd: worktreePath, + encoding: 'utf8', + timeout: 7000, + maxBuffer: 8 * 1024 * 1024, + }); + if (!res || res.status !== 0 || !res.stdout) return []; + const files = res.stdout + .split('\n') + .filter((line) => line.length > 3) + .map((line) => line.slice(3)) + .filter(Boolean) + // Exclude gitguardex runtime state — it's bookkeeping churn, not the + // agent's work (and is gitignored in real repos anyway). + .filter((f) => !f.startsWith('.omx/') && !f.startsWith('.omc/')); + if (files.length <= cap) return files; + return files.slice(0, cap).concat([`…(+${files.length - cap} more)`]); +} + +function isProtectedBranch(branch) { + return !branch || branch === 'HEAD' || PROTECTED_BRANCHES.has(branch); +} + +function parseAgentName(branch) { + // agent// -> name + const parts = String(branch || '').split('/'); + if (parts.length >= 3 && parts[0] === 'agent') return parts[1]; + return null; +} + +function humanizeSlug(branch) { + const parts = String(branch || '').split('/'); + const slug = (parts.length >= 3 ? parts.slice(2).join('/') : parts.slice(1).join('/')) || branch; + return slug.replace(/-\d{4}-\d{2}-\d{2}.*$/, '').replace(/-/g, ' ').trim() || branch; +} + +function repoName(repoPath) { + return path.basename(repoPath || ''); +} + +function listWorktrees(repoRoot) { + const out = git(repoRoot, ['worktree', 'list', '--porcelain']); + if (out == null) return []; + const worktrees = []; + out.split(/\n\n+/).forEach((block, idx) => { + let wtPath = null; + let branch = null; + let head = null; + let detached = false; + for (const line of block.split('\n')) { + if (line.startsWith('worktree ')) wtPath = line.slice(9).trim(); + else if (line.startsWith('branch ')) branch = line.slice(7).trim().replace(/^refs\/heads\//, ''); + else if (line.startsWith('HEAD ')) head = line.slice(5).trim(); + else if (line.trim() === 'detached') detached = true; + } + if (wtPath) worktrees.push({ path: wtPath, branch: detached ? null : branch, head, isPrimary: idx === 0 }); + }); + return worktrees; +} + +function readLockMap(repoRoot) { + const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE); + let raw; + try { + raw = fs.readFileSync(lockPath, 'utf8'); + } catch { + return {}; + } + try { + const data = JSON.parse(raw); + return (data && data.locks) || {}; + } catch { + // stdout is reserved for JSON-RPC; surface the problem on stderr so a + // poisoned lock file doesn't silently hide claims. + process.stderr.write(`[gx mcp] warning: ignoring corrupt lock file ${lockPath}\n`); + return {}; + } +} + +function locksByBranch(repoRoot) { + const map = readLockMap(repoRoot); + const byBranch = {}; + for (const [file, meta] of Object.entries(map)) { + const b = meta && meta.branch; + if (!b) continue; + (byBranch[b] = byBranch[b] || []).push(file); + } + return byBranch; +} + +// Resolve the MAIN repository root from any path inside it (a linked agent +// worktree resolves up to the primary checkout). Worktrees share one ref store +// via --git-common-dir, so all git ref ops below run against the main root. +function mainRepoRoot(somePath) { + const top = git(somePath, ['rev-parse', '--show-toplevel']); + if (!top) return null; + const common = git(somePath, ['rev-parse', '--git-common-dir']); + if (!common) return top; + const commonAbs = path.isAbsolute(common) ? common : path.resolve(top, common); + return path.basename(commonAbs) === '.git' ? path.dirname(commonAbs) : top; +} + +function branchHasUpstream(repoRoot, branch) { + return Boolean(git(repoRoot, ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`])); +} + +function lastCommit(repoRoot, branch) { + const out = git(repoRoot, ['log', '-1', '--format=%cI%x09%s', branch]); + if (!out) return null; + const tab = out.indexOf('\t'); + if (tab === -1) return { date: out, subject: '' }; + return { date: out.slice(0, tab), subject: out.slice(tab + 1) }; +} + +// Best-effort PR lookup. Skips gh entirely for un-pushed branches, and never +// throws (gh missing / unauthed / offline -> null). +function safePr(repoRoot, branch) { + if (!branchHasUpstream(repoRoot, branch)) return null; + try { + const pr = findOpenPrForBranch(repoRoot, branch); + if (!pr) return null; + return { + number: pr.number, + url: pr.url, + state: pr.state, + isDraft: pr.isDraft, + title: pr.title, + baseRefName: pr.baseRefName, + reviewDecision: pr.reviewDecision || null, + mergeable: pr.mergeable || null, + mergeStateStatus: pr.mergeStateStatus || null, + }; + } catch { + return null; + } +} + +function buildAgentRecord(mainRoot, wt, locks, includePrs) { + const branch = wt.branch; + const record = { + repo: repoName(mainRoot), + repoPath: mainRoot, + branch, + agent: parseAgentName(branch), + task: humanizeSlug(branch), + worktree: wt.path, + onPrimaryCheckout: Boolean(wt.isPrimary), + pushed: branchHasUpstream(mainRoot, branch), + dirty: dirtyFiles(wt.path), + locks, + lastCommit: lastCommit(mainRoot, branch), + pr: includePrs ? safePr(mainRoot, branch) : null, + }; + if (wt.isPrimary) { + record.warning = + 'on the PRIMARY checkout, not an isolated worktree — edits here risk auto-stash/revert when another lane switches branches. Use `gx branch start`.'; + } + return record; +} + +function collectRepoAgents(repoPath, { includePrs = true } = {}) { + const mainRoot = mainRepoRoot(repoPath) || repoPath; + const worktrees = listWorktrees(mainRoot); + if (worktrees.length === 0) return []; + const agents = []; + for (const wt of worktrees) { + // Skip the safe/normal states: primary on a protected base, detached + // worktrees, and the rare protected-branch linked worktree. What remains + // is an active agent lane (or an agent editing on primary, surfaced with a + // warning). + if (!wt.branch) continue; + if (isProtectedBranch(wt.branch) && !wt.isPrimary) continue; + if (wt.isPrimary && isProtectedBranch(wt.branch)) continue; + // Each worktree owns its OWN lock file; a lane's locks are the entries in + // its own worktree keyed to its branch. + const locks = locksByBranch(wt.path)[wt.branch] || []; + agents.push(buildAgentRecord(mainRoot, wt, locks, includePrs)); + } + return agents; +} + +function collectAllAgents({ roots, includePrs = true, limit } = {}) { + const found = findProjects(roots && roots.length ? { roots } : {}); + const projects = Array.isArray(found.projects) ? found.projects : []; + // Collapse discovered paths to unique MAIN repo roots — a repo and its linked + // worktrees must not be counted as separate "repos". + const seen = new Set(); + const mainRoots = []; + for (const project of projects) { + const root = mainRepoRoot(project.path) || project.path; + if (seen.has(root)) continue; + seen.add(root); + mainRoots.push(root); + if (limit && mainRoots.length >= limit) break; + } + const agents = []; + const errors = []; + for (const root of mainRoots) { + try { + agents.push(...collectRepoAgents(root, { includePrs })); + } catch (err) { + errors.push({ repo: root, error: String((err && err.message) || err) }); + } + } + agents.sort((a, b) => { + const da = (a.lastCommit && a.lastCommit.date) || ''; + const db = (b.lastCommit && b.lastCommit.date) || ''; + return db.localeCompare(da); // most recent activity first + }); + return { agents, scannedRepos: mainRoots.length, roots: found.roots || [], errors }; +} + +function repoState(repoOrCwd, { includePrs = true } = {}) { + const root = mainRepoRoot(repoOrCwd) || repoOrCwd; + return { repo: repoName(root), repoPath: root, agents: collectRepoAgents(root, { includePrs }) }; +} + +// Aggregate locks across ALL worktrees of the repo. Lock files are per-worktree +// on disk, so a single worktree's file only shows its own claims — the +// collision view requires the union. +function whoOwns(file, { cwd = process.cwd(), repoPath } = {}) { + if (!file) return { file: null, owner: null, error: 'no file given' }; + const mainRoot = mainRepoRoot(repoPath || cwd); + if (!mainRoot) return { file, owner: null, error: 'not a git repo' }; + const rel = path.isAbsolute(file) ? path.relative(mainRoot, file) : file; + const owners = []; + const seenBranch = new Set(); + for (const wt of listWorktrees(mainRoot)) { + const map = readLockMap(wt.path); + const entry = map[rel] || map[file]; + if (entry && entry.branch && !seenBranch.has(entry.branch)) { + seenBranch.add(entry.branch); + owners.push({ + branch: entry.branch, + agent: parseAgentName(entry.branch), + claimed_at: entry.claimed_at || null, + worktree: wt.path, + }); + } + } + if (owners.length === 0) return { file: rel, owner: null }; + return { file: rel, owner: owners.length === 1 ? owners[0] : null, owners, conflict: owners.length > 1 }; +} + +function myContext({ cwd = process.cwd(), includePr = true } = {}) { + const here = git(cwd, ['rev-parse', '--show-toplevel']); + if (!here) return { error: 'not a git repo', cwd }; + const mainRoot = mainRepoRoot(cwd) || here; + const branch = git(here, ['rev-parse', '--abbrev-ref', 'HEAD']); + const self = listWorktrees(mainRoot).find((w) => path.resolve(w.path) === path.resolve(here)); + return { + repo: repoName(mainRoot), + repoPath: mainRoot, + worktree: here, + branch, + agent: parseAgentName(branch), + onPrimaryCheckout: self ? Boolean(self.isPrimary) : null, + protected: isProtectedBranch(branch), + dirty: dirtyFiles(here), + locks: branch ? locksByBranch(here)[branch] || [] : [], // this lane's own claims + pr: includePr && branch ? safePr(mainRoot, branch) : null, + lastCommit: branch ? lastCommit(mainRoot, branch) : null, + }; +} + +module.exports = { + collectAllAgents, + collectRepoAgents, + repoState, + whoOwns, + myContext, + listWorktrees, + locksByBranch, + parseAgentName, + humanizeSlug, + isProtectedBranch, + LOCK_FILE_RELATIVE, +}; diff --git a/src/mcp/server.js b/src/mcp/server.js new file mode 100644 index 0000000..0fe0d41 --- /dev/null +++ b/src/mcp/server.js @@ -0,0 +1,157 @@ +'use strict'; + +// Minimal Model Context Protocol server over stdio, hand-rolled to keep +// gitguardex dependency-light (no @modelcontextprotocol/sdk). MCP stdio is +// newline-delimited JSON-RPC 2.0; we implement the small surface an agent +// needs: initialize, tools/list, tools/call, ping. +// +// All tools are READ-ONLY — the server only reflects git/worktree/lock/PR +// state, it never mutates a repo. + +const readline = require('node:readline'); + +const collect = require('./collect'); +const { packageJson } = require('../context'); + +const PROTOCOL_VERSION = '2024-11-05'; + +const TOOLS = [ + { + name: 'list_agents', + description: + 'List every active agent lane across all discovered repos: repo, branch, worktree, task, dirty (files changed RIGHT NOW), held file locks, last commit, the PR it is shipping (opt-in), and warnings — e.g. a lane editing the primary checkout (the harness should act on that warning by moving to `gx branch start`). Use this to see who is working on what before you start. Read-only.', + inputSchema: { + type: 'object', + properties: { + include_prs: { + type: 'boolean', + description: 'Fetch PR state per pushed branch via gh (slower, network fan-out). Default FALSE here — pass true when you need PRs, or use repo_state for one repo.', + }, + roots: { + type: 'array', + items: { type: 'string' }, + description: 'Override repo search roots. Default: ~/Documents, ~/code, ~/src, ~/projects.', + }, + limit: { type: 'number', description: 'Max number of repos to scan.' }, + }, + }, + }, + { + name: 'repo_state', + description: + 'Agent lanes for a single repository (branches, worktrees, file locks, PRs). Pass a repo path; defaults to the current working repo.', + inputSchema: { + type: 'object', + properties: { + repo: { type: 'string', description: 'Path inside the target repo. Defaults to cwd.' }, + include_prs: { type: 'boolean', description: 'Fetch PR state. Default true.' }, + }, + }, + }, + { + name: 'who_owns', + description: + 'Check which agent/branch holds the gitguardex file lock on a path BEFORE you edit it, to avoid colliding with another agent. Returns owner=null when the file is unclaimed.', + inputSchema: { + type: 'object', + properties: { + file: { type: 'string', description: 'Repo-relative or absolute path to check.' }, + repo: { type: 'string', description: 'Path inside the target repo. Defaults to cwd.' }, + }, + required: ['file'], + }, + }, + { + name: 'my_context', + description: + 'Report THIS session: current repo, branch, worktree, whether it is the protected primary checkout (where edits are unsafe), held locks, and the PR for the branch.', + inputSchema: { type: 'object', properties: {} }, + }, +]; + +function callTool(name, args = {}) { + switch (name) { + case 'list_agents': + // PRs are opt-in here: a cross-repo gh fan-out can exceed the client + // timeout. repo_state/my_context (narrow scope) keep PRs on by default. + return collect.collectAllAgents({ + roots: args.roots, + includePrs: args.include_prs === true, + limit: args.limit, + }); + case 'repo_state': + return collect.repoState(args.repo || process.cwd(), { includePrs: args.include_prs !== false }); + case 'who_owns': + return collect.whoOwns(args.file, { repoPath: args.repo }); + case 'my_context': + return collect.myContext({}); + default: + throw new Error(`Unknown tool: ${name}`); + } +} + +function ok(id, result) { + return id === undefined || id === null ? null : { jsonrpc: '2.0', id, result }; +} + +function rpcError(id, code, message) { + return id === undefined || id === null ? null : { jsonrpc: '2.0', id, error: { code, message } }; +} + +// Pure request handler: returns a JSON-RPC response object, or null for +// notifications (no `id`). Kept side-effect-free so it is unit-testable. +function dispatch(msg) { + const { id, method, params } = msg || {}; + const isNotification = id === undefined || id === null; + try { + if (method === 'initialize') { + // Pin the version WE support, regardless of what the client requested — + // echoing an unknown/future version back defeats MCP version negotiation. + return ok(id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'gx', version: (packageJson && packageJson.version) || '0.0.0' }, + }); + } + if (method === 'tools/list') return ok(id, { tools: TOOLS }); + if (method === 'tools/call') { + const name = params && params.name; + const args = (params && params.arguments) || {}; + const result = callTool(name, args); + return ok(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }); + } + if (method === 'ping') return ok(id, {}); + if (isNotification) return null; // e.g. notifications/initialized + return rpcError(id, -32601, `Method not found: ${method}`); + } catch (err) { + const message = String((err && err.message) || err); + // A failing tool call is reported as a tool result (isError), per MCP, so + // the agent sees the error instead of the whole call rejecting. + if (method === 'tools/call' && !isNotification) { + return ok(id, { content: [{ type: 'text', text: `Error: ${message}` }], isError: true }); + } + if (isNotification) return null; + return rpcError(id, -32603, message); + } +} + +function serve({ input = process.stdin, output = process.stdout } = {}) { + const rl = readline.createInterface({ input, terminal: false }); + rl.on('line', (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let msg; + try { + msg = JSON.parse(trimmed); + } catch { + // JSON-RPC 2.0: a parse error is reported with a null id. + output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`); + return; + } + const res = dispatch(msg); + if (res) output.write(`${JSON.stringify(res)}\n`); + }); + return rl; +} + +module.exports = { serve, dispatch, callTool, TOOLS, PROTOCOL_VERSION }; diff --git a/test/mcp-collect.test.js b/test/mcp-collect.test.js new file mode 100644 index 0000000..1424fdf --- /dev/null +++ b/test/mcp-collect.test.js @@ -0,0 +1,140 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const cp = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const collect = require('../src/mcp/collect'); + +function git(dir, args) { + const r = cp.spawnSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd: dir, encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr || r.stdout}`); + return (r.stdout || '').trim(); +} + +function writeLock(worktreePath, locks) { + const dir = path.join(worktreePath, '.omx', 'state'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'agent-file-locks.json'), `${JSON.stringify({ locks }, null, 2)}\n`); +} + +// A main repo on `main` plus two linked agent worktrees, each with its OWN +// per-worktree lock file (mirrors how gitguardex stores locks on disk). +function makeRepoWithLanes() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gxmcp-')); + const main = path.join(root, 'mainrepo'); + fs.mkdirSync(main); + git(main, ['init', '-q', '-b', 'main']); + git(main, ['config', 'user.email', 't@e.com']); + git(main, ['config', 'user.name', 'T']); + git(main, ['config', 'commit.gpgsign', 'false']); + fs.writeFileSync(path.join(main, 'README.md'), 'hi\n'); + git(main, ['add', '.']); + git(main, ['commit', '-q', '-m', 'seed']); + + const wtA = path.join(root, 'wt-alice'); + git(main, ['worktree', 'add', '-q', '-b', 'agent/alice/feature-x', wtA]); + writeLock(wtA, { 'src/x.js': { branch: 'agent/alice/feature-x', claimed_at: '2026-06-05T10:00:00+00:00' } }); + + const wtB = path.join(root, 'wt-bob'); + git(main, ['worktree', 'add', '-q', '-b', 'agent/bob/fix-y', wtB]); + writeLock(wtB, { 'src/y.js': { branch: 'agent/bob/fix-y', claimed_at: '2026-06-05T11:00:00+00:00' } }); + + return { root, main, wtA, wtB }; +} + +test('collectRepoAgents lists each agent lane (not the protected primary) with its own locks', () => { + const { root, main } = makeRepoWithLanes(); + try { + const agents = collect.collectRepoAgents(main, { includePrs: false }); + const branches = agents.map((a) => a.branch).sort(); + assert.deepEqual(branches, ['agent/alice/feature-x', 'agent/bob/fix-y'], 'two lanes, no main'); + const alice = agents.find((a) => a.agent === 'alice'); + assert.equal(alice.repo, 'mainrepo'); + assert.deepEqual(alice.locks, ['src/x.js']); + assert.equal(alice.onPrimaryCheckout, false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('whoOwns aggregates locks across ALL worktrees (per-worktree lock files)', () => { + const { root, main, wtA } = makeRepoWithLanes(); + try { + // Query from main: finds alice's lock that lives in wt-alice's file. + const x = collect.whoOwns('src/x.js', { repoPath: main }); + assert.equal(x.owner.branch, 'agent/alice/feature-x'); + assert.equal(x.owner.agent, 'alice'); + + // Query from ALICE's worktree: still finds BOB's lock (cross-worktree union). + const y = collect.whoOwns('src/y.js', { repoPath: wtA }); + assert.equal(y.owner.agent, 'bob', 'cross-worktree lock visibility is the whole point'); + + const free = collect.whoOwns('README.md', { repoPath: main }); + assert.equal(free.owner, null, 'unclaimed file has no owner'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('myContext resolves the real repo name + this lane from inside a worktree', () => { + const { root, wtA } = makeRepoWithLanes(); + try { + const ctx = collect.myContext({ cwd: wtA, includePr: false }); + assert.equal(ctx.repo, 'mainrepo', 'repo name is the MAIN repo, not the worktree dir'); + assert.equal(ctx.branch, 'agent/alice/feature-x'); + assert.equal(ctx.agent, 'alice'); + assert.equal(ctx.onPrimaryCheckout, false); + assert.equal(ctx.protected, false); + assert.deepEqual(ctx.locks, ['src/x.js']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('collectAllAgents dedupes a repo + its worktrees into one main root', () => { + const { root } = makeRepoWithLanes(); + try { + const data = collect.collectAllAgents({ roots: [root], includePrs: false }); + assert.equal(data.scannedRepos, 1, 'main repo + 2 worktrees collapse to one repo'); + assert.equal(data.agents.filter((a) => a.branch === 'agent/alice/feature-x').length, 1, 'no duplicate lanes'); + assert.equal(data.agents.length, 2); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('a lane reports the files it is editing RIGHT NOW (uncommitted), independent of locks', () => { + const { root, main, wtA } = makeRepoWithLanes(); + try { + // alice has no committed lock for README, but is editing it uncommitted. + fs.writeFileSync(path.join(wtA, 'README.md'), 'work in progress\n'); + const agents = collect.collectRepoAgents(main, { includePrs: false }); + const alice = agents.find((a) => a.agent === 'alice'); + assert.ok(alice.dirty.includes('README.md'), 'in-progress edit shows up in dirty'); + const bob = agents.find((a) => a.agent === 'bob'); + assert.deepEqual(bob.dirty, [], 'a clean lane reports no dirty files'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('an agent editing on the PRIMARY checkout is surfaced with a warning', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gxmcp-primary-')); + try { + git(root, ['init', '-q', '-b', 'feature/on-primary']); + git(root, ['config', 'user.email', 't@e.com']); + git(root, ['config', 'user.name', 'T']); + git(root, ['config', 'commit.gpgsign', 'false']); + fs.writeFileSync(path.join(root, 'f.txt'), 'x\n'); + git(root, ['add', '.']); + git(root, ['commit', '-q', '-m', 'seed']); + const agents = collect.collectRepoAgents(root, { includePrs: false }); + assert.equal(agents.length, 1); + assert.equal(agents[0].onPrimaryCheckout, true); + assert.match(agents[0].warning, /PRIMARY checkout/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/mcp-server.test.js b/test/mcp-server.test.js new file mode 100644 index 0000000..7f382c9 --- /dev/null +++ b/test/mcp-server.test.js @@ -0,0 +1,87 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { PassThrough } = require('node:stream'); + +const server = require('../src/mcp/server'); + +test('initialize returns serverInfo and echoes the protocol version', () => { + const r = server.dispatch({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } }); + assert.equal(r.id, 1); + assert.equal(r.result.serverInfo.name, 'gx'); + assert.equal(r.result.protocolVersion, '2024-11-05'); + assert.ok(r.result.capabilities.tools, 'declares tools capability'); +}); + +test('initialize pins the server protocol version even when the client requests another', () => { + const r = server.dispatch({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2099-01-01' } }); + assert.equal(r.result.protocolVersion, server.PROTOCOL_VERSION, 'server pins its own supported version'); +}); + +test('tools/list returns the four read-only tools, each with a schema', () => { + const r = server.dispatch({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); + const names = r.result.tools.map((t) => t.name).sort(); + assert.deepEqual(names, ['list_agents', 'my_context', 'repo_state', 'who_owns']); + for (const t of r.result.tools) { + assert.ok(t.description, `${t.name} has a description`); + assert.equal(t.inputSchema.type, 'object', `${t.name} has an object input schema`); + } +}); + +test('notifications (no id) produce no response', () => { + assert.equal(server.dispatch({ jsonrpc: '2.0', method: 'notifications/initialized' }), null); +}); + +test('unknown method returns JSON-RPC method-not-found (-32601)', () => { + const r = server.dispatch({ jsonrpc: '2.0', id: 9, method: 'bogus/method' }); + assert.equal(r.error.code, -32601); +}); + +test('tools/call wraps the tool result as JSON text content', () => { + const r = server.dispatch({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'who_owns', arguments: { file: 'definitely-unclaimed-xyz.txt' } }, + }); + assert.equal(r.id, 3); + assert.ok(Array.isArray(r.result.content)); + const parsed = JSON.parse(r.result.content[0].text); + assert.ok('owner' in parsed, 'who_owns result has an owner field'); +}); + +test('a failing tool call comes back as isError, not a thrown rejection', () => { + const r = server.dispatch({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'nope', arguments: {} } }); + assert.equal(r.result.isError, true); + assert.match(r.result.content[0].text, /Unknown tool/); +}); + +test('serve() reads newline-delimited JSON from stdin and writes responses to stdout', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + let out = ''; + output.on('data', (c) => { out += c; }); + const rl = server.serve({ input, output }); + input.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })}\n`); + input.end(); + await new Promise((resolve) => { output.on('end', resolve); rl.on('close', resolve); setTimeout(resolve, 100); }); + rl.close(); + const first = out.trim().split('\n')[0]; + const msg = JSON.parse(first); + assert.equal(msg.id, 1); + assert.equal(msg.result.tools.length, 4); +}); + +test('serve() reports a malformed line as a JSON-RPC parse error (-32700, id null)', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + let out = ''; + output.on('data', (c) => { out += c; }); + const rl = server.serve({ input, output }); + input.write('this is not json\n'); + input.end(); + await new Promise((resolve) => { rl.on('close', resolve); setTimeout(resolve, 100); }); + rl.close(); + const msg = JSON.parse(out.trim().split('\n')[0]); + assert.equal(msg.id, null); + assert.equal(msg.error.code, -32700); +});