@imdeadpool/guardex 7.0.41 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -13
- package/package.json +3 -1
- package/skills/gitguardex/SKILL.md +13 -0
- package/skills/guardex-merge-skills-to-dev/SKILL.md +59 -0
- package/skills/gx-act/SKILL.md +82 -0
- package/src/agents/cleanup-sessions.js +126 -0
- package/src/agents/finish.js +172 -0
- package/src/agents/inspect.js +202 -0
- package/src/agents/launch.js +249 -0
- package/src/agents/registry.js +133 -0
- package/src/agents/selection-panel.js +571 -0
- package/src/agents/sessions.js +151 -0
- package/src/agents/start.js +591 -0
- package/src/agents/status.js +146 -0
- package/src/agents/terminal.js +152 -0
- package/src/budget/index.js +344 -0
- package/src/ci-init/index.js +265 -0
- package/src/cli/args.js +357 -3
- package/src/cli/commands/agents.js +364 -0
- package/src/cli/commands/bootstrap.js +92 -0
- package/src/cli/commands/branch.js +127 -0
- package/src/cli/commands/claude.js +674 -0
- package/src/cli/commands/doctor.js +268 -0
- package/src/cli/commands/finish.js +26 -0
- package/src/cli/commands/mcp.js +122 -0
- package/src/cli/commands/misc.js +304 -0
- package/src/cli/commands/pr.js +439 -0
- package/src/cli/commands/prompt.js +92 -0
- package/src/cli/commands/release.js +305 -0
- package/src/cli/commands/report.js +244 -0
- package/src/cli/commands/review.js +32 -0
- package/src/cli/commands/setup.js +242 -0
- package/src/cli/commands/status.js +338 -0
- package/src/cli/commands/watch.js +234 -0
- package/src/cli/main.js +85 -3613
- package/src/cli/shared/repo-env.js +161 -0
- package/src/cli/shared/sandbox.js +417 -0
- package/src/cli/shared/scaffolding.js +535 -0
- package/src/cli/shared/toolchain-shims.js +420 -0
- package/src/cockpit/action-runner.js +3 -0
- package/src/cockpit/actions.js +80 -0
- package/src/cockpit/control.js +1121 -0
- package/src/cockpit/index.js +426 -0
- package/src/cockpit/kitty-layout.js +549 -0
- package/src/cockpit/kitty-tree.js +144 -0
- package/src/cockpit/logs-reader.js +182 -0
- package/src/cockpit/menu.js +204 -0
- package/src/cockpit/pane-actions.js +597 -0
- package/src/cockpit/pane-menu.js +387 -0
- package/src/cockpit/projects-finder.js +178 -0
- package/src/cockpit/render.js +215 -0
- package/src/cockpit/settings-render.js +128 -0
- package/src/cockpit/settings.js +124 -0
- package/src/cockpit/shortcuts.js +24 -0
- package/src/cockpit/sidebar.js +311 -0
- package/src/cockpit/state.js +72 -0
- package/src/cockpit/theme.js +128 -0
- package/src/cockpit/welcome.js +266 -0
- package/src/context.js +304 -43
- package/src/core/runtime.js +6 -1
- package/src/doctor/index.js +45 -15
- package/src/finish/index.js +186 -7
- package/src/finish/preflight.js +177 -0
- package/src/finish/review-gate.js +182 -0
- package/src/git/index.js +511 -4
- package/src/hooks/index.js +0 -64
- package/src/kitty/command.js +101 -0
- package/src/kitty/runtime.js +250 -0
- package/src/mcp/collect.js +370 -0
- package/src/mcp/server.js +157 -0
- package/src/output/index.js +68 -2
- package/src/pr-review.js +264 -0
- package/src/pr.js +381 -0
- package/src/sandbox/index.js +13 -2
- package/src/scaffold/agent-worktree-prep.js +213 -0
- package/src/scaffold/index.js +127 -10
- package/src/speckit/index.js +226 -0
- package/src/submodule/index.js +288 -0
- package/src/terminal/index.js +45 -0
- package/src/terminal/kitty.js +622 -0
- package/src/terminal/tmux.js +125 -0
- package/src/tmux/command.js +27 -0
- package/src/tmux/session.js +89 -0
- package/src/toolchain/index.js +20 -0
- package/templates/AGENTS.monorepo-apps.md +26 -0
- package/templates/AGENTS.multiagent-safety.md +63 -323
- package/templates/AGENTS.multiagent-safety.min.md +11 -0
- package/templates/codex/skills/gitguardex/SKILL.md +2 -0
- package/templates/codex/skills/gx-act/SKILL.md +82 -0
- package/templates/githooks/pre-commit +44 -20
- package/templates/github/workflows/README.md +87 -0
- package/templates/github/workflows/ci-full.yml +55 -0
- package/templates/github/workflows/ci.yml +56 -0
- package/templates/github/workflows/cr.yml +20 -1
- package/templates/scripts/agent-branch-finish.sh +519 -23
- package/templates/scripts/agent-branch-merge.sh +4 -1
- package/templates/scripts/agent-branch-start.sh +176 -24
- package/templates/scripts/agent-preflight.sh +115 -0
- package/templates/scripts/agent-worktree-prune.sh +96 -5
- package/templates/scripts/codex-agent.sh +41 -97
- package/templates/scripts/openspec/init-plan-workspace.sh +43 -0
- package/templates/scripts/review-bot-watch.sh +31 -2
- package/templates/scripts/agent-session-state.js +0 -171
- package/templates/scripts/install-vscode-active-agents-extension.js +0 -135
- package/templates/vscode/guardex-active-agents/README.md +0 -34
- package/templates/vscode/guardex-active-agents/extension.js +0 -3782
- package/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json +0 -54
- package/templates/vscode/guardex-active-agents/fileicons/icons/agent.svg +0 -5
- package/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg +0 -7
- package/templates/vscode/guardex-active-agents/fileicons/icons/config.svg +0 -4
- package/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg +0 -4
- package/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg +0 -5
- package/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg +0 -4
- package/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg +0 -5
- package/templates/vscode/guardex-active-agents/icon.png +0 -0
- package/templates/vscode/guardex-active-agents/media/active-agents-hivemind.svg +0 -14
- package/templates/vscode/guardex-active-agents/package.json +0 -169
- package/templates/vscode/guardex-active-agents/session-schema.js +0 -1348
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Read-only collector for the gx MCP server. Assembles a cross-repo picture of
|
|
4
|
+
// "which agent is on which branch / worktree / PR, and what file locks they
|
|
5
|
+
// hold" purely from git + gitguardex on-disk state — no manual bookkeeping.
|
|
6
|
+
//
|
|
7
|
+
// Sources (all already maintained by gitguardex):
|
|
8
|
+
// - repo discovery : cockpit/projects-finder.findProjects()
|
|
9
|
+
// - branches/worktrees: `git worktree list --porcelain`
|
|
10
|
+
// - file locks : .omx/state/agent-file-locks.json
|
|
11
|
+
// - PR state : pr.findOpenPrForBranch() (gh, best-effort)
|
|
12
|
+
|
|
13
|
+
const cp = require('node:child_process');
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
|
|
17
|
+
const { findProjects } = require('../cockpit/projects-finder');
|
|
18
|
+
const { findOpenPrForBranch, listOpenPrsForRepo } = require('../pr');
|
|
19
|
+
|
|
20
|
+
const PROTECTED_BRANCHES = new Set(['main', 'master', 'dev']);
|
|
21
|
+
const LOCK_FILE_RELATIVE = path.join('.omx', 'state', 'agent-file-locks.json');
|
|
22
|
+
// A lane older than this (days since last commit), with no open PR and no
|
|
23
|
+
// uncommitted work, is flagged `stale: true` — a candidate for cleanup.
|
|
24
|
+
const STALE_DAYS = Number(process.env.GUARDEX_MCP_STALE_DAYS) || 14;
|
|
25
|
+
|
|
26
|
+
function git(repoRoot, args) {
|
|
27
|
+
// Bounded: a hung git call must not stall the whole MCP request past the
|
|
28
|
+
// client timeout. On timeout spawnSync sets status=null -> we return null.
|
|
29
|
+
const res = cp.spawnSync('git', args, {
|
|
30
|
+
cwd: repoRoot,
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
timeout: 7000,
|
|
33
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
34
|
+
});
|
|
35
|
+
if (!res || res.status !== 0) return null;
|
|
36
|
+
return (res.stdout || '').trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Files an agent is changing RIGHT NOW in a worktree (uncommitted). Unlike
|
|
40
|
+
// locks (written at commit time), this reflects in-progress edits — the most
|
|
41
|
+
// direct "who is working on what" signal.
|
|
42
|
+
function dirtyFiles(worktreePath, cap = 25) {
|
|
43
|
+
// NB: parse RAW stdout (not the trimmed git() helper) — porcelain is
|
|
44
|
+
// column-sensitive ("XY PATH"); trimming eats the first line's leading
|
|
45
|
+
// status space and shifts the path by one.
|
|
46
|
+
const res = cp.spawnSync('git', ['status', '--porcelain'], {
|
|
47
|
+
cwd: worktreePath,
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
timeout: 7000,
|
|
50
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
51
|
+
});
|
|
52
|
+
if (!res || res.status !== 0 || !res.stdout) return [];
|
|
53
|
+
const files = res.stdout
|
|
54
|
+
.split('\n')
|
|
55
|
+
.filter((line) => line.length > 3)
|
|
56
|
+
.map((line) => line.slice(3))
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
// Exclude gitguardex runtime state — it's bookkeeping churn, not the
|
|
59
|
+
// agent's work (and is gitignored in real repos anyway).
|
|
60
|
+
.filter((f) => !f.startsWith('.omx/') && !f.startsWith('.omc/'));
|
|
61
|
+
if (files.length <= cap) return files;
|
|
62
|
+
return files.slice(0, cap).concat([`…(+${files.length - cap} more)`]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isProtectedBranch(branch) {
|
|
66
|
+
return !branch || branch === 'HEAD' || PROTECTED_BRANCHES.has(branch);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseAgentName(branch) {
|
|
70
|
+
// agent/<name>/<slug> -> name
|
|
71
|
+
const parts = String(branch || '').split('/');
|
|
72
|
+
if (parts.length >= 3 && parts[0] === 'agent') return parts[1];
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function humanizeSlug(branch) {
|
|
77
|
+
const parts = String(branch || '').split('/');
|
|
78
|
+
const slug = (parts.length >= 3 ? parts.slice(2).join('/') : parts.slice(1).join('/')) || branch;
|
|
79
|
+
return slug.replace(/-\d{4}-\d{2}-\d{2}.*$/, '').replace(/-/g, ' ').trim() || branch;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function repoName(repoPath) {
|
|
83
|
+
return path.basename(repoPath || '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function listWorktrees(repoRoot) {
|
|
87
|
+
const out = git(repoRoot, ['worktree', 'list', '--porcelain']);
|
|
88
|
+
if (out == null) return [];
|
|
89
|
+
const worktrees = [];
|
|
90
|
+
out.split(/\n\n+/).forEach((block, idx) => {
|
|
91
|
+
let wtPath = null;
|
|
92
|
+
let branch = null;
|
|
93
|
+
let head = null;
|
|
94
|
+
let detached = false;
|
|
95
|
+
for (const line of block.split('\n')) {
|
|
96
|
+
if (line.startsWith('worktree ')) wtPath = line.slice(9).trim();
|
|
97
|
+
else if (line.startsWith('branch ')) branch = line.slice(7).trim().replace(/^refs\/heads\//, '');
|
|
98
|
+
else if (line.startsWith('HEAD ')) head = line.slice(5).trim();
|
|
99
|
+
else if (line.trim() === 'detached') detached = true;
|
|
100
|
+
}
|
|
101
|
+
if (wtPath) worktrees.push({ path: wtPath, branch: detached ? null : branch, head, isPrimary: idx === 0 });
|
|
102
|
+
});
|
|
103
|
+
return worktrees;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readLockMap(repoRoot) {
|
|
107
|
+
const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
|
|
108
|
+
let raw;
|
|
109
|
+
try {
|
|
110
|
+
raw = fs.readFileSync(lockPath, 'utf8');
|
|
111
|
+
} catch {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const data = JSON.parse(raw);
|
|
116
|
+
return (data && data.locks) || {};
|
|
117
|
+
} catch {
|
|
118
|
+
// stdout is reserved for JSON-RPC; surface the problem on stderr so a
|
|
119
|
+
// poisoned lock file doesn't silently hide claims.
|
|
120
|
+
process.stderr.write(`[gx mcp] warning: ignoring corrupt lock file ${lockPath}\n`);
|
|
121
|
+
return {};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function locksByBranch(repoRoot) {
|
|
126
|
+
const map = readLockMap(repoRoot);
|
|
127
|
+
const byBranch = {};
|
|
128
|
+
for (const [file, meta] of Object.entries(map)) {
|
|
129
|
+
const b = meta && meta.branch;
|
|
130
|
+
if (!b) continue;
|
|
131
|
+
(byBranch[b] = byBranch[b] || []).push(file);
|
|
132
|
+
}
|
|
133
|
+
return byBranch;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Resolve the MAIN repository root from any path inside it (a linked agent
|
|
137
|
+
// worktree resolves up to the primary checkout). Worktrees share one ref store
|
|
138
|
+
// via --git-common-dir, so all git ref ops below run against the main root.
|
|
139
|
+
function mainRepoRoot(somePath) {
|
|
140
|
+
const top = git(somePath, ['rev-parse', '--show-toplevel']);
|
|
141
|
+
if (!top) return null;
|
|
142
|
+
const common = git(somePath, ['rev-parse', '--git-common-dir']);
|
|
143
|
+
if (!common) return top;
|
|
144
|
+
const commonAbs = path.isAbsolute(common) ? common : path.resolve(top, common);
|
|
145
|
+
return path.basename(commonAbs) === '.git' ? path.dirname(commonAbs) : top;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function branchHasUpstream(repoRoot, branch) {
|
|
149
|
+
return Boolean(git(repoRoot, ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`]));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function lastCommit(repoRoot, branch) {
|
|
153
|
+
const out = git(repoRoot, ['log', '-1', '--format=%cI%x09%s', branch]);
|
|
154
|
+
if (!out) return null;
|
|
155
|
+
const tab = out.indexOf('\t');
|
|
156
|
+
if (tab === -1) return { date: out, subject: '' };
|
|
157
|
+
return { date: out.slice(0, tab), subject: out.slice(tab + 1) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Best-effort PR lookup. Skips gh entirely for un-pushed branches, and never
|
|
161
|
+
// throws (gh missing / unauthed / offline -> null).
|
|
162
|
+
function safePr(repoRoot, branch) {
|
|
163
|
+
if (!branchHasUpstream(repoRoot, branch)) return null;
|
|
164
|
+
try {
|
|
165
|
+
const pr = findOpenPrForBranch(repoRoot, branch);
|
|
166
|
+
return pr ? slimPr(pr) : null;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function slimPr(pr) {
|
|
173
|
+
return {
|
|
174
|
+
number: pr.number,
|
|
175
|
+
url: pr.url,
|
|
176
|
+
state: pr.state,
|
|
177
|
+
isDraft: pr.isDraft,
|
|
178
|
+
title: pr.title,
|
|
179
|
+
baseRefName: pr.baseRefName,
|
|
180
|
+
reviewDecision: pr.reviewDecision || null,
|
|
181
|
+
mergeable: pr.mergeable || null,
|
|
182
|
+
mergeStateStatus: pr.mergeStateStatus || null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Pure: index a `gh pr list` array by its branch (headRefName) for O(1) lookup.
|
|
187
|
+
function indexPrsByBranch(prs) {
|
|
188
|
+
const map = {};
|
|
189
|
+
for (const pr of prs || []) {
|
|
190
|
+
if (pr && pr.headRefName) map[pr.headRefName] = slimPr(pr);
|
|
191
|
+
}
|
|
192
|
+
return map;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// One gh call per repo -> { map: branch->PR, error }. Best-effort (never throws).
|
|
196
|
+
// `error` is set when the lookup itself failed (gh missing/unauthed/offline),
|
|
197
|
+
// distinct from a successful lookup that found no open PRs.
|
|
198
|
+
function prMapForRepo(mainRoot) {
|
|
199
|
+
try {
|
|
200
|
+
const { prs, error } = listOpenPrsForRepo(mainRoot);
|
|
201
|
+
return { map: indexPrsByBranch(prs), error: error || null };
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return { map: {}, error: String((err && err.message) || err) };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Whole days since an ISO timestamp, or null. Pure (now injected) for testing.
|
|
208
|
+
function daysSince(iso, nowMs) {
|
|
209
|
+
if (!iso) return null;
|
|
210
|
+
const t = Date.parse(iso);
|
|
211
|
+
if (Number.isNaN(t)) return null;
|
|
212
|
+
return Math.floor((nowMs - t) / 86400000);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function buildAgentRecord(mainRoot, wt, locks, prInfo, nowMs) {
|
|
216
|
+
const branch = wt.branch;
|
|
217
|
+
const record = {
|
|
218
|
+
repo: repoName(mainRoot),
|
|
219
|
+
repoPath: mainRoot,
|
|
220
|
+
branch,
|
|
221
|
+
agent: parseAgentName(branch),
|
|
222
|
+
task: humanizeSlug(branch),
|
|
223
|
+
worktree: wt.path,
|
|
224
|
+
onPrimaryCheckout: Boolean(wt.isPrimary),
|
|
225
|
+
pushed: branchHasUpstream(mainRoot, branch),
|
|
226
|
+
dirty: dirtyFiles(wt.path),
|
|
227
|
+
locks,
|
|
228
|
+
lastCommit: lastCommit(mainRoot, branch),
|
|
229
|
+
pr: prInfo ? prInfo.map[branch] || null : null,
|
|
230
|
+
prLookupError: prInfo ? prInfo.error : null,
|
|
231
|
+
};
|
|
232
|
+
// Stale = old, no open PR, no uncommitted work — a safe prune candidate.
|
|
233
|
+
record.ageDays = record.lastCommit ? daysSince(record.lastCommit.date, nowMs) : null;
|
|
234
|
+
record.stale = record.ageDays != null
|
|
235
|
+
&& record.ageDays > STALE_DAYS
|
|
236
|
+
&& !record.pr
|
|
237
|
+
&& record.dirty.length === 0;
|
|
238
|
+
if (wt.isPrimary) {
|
|
239
|
+
record.warning =
|
|
240
|
+
'on the PRIMARY checkout, not an isolated worktree — edits here risk auto-stash/revert when another lane switches branches. Use `gx branch start`.';
|
|
241
|
+
}
|
|
242
|
+
return record;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isAgentLane(wt) {
|
|
246
|
+
// An active agent lane = a worktree on a non-protected branch (or the primary
|
|
247
|
+
// checkout sitting on a working branch, surfaced later with a warning).
|
|
248
|
+
if (!wt.branch) return false;
|
|
249
|
+
if (isProtectedBranch(wt.branch) && !wt.isPrimary) return false;
|
|
250
|
+
if (wt.isPrimary && isProtectedBranch(wt.branch)) return false;
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function collectRepoAgents(repoPath, { includePrs = true } = {}) {
|
|
255
|
+
const mainRoot = mainRepoRoot(repoPath) || repoPath;
|
|
256
|
+
const lanes = listWorktrees(mainRoot).filter(isAgentLane);
|
|
257
|
+
if (lanes.length === 0) return []; // no lanes -> no gh call for this repo
|
|
258
|
+
// ONE gh call for the whole repo, only when there is at least one lane.
|
|
259
|
+
const prInfo = includePrs ? prMapForRepo(mainRoot) : null;
|
|
260
|
+
const nowMs = Date.now();
|
|
261
|
+
return lanes.map((wt) => {
|
|
262
|
+
// Each worktree owns its OWN lock file; a lane's locks are the entries in
|
|
263
|
+
// its own worktree keyed to its branch.
|
|
264
|
+
const locks = locksByBranch(wt.path)[wt.branch] || [];
|
|
265
|
+
return buildAgentRecord(mainRoot, wt, locks, prInfo, nowMs);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function collectAllAgents({ roots, includePrs = true, limit } = {}) {
|
|
270
|
+
const found = findProjects(roots && roots.length ? { roots } : {});
|
|
271
|
+
const projects = Array.isArray(found.projects) ? found.projects : [];
|
|
272
|
+
// Collapse discovered paths to unique MAIN repo roots — a repo and its linked
|
|
273
|
+
// worktrees must not be counted as separate "repos".
|
|
274
|
+
const seen = new Set();
|
|
275
|
+
const mainRoots = [];
|
|
276
|
+
for (const project of projects) {
|
|
277
|
+
const root = mainRepoRoot(project.path) || project.path;
|
|
278
|
+
if (seen.has(root)) continue;
|
|
279
|
+
seen.add(root);
|
|
280
|
+
mainRoots.push(root);
|
|
281
|
+
if (limit && mainRoots.length >= limit) break;
|
|
282
|
+
}
|
|
283
|
+
const agents = [];
|
|
284
|
+
const errors = [];
|
|
285
|
+
for (const root of mainRoots) {
|
|
286
|
+
try {
|
|
287
|
+
agents.push(...collectRepoAgents(root, { includePrs }));
|
|
288
|
+
} catch (err) {
|
|
289
|
+
errors.push({ repo: root, error: String((err && err.message) || err) });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
agents.sort((a, b) => {
|
|
293
|
+
const da = (a.lastCommit && a.lastCommit.date) || '';
|
|
294
|
+
const db = (b.lastCommit && b.lastCommit.date) || '';
|
|
295
|
+
return db.localeCompare(da); // most recent activity first
|
|
296
|
+
});
|
|
297
|
+
return { agents, scannedRepos: mainRoots.length, roots: found.roots || [], errors };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function repoState(repoOrCwd, { includePrs = true } = {}) {
|
|
301
|
+
const root = mainRepoRoot(repoOrCwd) || repoOrCwd;
|
|
302
|
+
return { repo: repoName(root), repoPath: root, agents: collectRepoAgents(root, { includePrs }) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Aggregate locks across ALL worktrees of the repo. Lock files are per-worktree
|
|
306
|
+
// on disk, so a single worktree's file only shows its own claims — the
|
|
307
|
+
// collision view requires the union.
|
|
308
|
+
function whoOwns(file, { cwd = process.cwd(), repoPath } = {}) {
|
|
309
|
+
if (!file) return { file: null, owner: null, error: 'no file given' };
|
|
310
|
+
const mainRoot = mainRepoRoot(repoPath || cwd);
|
|
311
|
+
if (!mainRoot) return { file, owner: null, error: 'not a git repo' };
|
|
312
|
+
const rel = path.isAbsolute(file) ? path.relative(mainRoot, file) : file;
|
|
313
|
+
const owners = [];
|
|
314
|
+
const seenBranch = new Set();
|
|
315
|
+
for (const wt of listWorktrees(mainRoot)) {
|
|
316
|
+
const map = readLockMap(wt.path);
|
|
317
|
+
const entry = map[rel] || map[file];
|
|
318
|
+
if (entry && entry.branch && !seenBranch.has(entry.branch)) {
|
|
319
|
+
seenBranch.add(entry.branch);
|
|
320
|
+
owners.push({
|
|
321
|
+
branch: entry.branch,
|
|
322
|
+
agent: parseAgentName(entry.branch),
|
|
323
|
+
claimed_at: entry.claimed_at || null,
|
|
324
|
+
worktree: wt.path,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (owners.length === 0) return { file: rel, owner: null };
|
|
329
|
+
return { file: rel, owner: owners.length === 1 ? owners[0] : null, owners, conflict: owners.length > 1 };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function myContext({ cwd = process.cwd(), includePr = true } = {}) {
|
|
333
|
+
const here = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
334
|
+
if (!here) return { error: 'not a git repo', cwd };
|
|
335
|
+
const mainRoot = mainRepoRoot(cwd) || here;
|
|
336
|
+
const branch = git(here, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
337
|
+
const self = listWorktrees(mainRoot).find((w) => path.resolve(w.path) === path.resolve(here));
|
|
338
|
+
const lc = branch ? lastCommit(mainRoot, branch) : null;
|
|
339
|
+
return {
|
|
340
|
+
repo: repoName(mainRoot),
|
|
341
|
+
repoPath: mainRoot,
|
|
342
|
+
worktree: here,
|
|
343
|
+
branch,
|
|
344
|
+
agent: parseAgentName(branch),
|
|
345
|
+
onPrimaryCheckout: self ? Boolean(self.isPrimary) : null,
|
|
346
|
+
protected: isProtectedBranch(branch),
|
|
347
|
+
dirty: dirtyFiles(here),
|
|
348
|
+
locks: branch ? locksByBranch(here)[branch] || [] : [], // this lane's own claims
|
|
349
|
+
pr: includePr && branch ? safePr(mainRoot, branch) : null,
|
|
350
|
+
lastCommit: lc,
|
|
351
|
+
ageDays: lc ? daysSince(lc.date, Date.now()) : null,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
module.exports = {
|
|
356
|
+
collectAllAgents,
|
|
357
|
+
collectRepoAgents,
|
|
358
|
+
repoState,
|
|
359
|
+
whoOwns,
|
|
360
|
+
myContext,
|
|
361
|
+
indexPrsByBranch,
|
|
362
|
+
daysSince,
|
|
363
|
+
STALE_DAYS,
|
|
364
|
+
listWorktrees,
|
|
365
|
+
locksByBranch,
|
|
366
|
+
parseAgentName,
|
|
367
|
+
humanizeSlug,
|
|
368
|
+
isProtectedBranch,
|
|
369
|
+
LOCK_FILE_RELATIVE,
|
|
370
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Minimal Model Context Protocol server over stdio, hand-rolled to keep
|
|
4
|
+
// gitguardex dependency-light (no @modelcontextprotocol/sdk). MCP stdio is
|
|
5
|
+
// newline-delimited JSON-RPC 2.0; we implement the small surface an agent
|
|
6
|
+
// needs: initialize, tools/list, tools/call, ping.
|
|
7
|
+
//
|
|
8
|
+
// All tools are READ-ONLY — the server only reflects git/worktree/lock/PR
|
|
9
|
+
// state, it never mutates a repo.
|
|
10
|
+
|
|
11
|
+
const readline = require('node:readline');
|
|
12
|
+
|
|
13
|
+
const collect = require('./collect');
|
|
14
|
+
const { packageJson } = require('../context');
|
|
15
|
+
|
|
16
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
17
|
+
|
|
18
|
+
const TOOLS = [
|
|
19
|
+
{
|
|
20
|
+
name: 'list_agents',
|
|
21
|
+
description:
|
|
22
|
+
'List every active agent lane across all discovered repos: repo, branch, worktree, task, dirty (files changed RIGHT NOW), held file locks, last commit, ageDays, the PR it is shipping (with prLookupError set when the gh lookup itself failed, vs no open PR), stale (old + no PR + clean = a prune candidate), 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.',
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
include_prs: {
|
|
27
|
+
type: 'boolean',
|
|
28
|
+
description: 'Fetch PR state via gh (one call per repo that has lanes). Default true; pass false to skip gh entirely.',
|
|
29
|
+
},
|
|
30
|
+
roots: {
|
|
31
|
+
type: 'array',
|
|
32
|
+
items: { type: 'string' },
|
|
33
|
+
description: 'Override repo search roots. Default: ~/Documents, ~/code, ~/src, ~/projects.',
|
|
34
|
+
},
|
|
35
|
+
limit: { type: 'number', description: 'Max number of repos to scan.' },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'repo_state',
|
|
41
|
+
description:
|
|
42
|
+
'Agent lanes for a single repository (branches, worktrees, file locks, PRs). Pass a repo path; defaults to the current working repo.',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
repo: { type: 'string', description: 'Path inside the target repo. Defaults to cwd.' },
|
|
47
|
+
include_prs: { type: 'boolean', description: 'Fetch PR state. Default true.' },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'who_owns',
|
|
53
|
+
description:
|
|
54
|
+
'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.',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
file: { type: 'string', description: 'Repo-relative or absolute path to check.' },
|
|
59
|
+
repo: { type: 'string', description: 'Path inside the target repo. Defaults to cwd.' },
|
|
60
|
+
},
|
|
61
|
+
required: ['file'],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'my_context',
|
|
66
|
+
description:
|
|
67
|
+
'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.',
|
|
68
|
+
inputSchema: { type: 'object', properties: {} },
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
function callTool(name, args = {}) {
|
|
73
|
+
switch (name) {
|
|
74
|
+
case 'list_agents':
|
|
75
|
+
// PRs on by default: one gh call per repo that has active lanes (most
|
|
76
|
+
// repos have none, so they cost nothing). Pass include_prs:false to skip.
|
|
77
|
+
return collect.collectAllAgents({
|
|
78
|
+
roots: args.roots,
|
|
79
|
+
includePrs: args.include_prs !== false,
|
|
80
|
+
limit: args.limit,
|
|
81
|
+
});
|
|
82
|
+
case 'repo_state':
|
|
83
|
+
return collect.repoState(args.repo || process.cwd(), { includePrs: args.include_prs !== false });
|
|
84
|
+
case 'who_owns':
|
|
85
|
+
return collect.whoOwns(args.file, { repoPath: args.repo });
|
|
86
|
+
case 'my_context':
|
|
87
|
+
return collect.myContext({});
|
|
88
|
+
default:
|
|
89
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ok(id, result) {
|
|
94
|
+
return id === undefined || id === null ? null : { jsonrpc: '2.0', id, result };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function rpcError(id, code, message) {
|
|
98
|
+
return id === undefined || id === null ? null : { jsonrpc: '2.0', id, error: { code, message } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Pure request handler: returns a JSON-RPC response object, or null for
|
|
102
|
+
// notifications (no `id`). Kept side-effect-free so it is unit-testable.
|
|
103
|
+
function dispatch(msg) {
|
|
104
|
+
const { id, method, params } = msg || {};
|
|
105
|
+
const isNotification = id === undefined || id === null;
|
|
106
|
+
try {
|
|
107
|
+
if (method === 'initialize') {
|
|
108
|
+
// Pin the version WE support, regardless of what the client requested —
|
|
109
|
+
// echoing an unknown/future version back defeats MCP version negotiation.
|
|
110
|
+
return ok(id, {
|
|
111
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
112
|
+
capabilities: { tools: {} },
|
|
113
|
+
serverInfo: { name: 'gx', version: (packageJson && packageJson.version) || '0.0.0' },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (method === 'tools/list') return ok(id, { tools: TOOLS });
|
|
117
|
+
if (method === 'tools/call') {
|
|
118
|
+
const name = params && params.name;
|
|
119
|
+
const args = (params && params.arguments) || {};
|
|
120
|
+
const result = callTool(name, args);
|
|
121
|
+
return ok(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
|
|
122
|
+
}
|
|
123
|
+
if (method === 'ping') return ok(id, {});
|
|
124
|
+
if (isNotification) return null; // e.g. notifications/initialized
|
|
125
|
+
return rpcError(id, -32601, `Method not found: ${method}`);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const message = String((err && err.message) || err);
|
|
128
|
+
// A failing tool call is reported as a tool result (isError), per MCP, so
|
|
129
|
+
// the agent sees the error instead of the whole call rejecting.
|
|
130
|
+
if (method === 'tools/call' && !isNotification) {
|
|
131
|
+
return ok(id, { content: [{ type: 'text', text: `Error: ${message}` }], isError: true });
|
|
132
|
+
}
|
|
133
|
+
if (isNotification) return null;
|
|
134
|
+
return rpcError(id, -32603, message);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function serve({ input = process.stdin, output = process.stdout } = {}) {
|
|
139
|
+
const rl = readline.createInterface({ input, terminal: false });
|
|
140
|
+
rl.on('line', (line) => {
|
|
141
|
+
const trimmed = line.trim();
|
|
142
|
+
if (!trimmed) return;
|
|
143
|
+
let msg;
|
|
144
|
+
try {
|
|
145
|
+
msg = JSON.parse(trimmed);
|
|
146
|
+
} catch {
|
|
147
|
+
// JSON-RPC 2.0: a parse error is reported with a null id.
|
|
148
|
+
output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const res = dispatch(msg);
|
|
152
|
+
if (res) output.write(`${JSON.stringify(res)}\n`);
|
|
153
|
+
});
|
|
154
|
+
return rl;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { serve, dispatch, callTool, TOOLS, PROTOCOL_VERSION };
|
package/src/output/index.js
CHANGED
|
@@ -32,6 +32,48 @@ function supportsAnsiColors() {
|
|
|
32
32
|
return Boolean(process.stdout.isTTY) && process.env.TERM !== 'dumb';
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// envTruthy returns true when an env var is set to a truthy-ish string. Used
|
|
36
|
+
// by isTerseMode so callers can flip terse / verbose without parsing flags.
|
|
37
|
+
function envTruthy(name) {
|
|
38
|
+
const value = String(process.env[name] || '').trim().toLowerCase();
|
|
39
|
+
return value === '1' || value === 'true' || value === 'yes' || value === 'on';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// argvHasFlag scans the current process argv for a literal flag. This avoids
|
|
43
|
+
// re-parsing every command's argument table just to learn whether the caller
|
|
44
|
+
// asked for verbose / terse output.
|
|
45
|
+
function argvHasFlag(flag) {
|
|
46
|
+
const argv = process.argv || [];
|
|
47
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
48
|
+
if (argv[index] === flag) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// isTerseMode collapses noisy human-friendly narration when stdout is being
|
|
56
|
+
// piped (typical for AI coding agents that read CLI output into a conversation
|
|
57
|
+
// transcript). Explicit verbose flags / env vars always win so operators can
|
|
58
|
+
// recover full output. Decorative blank lines, banners, and "[OK] default
|
|
59
|
+
// chosen" confirmations should be gated through this helper; errors, blockers,
|
|
60
|
+
// PR URLs, branch names, and file paths must stay visible in both modes.
|
|
61
|
+
function isTerseMode() {
|
|
62
|
+
if (envTruthy('GUARDEX_VERBOSE')) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (argvHasFlag('--verbose')) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (envTruthy('GUARDEX_TERSE')) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
if (argvHasFlag('--terse')) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return !process.stdout.isTTY;
|
|
75
|
+
}
|
|
76
|
+
|
|
35
77
|
function colorize(text, colorCode) {
|
|
36
78
|
if (!supportsAnsiColors()) {
|
|
37
79
|
return text;
|
|
@@ -231,7 +273,9 @@ function getInvokedCliName() {
|
|
|
231
273
|
|
|
232
274
|
function printToolLogsSummary(options = {}) {
|
|
233
275
|
const invoked = options.invokedBasename || getInvokedCliName();
|
|
234
|
-
|
|
276
|
+
// Terse mode collapses the full help tree into a single hint line so agents
|
|
277
|
+
// reading non-TTY output don't pay for the decorative banners.
|
|
278
|
+
const compact = Boolean(options.compact) || isTerseMode();
|
|
235
279
|
|
|
236
280
|
if (compact) {
|
|
237
281
|
const helpLine = `Try '${invoked} help' for commands, or '${invoked} status --verbose' for full service details.`;
|
|
@@ -320,6 +364,27 @@ function usage(options = {}) {
|
|
|
320
364
|
const { outsideGitRepo = false } = options;
|
|
321
365
|
const invoked = options.invokedBasename || getInvokedCliName();
|
|
322
366
|
|
|
367
|
+
// In terse mode (default when stdout is non-TTY, e.g. agents piping output),
|
|
368
|
+
// drop the long NOTES / VERSION / QUICKSTART / REPO TOGGLE sections and emit
|
|
369
|
+
// just usage + command catalog. Errors and the outside-git-repo hint stay so
|
|
370
|
+
// agents still see actionable next steps.
|
|
371
|
+
if (isTerseMode()) {
|
|
372
|
+
const groupedCommandLinesTerse = groupedCommandCatalogLines(' ', {
|
|
373
|
+
colorizeLabel: (text) => text,
|
|
374
|
+
})
|
|
375
|
+
.map((line) => (line == null ? '' : line))
|
|
376
|
+
.join('\n');
|
|
377
|
+
console.log(`USAGE: ${invoked} <command> [options]
|
|
378
|
+
COMMANDS
|
|
379
|
+
${groupedCommandLinesTerse}`);
|
|
380
|
+
if (outsideGitRepo) {
|
|
381
|
+
console.log(
|
|
382
|
+
`[${TOOL_NAME}] No git repository detected. Re-run from a repo root or pass --target <path>.`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
323
388
|
const groupedCommandLines = groupedCommandCatalogLines(' ', {
|
|
324
389
|
colorizeLabel: (text) => colorize(text, '1;36'),
|
|
325
390
|
})
|
|
@@ -347,7 +412,7 @@ REPO TOGGLE
|
|
|
347
412
|
${repoToggleLines().join('\n')}
|
|
348
413
|
|
|
349
414
|
NOTES
|
|
350
|
-
- No command = ${invoked} status (compact
|
|
415
|
+
- No command = ${invoked} status (compact by default; pass --verbose for full services + help tree).
|
|
351
416
|
- ${invoked} init is an alias of ${invoked} setup.
|
|
352
417
|
- Global installs need Y/N approval; GitHub CLI (gh) is required for PR automation.
|
|
353
418
|
- Target another repo: ${invoked} <cmd> --target <repo-path>.
|
|
@@ -578,6 +643,7 @@ function printAutoFinishSummary(summary, options = {}) {
|
|
|
578
643
|
module.exports = {
|
|
579
644
|
runtimeVersion,
|
|
580
645
|
supportsAnsiColors,
|
|
646
|
+
isTerseMode,
|
|
581
647
|
colorize,
|
|
582
648
|
doctorOutputColorCode,
|
|
583
649
|
colorizeDoctorOutput,
|