@agilemonster/mcp-server 1.0.22 → 1.0.24
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 +33 -0
- package/dist/agent-engine.d.ts +132 -0
- package/dist/agent-engine.js +517 -0
- package/dist/client.d.ts +15 -2
- package/dist/client.js +23 -2
- package/dist/deploy-profile.d.ts +26 -1
- package/dist/deploy-profile.js +69 -1
- package/dist/deploy-runner.js +3 -1
- package/dist/server.d.ts +24 -2
- package/dist/server.js +679 -286
- package/dist/types.d.ts +19 -0
- package/package.json +1 -1
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { agileMonsterHome, exec, execShell, readJson, which, writeJsonAtomic } from './runtime.js';
|
|
6
|
+
/** The last complete JSON value an agent CLI printed (they print one result object, sometimes after other lines). */
|
|
7
|
+
function lastJsonValue(stdout) {
|
|
8
|
+
const trimmed = stdout.trim();
|
|
9
|
+
if (!trimmed)
|
|
10
|
+
return null;
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(trimmed);
|
|
13
|
+
}
|
|
14
|
+
catch { }
|
|
15
|
+
const lines = trimmed.split('\n');
|
|
16
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
17
|
+
const line = lines[i].trim();
|
|
18
|
+
if (!line.startsWith('{') && !line.startsWith('['))
|
|
19
|
+
continue;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(line);
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
function resultObject(stdout) {
|
|
28
|
+
const value = lastJsonValue(stdout);
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
for (let i = value.length - 1; i >= 0; i--) {
|
|
31
|
+
if (value[i] && typeof value[i] === 'object' && value[i].type === 'result')
|
|
32
|
+
return value[i];
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return value && typeof value === 'object' ? value : null;
|
|
37
|
+
}
|
|
38
|
+
const ENGINES = [
|
|
39
|
+
{
|
|
40
|
+
id: 'claude',
|
|
41
|
+
label: 'Claude Code',
|
|
42
|
+
providers: ['claude_code'],
|
|
43
|
+
bins: ['claude'],
|
|
44
|
+
installHint: 'npm install -g @anthropic-ai/claude-code',
|
|
45
|
+
args: (opts) => {
|
|
46
|
+
// The prompt directly follows -p; the list-valued flags come last so they cannot swallow it.
|
|
47
|
+
const args = ['-p', opts.prompt, '--output-format', 'json', '--permission-mode', 'acceptEdits'];
|
|
48
|
+
if (opts.resumeSessionId)
|
|
49
|
+
args.push('--resume', opts.resumeSessionId);
|
|
50
|
+
if (opts.model && /^(claude|opus|sonnet|haiku|fable)/i.test(opts.model))
|
|
51
|
+
args.push('--model', opts.model);
|
|
52
|
+
// Shell access is what lets it run the build and the tests. The card's status belongs to the runner.
|
|
53
|
+
args.push('--allowedTools', 'Bash', '--disallowedTools', 'mcp__agilemonster');
|
|
54
|
+
return args;
|
|
55
|
+
},
|
|
56
|
+
parse: (res) => {
|
|
57
|
+
const obj = resultObject(res.stdout);
|
|
58
|
+
if (!obj)
|
|
59
|
+
return { text: res.stdout.trim() };
|
|
60
|
+
const text = typeof obj.result === 'string' ? obj.result : '';
|
|
61
|
+
return {
|
|
62
|
+
text,
|
|
63
|
+
sessionId: typeof obj.session_id === 'string' ? obj.session_id : undefined,
|
|
64
|
+
costUsd: typeof obj.total_cost_usd === 'number' ? obj.total_cost_usd : undefined,
|
|
65
|
+
error: obj.is_error ? text || String(obj.subtype || 'the run reported an error') : undefined,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'cursor',
|
|
71
|
+
label: 'Cursor Agent',
|
|
72
|
+
providers: ['cursor'],
|
|
73
|
+
bins: ['cursor-agent'],
|
|
74
|
+
installHint: 'curl https://cursor.com/install -fsS | bash',
|
|
75
|
+
ready: async (bin) => {
|
|
76
|
+
if ((process.env.CURSOR_API_KEY || '').trim())
|
|
77
|
+
return null;
|
|
78
|
+
const status = await exec(bin, ['status'], { timeoutMs: 20000 });
|
|
79
|
+
return /not logged in/i.test(`${status.stdout}\n${status.stderr}`)
|
|
80
|
+
? 'it is installed but not signed in — run `cursor-agent login` once in a terminal'
|
|
81
|
+
: null;
|
|
82
|
+
},
|
|
83
|
+
args: (opts) => {
|
|
84
|
+
const args = ['-p', '--force', '--trust', '--output-format', 'json', '--workspace', opts.projectPath];
|
|
85
|
+
if (opts.resumeSessionId)
|
|
86
|
+
args.push('--resume', opts.resumeSessionId);
|
|
87
|
+
if (opts.model)
|
|
88
|
+
args.push('--model', opts.model);
|
|
89
|
+
args.push(opts.prompt);
|
|
90
|
+
return args;
|
|
91
|
+
},
|
|
92
|
+
parse: (res) => {
|
|
93
|
+
const obj = resultObject(res.stdout);
|
|
94
|
+
if (!obj)
|
|
95
|
+
return { text: res.stdout.trim() };
|
|
96
|
+
const text = typeof obj.result === 'string' ? obj.result : '';
|
|
97
|
+
return {
|
|
98
|
+
text,
|
|
99
|
+
sessionId: typeof obj.session_id === 'string' ? obj.session_id : undefined,
|
|
100
|
+
error: obj.is_error ? text || 'the run reported an error' : undefined,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'codex',
|
|
106
|
+
label: 'Codex',
|
|
107
|
+
providers: ['codex'],
|
|
108
|
+
bins: ['codex'],
|
|
109
|
+
installHint: 'npm install -g @openai/codex',
|
|
110
|
+
args: (opts, scratch) => ['exec', '--sandbox', 'workspace-write', '--skip-git-repo-check', '-o', scratch.lastMessageFile, opts.prompt],
|
|
111
|
+
parse: (res, scratch) => {
|
|
112
|
+
let text = '';
|
|
113
|
+
try {
|
|
114
|
+
text = fs.readFileSync(scratch.lastMessageFile, 'utf8').trim();
|
|
115
|
+
}
|
|
116
|
+
catch { }
|
|
117
|
+
return { text: text || res.stdout.trim() };
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
const CUSTOM_ENGINE = { id: 'custom', label: 'your agent command', bin: '' };
|
|
122
|
+
function customCommand() {
|
|
123
|
+
return (process.env.AGILEMONSTER_AGENT_COMMAND || '').trim();
|
|
124
|
+
}
|
|
125
|
+
const READY_TTL_MS = 2 * 60 * 1000;
|
|
126
|
+
const readyCache = new Map();
|
|
127
|
+
async function readiness(spec, bin) {
|
|
128
|
+
if (!spec.ready)
|
|
129
|
+
return null;
|
|
130
|
+
const cached = readyCache.get(bin);
|
|
131
|
+
if (cached && Date.now() - cached.at < READY_TTL_MS)
|
|
132
|
+
return cached.reason;
|
|
133
|
+
let reason = null;
|
|
134
|
+
try {
|
|
135
|
+
reason = await spec.ready(bin);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
reason = null; // an unanswerable check must not hide a working agent: the run itself will tell
|
|
139
|
+
}
|
|
140
|
+
readyCache.set(bin, { at: Date.now(), reason });
|
|
141
|
+
return reason;
|
|
142
|
+
}
|
|
143
|
+
export function engineLabelFor(provider) {
|
|
144
|
+
const spec = ENGINES.find((e) => e.providers.includes((provider || '').toLowerCase()));
|
|
145
|
+
return spec ? spec.label : null;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The agent CLIs this machine can run for a card, best first: the card's own agent, then any other one that
|
|
149
|
+
* is installed. `AGILEMONSTER_AGENT_ENGINE` pins the choice (`claude`, `cursor`, `codex`) or switches the
|
|
150
|
+
* whole thing off (`builtin`); `AGILEMONSTER_AGENT_COMMAND` replaces it with a command of the developer's own.
|
|
151
|
+
*
|
|
152
|
+
* `notes` say, in words for the card, why a better engine was passed over — an installed CLI that is not
|
|
153
|
+
* signed in is one command away from working, and the developer should hear that.
|
|
154
|
+
*/
|
|
155
|
+
export async function resolveEngines(provider) {
|
|
156
|
+
const pinned = (process.env.AGILEMONSTER_AGENT_ENGINE || '').trim().toLowerCase();
|
|
157
|
+
if (['builtin', 'built-in', 'off', 'none', '0', 'false'].includes(pinned))
|
|
158
|
+
return { engines: [], notes: [] };
|
|
159
|
+
if (customCommand() && (!pinned || pinned === 'auto' || pinned === 'custom'))
|
|
160
|
+
return { engines: [CUSTOM_ENGINE], notes: [] };
|
|
161
|
+
const prov = (provider || '').toLowerCase();
|
|
162
|
+
const native = ENGINES.filter((e) => e.providers.includes(prov));
|
|
163
|
+
let ordered = [...native, ...ENGINES.filter((e) => !native.includes(e))];
|
|
164
|
+
if (pinned && pinned !== 'auto')
|
|
165
|
+
ordered = ENGINES.filter((e) => e.id === pinned);
|
|
166
|
+
const engines = [];
|
|
167
|
+
const notes = [];
|
|
168
|
+
for (const spec of ordered) {
|
|
169
|
+
const isNative = native.includes(spec);
|
|
170
|
+
const bin = spec.bins.map((b) => which(b)).find(Boolean) || null;
|
|
171
|
+
if (!bin) {
|
|
172
|
+
if (isNative)
|
|
173
|
+
notes.push(`${spec.label}'s command line agent is not installed on this machine (\`${spec.installHint}\`)`);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const reason = await readiness(spec, bin);
|
|
177
|
+
if (reason) {
|
|
178
|
+
notes.push(`${spec.label}: ${reason}`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
engines.push({ id: spec.id, label: spec.label, bin });
|
|
182
|
+
}
|
|
183
|
+
return { engines, notes };
|
|
184
|
+
}
|
|
185
|
+
export function agentTimeoutMs() {
|
|
186
|
+
const minutes = Number(process.env.AGILEMONSTER_AGENT_TIMEOUT_MINUTES || '');
|
|
187
|
+
return (Number.isFinite(minutes) && minutes >= 1 ? Math.min(minutes, 180) : 30) * 60 * 1000;
|
|
188
|
+
}
|
|
189
|
+
function failureTail(res) {
|
|
190
|
+
return (res.stderr.trim() || res.stdout.trim() || res.tail).split('\n').filter((l) => l.trim()).slice(-4).join(' | ').slice(0, 500);
|
|
191
|
+
}
|
|
192
|
+
/** Run one agent to completion. Never throws: a CLI that is missing, logged out or crashed comes back as `ok: false`. */
|
|
193
|
+
export async function runEngine(engine, opts) {
|
|
194
|
+
const started = Date.now();
|
|
195
|
+
const timeoutMs = opts.timeoutMs || agentTimeoutMs();
|
|
196
|
+
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'am-agent-'));
|
|
197
|
+
const scratch = { lastMessageFile: path.join(scratchDir, 'last-message.txt') };
|
|
198
|
+
// The agent works on the code. The card belongs to the runner, so its API key stays out of the agent's reach.
|
|
199
|
+
const env = { ...process.env };
|
|
200
|
+
delete env.AGILEMONSTER_API_KEY;
|
|
201
|
+
try {
|
|
202
|
+
if (engine.id === 'custom') {
|
|
203
|
+
const promptFile = path.join(scratchDir, 'prompt.md');
|
|
204
|
+
fs.writeFileSync(promptFile, opts.prompt, 'utf8');
|
|
205
|
+
const res = await execShell(customCommand(), {
|
|
206
|
+
cwd: opts.projectPath,
|
|
207
|
+
timeoutMs,
|
|
208
|
+
env: { ...env, AGILEMONSTER_PROMPT_FILE: promptFile, AGILEMONSTER_PROJECT_PATH: opts.projectPath },
|
|
209
|
+
});
|
|
210
|
+
const text = res.stdout.trim();
|
|
211
|
+
return {
|
|
212
|
+
ok: res.ok,
|
|
213
|
+
text,
|
|
214
|
+
timedOut: res.timedOut,
|
|
215
|
+
durationMs: Date.now() - started,
|
|
216
|
+
failure: res.ok ? undefined : res.timedOut ? `timed out after ${Math.round(timeoutMs / 60000)} minutes` : failureTail(res),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const spec = ENGINES.find((e) => e.id === engine.id);
|
|
220
|
+
if (!spec)
|
|
221
|
+
return { ok: false, text: '', timedOut: false, durationMs: 0, failure: `unknown engine ${engine.id}` };
|
|
222
|
+
let res = await exec(engine.bin, spec.args(opts, scratch), { cwd: opts.projectPath, timeoutMs, env });
|
|
223
|
+
// A model name the CLI does not know fails within seconds: the card's preference must not cost the whole run
|
|
224
|
+
if (!res.ok && !res.timedOut && opts.model && Date.now() - started < 45000 && /model/i.test(`${res.stderr}\n${res.stdout}`)) {
|
|
225
|
+
res = await exec(engine.bin, spec.args({ ...opts, model: null }, scratch), { cwd: opts.projectPath, timeoutMs, env });
|
|
226
|
+
}
|
|
227
|
+
const parsed = spec.parse(res, scratch);
|
|
228
|
+
const failed = !res.ok || !!parsed.error;
|
|
229
|
+
return {
|
|
230
|
+
ok: !failed,
|
|
231
|
+
text: parsed.text,
|
|
232
|
+
sessionId: parsed.sessionId,
|
|
233
|
+
costUsd: parsed.costUsd,
|
|
234
|
+
timedOut: res.timedOut,
|
|
235
|
+
durationMs: Date.now() - started,
|
|
236
|
+
failure: !failed ? undefined : res.timedOut ? `timed out after ${Math.round(timeoutMs / 60000)} minutes` : (parsed.error || failureTail(res)).slice(0, 500),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
try {
|
|
241
|
+
fs.rmSync(scratchDir, { recursive: true, force: true });
|
|
242
|
+
}
|
|
243
|
+
catch { }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* What the agent is told. It reads like a request typed into the IDE — the card, the thread, and the few
|
|
248
|
+
* rules that differ from an interactive session (nobody is there to answer, and the runner does the shipping).
|
|
249
|
+
*/
|
|
250
|
+
export function buildAgentPrompt(brief) {
|
|
251
|
+
const lines = [];
|
|
252
|
+
lines.push(`You are picking up a task card from the "${brief.boardName || 'project'}" board in Agile Monster. The repository is your current directory (${brief.projectPath}).`, 'Nobody is watching this session. Work the way you would for a developer who typed this request into the IDE and walked away: figure it out, build it, check it.', '', '## The card', `Title: ${brief.title.trim() || '(untitled)'}`, '', brief.description.trim() || '(no description — the title and the conversation below are the whole request)');
|
|
253
|
+
if (brief.conversation.length > 0) {
|
|
254
|
+
lines.push('', '## Conversation on the card (oldest first)');
|
|
255
|
+
for (const turn of brief.conversation) {
|
|
256
|
+
lines.push(`${turn.who === 'requester' ? 'Requester' : 'Agent (an earlier attempt)'}: ${turn.text.trim()}`, '');
|
|
257
|
+
}
|
|
258
|
+
lines.push('The requester\'s replies are part of the request. Where an earlier attempt asked a question, their reply is the answer — do not ask it again.');
|
|
259
|
+
}
|
|
260
|
+
lines.push('', '## How to work', '- Find the right place yourself. Search the code for the page names, labels and visible text the card mentions; never ask where something lives.', '- Where the card leaves a detail open, decide it the way this codebase already does things and say what you chose in your summary. Do not ask for confirmation of anything the requester already said.', '- Build the whole change, through every layer it needs (data/API, types, UI).', '- Check your work before you finish: run the type check, build or tests that cover what you touched, and fix what you broke.', '- Do not commit, push, deploy, stash or switch branches. When you finish, the Agile Monster runner commits and deploys exactly the files you changed.', '- Do not use Agile Monster MCP tools or its API. This card is already claimed and its status is managed for you, so skip any project instruction about claiming cards or submitting them for review.');
|
|
261
|
+
if (brief.developerFiles.length > 0) {
|
|
262
|
+
lines.push(`- These files hold the developer's own uncommitted work. Do not modify them: ${brief.developerFiles.slice(0, 40).map((f) => `\`${f}\``).join(', ')}`);
|
|
263
|
+
}
|
|
264
|
+
if (brief.earlierFiles.length > 0) {
|
|
265
|
+
lines.push(`- An earlier attempt at this card left uncommitted changes in ${brief.earlierFiles.slice(0, 40).map((f) => `\`${f}\``).join(', ')}. They are yours to keep, fix or replace.` +
|
|
266
|
+
(brief.earlierSummary ? ` It reported: ${brief.earlierSummary.trim().slice(0, 600)}` : ''));
|
|
267
|
+
}
|
|
268
|
+
lines.push('', '## When you are done', 'End your reply with one JSON object on its own, and nothing after it:', '{"status":"done","summary":"2-4 plain sentences for the person reviewing the card: what changed and where they will see it","verification_steps":["a step they can do in the running app","..."]}', 'Only if the work is impossible or unsafe without something that is not in the repository:', '{"status":"blocked","question":"the one specific thing you need, and what you already tried"}');
|
|
269
|
+
return lines.join('\n');
|
|
270
|
+
}
|
|
271
|
+
/** Read the closing JSON object out of the agent's final message. Tolerates code fences and prose around it. */
|
|
272
|
+
export function parseAgentReport(text) {
|
|
273
|
+
const clean = (text || '').trim();
|
|
274
|
+
const starts = [];
|
|
275
|
+
const re = /\{\s*"status"\s*:/g;
|
|
276
|
+
let m;
|
|
277
|
+
while ((m = re.exec(clean)))
|
|
278
|
+
starts.push(m.index);
|
|
279
|
+
for (let i = starts.length - 1; i >= 0; i--) {
|
|
280
|
+
let end = clean.lastIndexOf('}');
|
|
281
|
+
while (end > starts[i]) {
|
|
282
|
+
try {
|
|
283
|
+
const obj = JSON.parse(clean.slice(starts[i], end + 1));
|
|
284
|
+
const status = String(obj.status || '').toLowerCase();
|
|
285
|
+
const steps = Array.isArray(obj.verification_steps)
|
|
286
|
+
? obj.verification_steps.map((s) => String(s).trim()).filter(Boolean)
|
|
287
|
+
: typeof obj.verification_steps === 'string' && obj.verification_steps.trim()
|
|
288
|
+
? [obj.verification_steps.trim()]
|
|
289
|
+
: [];
|
|
290
|
+
return {
|
|
291
|
+
status: status === 'done' ? 'done' : status === 'blocked' ? 'blocked' : 'unknown',
|
|
292
|
+
summary: String(obj.summary || '').trim(),
|
|
293
|
+
verificationSteps: steps,
|
|
294
|
+
question: String(obj.question || '').trim(),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
catch { }
|
|
298
|
+
end = clean.lastIndexOf('}', end - 1);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { status: 'unknown', summary: '', verificationSteps: [], question: '' };
|
|
302
|
+
}
|
|
303
|
+
/** The agent's prose without the closing JSON object: what to show a human when the report is missing a field. */
|
|
304
|
+
export function proseOf(text, maxChars = 900) {
|
|
305
|
+
const clean = (text || '').replace(/```(?:json)?\s*\{\s*"status"[\s\S]*?```/g, '').replace(/\{\s*"status"\s*:[\s\S]*$/, '').trim();
|
|
306
|
+
return clean.length > maxChars ? `…${clean.slice(-maxChars)}` : clean;
|
|
307
|
+
}
|
|
308
|
+
const ABSENT = 'absent';
|
|
309
|
+
const MAX_FINGERPRINT_BYTES = 8 * 1024 * 1024;
|
|
310
|
+
const MAX_SAVED_BYTES = 2 * 1024 * 1024;
|
|
311
|
+
const git = (cwd, args, timeoutMs = 30000) => exec('git', ['--literal-pathspecs', ...args], { cwd, timeoutMs });
|
|
312
|
+
function fingerprint(abs) {
|
|
313
|
+
try {
|
|
314
|
+
const stat = fs.statSync(abs);
|
|
315
|
+
if (!stat.isFile())
|
|
316
|
+
return `other:${stat.mtimeMs}`;
|
|
317
|
+
if (stat.size > MAX_FINGERPRINT_BYTES)
|
|
318
|
+
return `big:${stat.size}:${stat.mtimeMs}`;
|
|
319
|
+
return crypto.createHash('sha1').update(fs.readFileSync(abs)).digest('hex');
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return ABSENT;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** Paths from `git status --porcelain -z`. A rename reports both its new and its old path. */
|
|
326
|
+
export function parsePorcelainZ(out) {
|
|
327
|
+
const paths = [];
|
|
328
|
+
const tokens = out.split('\0');
|
|
329
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
330
|
+
const token = tokens[i];
|
|
331
|
+
if (token.length < 4)
|
|
332
|
+
continue;
|
|
333
|
+
paths.push(token.slice(3));
|
|
334
|
+
if (token[0] === 'R' || token[0] === 'C') {
|
|
335
|
+
i++;
|
|
336
|
+
if (tokens[i])
|
|
337
|
+
paths.push(tokens[i]);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return paths;
|
|
341
|
+
}
|
|
342
|
+
async function statusPaths(projectPath) {
|
|
343
|
+
const res = await git(projectPath, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
|
|
344
|
+
return res.ok ? parsePorcelainZ(res.stdout) : null;
|
|
345
|
+
}
|
|
346
|
+
/** Record the working tree before the agent starts. Null when the folder is not a Git repository. */
|
|
347
|
+
export async function snapshotWorkingTree(projectPath) {
|
|
348
|
+
const head = await git(projectPath, ['rev-parse', 'HEAD']);
|
|
349
|
+
const paths = await statusPaths(projectPath);
|
|
350
|
+
if (!head.ok || !paths)
|
|
351
|
+
return null;
|
|
352
|
+
const entries = new Map();
|
|
353
|
+
const saved = new Map();
|
|
354
|
+
for (const rel of paths) {
|
|
355
|
+
const abs = path.join(projectPath, rel);
|
|
356
|
+
const print = fingerprint(abs);
|
|
357
|
+
entries.set(rel, print);
|
|
358
|
+
if (print === ABSENT) {
|
|
359
|
+
saved.set(rel, null);
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
try {
|
|
363
|
+
if (fs.statSync(abs).size <= MAX_SAVED_BYTES)
|
|
364
|
+
saved.set(rel, fs.readFileSync(abs));
|
|
365
|
+
}
|
|
366
|
+
catch { }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { head: head.stdout.trim(), entries, saved };
|
|
370
|
+
}
|
|
371
|
+
/** Files an agent's own tooling drops into a project. New ones are never part of a card's change. */
|
|
372
|
+
function isAgentToolingPath(rel) {
|
|
373
|
+
return /^(\.claude|\.cursor|\.codex|\.agilemonster|\.specstory)(\/|$)/.test(rel) || rel === '.agilemonster.json';
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Compare the working tree with the snapshot. If the agent committed despite its instructions, the commits
|
|
377
|
+
* are undone first (soft, so nothing is lost) — unless they were pushed, which only a human can sort out.
|
|
378
|
+
*/
|
|
379
|
+
export async function changesSince(projectPath, snap) {
|
|
380
|
+
let pushedCommit;
|
|
381
|
+
const head = await git(projectPath, ['rev-parse', 'HEAD']);
|
|
382
|
+
const nowHead = head.ok ? head.stdout.trim() : snap.head;
|
|
383
|
+
if (nowHead !== snap.head) {
|
|
384
|
+
const descends = await git(projectPath, ['merge-base', '--is-ancestor', snap.head, nowHead]);
|
|
385
|
+
const onRemote = await git(projectPath, ['branch', '-r', '--contains', nowHead]);
|
|
386
|
+
if (onRemote.ok && onRemote.stdout.trim() !== '') {
|
|
387
|
+
pushedCommit = nowHead;
|
|
388
|
+
}
|
|
389
|
+
else if (descends.ok) {
|
|
390
|
+
await git(projectPath, ['reset', '--soft', snap.head]);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const changed = [];
|
|
394
|
+
const overlapping = [];
|
|
395
|
+
const now = new Set((await statusPaths(projectPath)) || []);
|
|
396
|
+
for (const rel of now) {
|
|
397
|
+
const before = snap.entries.get(rel);
|
|
398
|
+
if (before === undefined) {
|
|
399
|
+
if (!isAgentToolingPath(rel))
|
|
400
|
+
changed.push(rel);
|
|
401
|
+
}
|
|
402
|
+
else if (fingerprint(path.join(projectPath, rel)) !== before) {
|
|
403
|
+
changed.push(rel);
|
|
404
|
+
overlapping.push(rel);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// A file that was dirty and is clean again was put back to HEAD by the agent: the developer's change is gone
|
|
408
|
+
for (const rel of snap.entries.keys()) {
|
|
409
|
+
if (!now.has(rel)) {
|
|
410
|
+
changed.push(rel);
|
|
411
|
+
overlapping.push(rel);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return { changed: changed.sort(), overlapping: overlapping.sort(), pushedCommit };
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Put the given files back the way the snapshot found them. Returns the files that could not be restored
|
|
418
|
+
* (only possible for a file the developer had changed that was too large to keep a copy of).
|
|
419
|
+
*/
|
|
420
|
+
export async function restoreWorkingTree(projectPath, snap, paths) {
|
|
421
|
+
const failed = [];
|
|
422
|
+
const root = path.resolve(projectPath);
|
|
423
|
+
for (const rel of paths) {
|
|
424
|
+
const abs = path.resolve(root, rel);
|
|
425
|
+
if (abs !== root && !abs.startsWith(root + path.sep))
|
|
426
|
+
continue;
|
|
427
|
+
try {
|
|
428
|
+
if (snap.entries.has(rel)) {
|
|
429
|
+
if (!snap.saved.has(rel)) {
|
|
430
|
+
failed.push(rel);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const original = snap.saved.get(rel);
|
|
434
|
+
if (original === null || original === undefined) {
|
|
435
|
+
fs.rmSync(abs, { force: true });
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
439
|
+
fs.writeFileSync(abs, original);
|
|
440
|
+
}
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
// Clean before the run: either a tracked file (take it from HEAD) or one that did not exist (remove it)
|
|
444
|
+
const tracked = await git(projectPath, ['cat-file', '-e', `${snap.head}:${rel}`]);
|
|
445
|
+
if (tracked.ok) {
|
|
446
|
+
const back = await git(projectPath, ['checkout', snap.head, '--', rel]);
|
|
447
|
+
if (!back.ok)
|
|
448
|
+
failed.push(rel);
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
await git(projectPath, ['rm', '--cached', '--quiet', '--ignore-unmatch', '--', rel]);
|
|
452
|
+
fs.rmSync(abs, { force: true });
|
|
453
|
+
let dir = path.dirname(abs);
|
|
454
|
+
while (dir !== root && dir.startsWith(root + path.sep)) {
|
|
455
|
+
try {
|
|
456
|
+
fs.rmdirSync(dir); // only succeeds on a directory the agent created and we just emptied
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
dir = path.dirname(dir);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
failed.push(rel);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return failed;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* `git diff` does not show files Git has never seen. Mark the agent's new files as "to be added" (no content
|
|
473
|
+
* is staged) so the reviewer's diff includes them.
|
|
474
|
+
*/
|
|
475
|
+
export async function markNewFilesForDiff(projectPath, files) {
|
|
476
|
+
if (files.length === 0)
|
|
477
|
+
return;
|
|
478
|
+
const untracked = await git(projectPath, ['ls-files', '--others', '--exclude-standard', '-z', '--', ...files]);
|
|
479
|
+
const fresh = untracked.ok ? untracked.stdout.split('\0').filter(Boolean) : [];
|
|
480
|
+
if (fresh.length > 0)
|
|
481
|
+
await git(projectPath, ['add', '--intent-to-add', '--', ...fresh]);
|
|
482
|
+
}
|
|
483
|
+
function runRecordFile(cardId) {
|
|
484
|
+
return path.join(agileMonsterHome(), 'agent-runs', `${cardId.replace(/[^a-zA-Z0-9-]/g, '')}.json`);
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Snapshot the tree before this card's agent starts, and note that a run is in progress.
|
|
488
|
+
*
|
|
489
|
+
* `interrupted` is true when an earlier run of the same card never reached a conclusion (the machine slept,
|
|
490
|
+
* the daemon was killed). Whatever that run left in the tree cannot be told apart from the developer's own
|
|
491
|
+
* edits, so it is treated as theirs — never reverted, never committed — and the card can say where the
|
|
492
|
+
* leftovers probably came from.
|
|
493
|
+
*/
|
|
494
|
+
export async function baselineFor(cardId, projectPath) {
|
|
495
|
+
const snapshot = await snapshotWorkingTree(projectPath);
|
|
496
|
+
if (!snapshot)
|
|
497
|
+
return null;
|
|
498
|
+
const prior = readJson(runRecordFile(cardId));
|
|
499
|
+
const interrupted = !!prior && prior.project_path === projectPath;
|
|
500
|
+
try {
|
|
501
|
+
writeJsonAtomic(runRecordFile(cardId), {
|
|
502
|
+
card_id: cardId,
|
|
503
|
+
project_path: projectPath,
|
|
504
|
+
pid: process.pid,
|
|
505
|
+
at: new Date().toISOString(),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
catch { }
|
|
509
|
+
return { snapshot, interrupted };
|
|
510
|
+
}
|
|
511
|
+
/** The card's run reached a conclusion (shipped, reverted, or handed to the human): it is no longer in progress. */
|
|
512
|
+
export function clearBaseline(cardId) {
|
|
513
|
+
try {
|
|
514
|
+
fs.rmSync(runRecordFile(cardId), { force: true });
|
|
515
|
+
}
|
|
516
|
+
catch { }
|
|
517
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import { AgileMonsterProjectConfig, AutonomousStepResponse, CardComment, DeployReport, Board, BoardAgentConfig, Card, ContextResponse, DiscoveredProject, PulseResponse } from './types.js';
|
|
2
|
-
export declare const MCP_SERVER_VERSION = "1.0.
|
|
1
|
+
import { ActiveClaim, AgileMonsterProjectConfig, AutonomousStepResponse, CardComment, DeployReport, Board, BoardAgentConfig, Card, ContextResponse, DiscoveredProject, PulseResponse } from './types.js';
|
|
2
|
+
export declare const MCP_SERVER_VERSION = "1.0.24";
|
|
3
|
+
/** An API call the server answered with an error status. `status` lets callers tell "refused" from "broken". */
|
|
4
|
+
export declare class ApiError extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
constructor(message: string, status: number);
|
|
7
|
+
}
|
|
8
|
+
/** The server refused a claim because another agent session holds the card (HTTP 409). */
|
|
9
|
+
export declare function isClaimConflict(err: unknown): boolean;
|
|
3
10
|
export declare class AgileMonsterClient {
|
|
4
11
|
private apiKey;
|
|
5
12
|
private baseUrl;
|
|
@@ -41,7 +48,13 @@ export declare class AgileMonsterClient {
|
|
|
41
48
|
models: Record<string, string> | string[];
|
|
42
49
|
}>;
|
|
43
50
|
installedAgents?: string[];
|
|
51
|
+
/** Cards this process holds: the server extends their claim (heartbeat). */
|
|
52
|
+
activeClaims?: ActiveClaim[];
|
|
44
53
|
}): Promise<PulseResponse>;
|
|
54
|
+
/** Extend the claims this process holds. Used while a long job keeps the pulse loop busy. */
|
|
55
|
+
heartbeat(activeClaims: ActiveClaim[]): Promise<{
|
|
56
|
+
renewed: number;
|
|
57
|
+
}>;
|
|
45
58
|
/**
|
|
46
59
|
* Cross-platform detection of AI coding agents installed on this computer.
|
|
47
60
|
* Checks for Cursor, Claude Code, Antigravity, Codex, Hermes, Windsurf, Aider.
|
package/dist/client.js
CHANGED
|
@@ -4,7 +4,20 @@ import { execSync } from 'node:child_process';
|
|
|
4
4
|
import { resolveDeployProfile } from './deploy-profile.js';
|
|
5
5
|
import { loadMemory } from './deploy-memory.js';
|
|
6
6
|
import { stripRemoteCredentials } from './redact.js';
|
|
7
|
-
export const MCP_SERVER_VERSION = '1.0.
|
|
7
|
+
export const MCP_SERVER_VERSION = '1.0.24';
|
|
8
|
+
/** An API call the server answered with an error status. `status` lets callers tell "refused" from "broken". */
|
|
9
|
+
export class ApiError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(message, status) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'ApiError';
|
|
14
|
+
this.status = status;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** The server refused a claim because another agent session holds the card (HTTP 409). */
|
|
18
|
+
export function isClaimConflict(err) {
|
|
19
|
+
return err instanceof ApiError && err.status === 409;
|
|
20
|
+
}
|
|
8
21
|
export class AgileMonsterClient {
|
|
9
22
|
apiKey = '';
|
|
10
23
|
baseUrl;
|
|
@@ -144,7 +157,7 @@ export class AgileMonsterClient {
|
|
|
144
157
|
}
|
|
145
158
|
if (!res.ok) {
|
|
146
159
|
const msg = json?.message || json?.error || `HTTP ${res.status}: ${text.slice(0, 150)}`;
|
|
147
|
-
throw new
|
|
160
|
+
throw new ApiError(`Agile Monster API Error: ${msg}`, res.status);
|
|
148
161
|
}
|
|
149
162
|
return json;
|
|
150
163
|
}
|
|
@@ -202,9 +215,17 @@ export class AgileMonsterClient {
|
|
|
202
215
|
discovered_projects: options.discoveredProjects,
|
|
203
216
|
discovered_models: options.discoveredModels,
|
|
204
217
|
installed_agents: installed,
|
|
218
|
+
active_claims: options.activeClaims && options.activeClaims.length > 0 ? options.activeClaims : undefined,
|
|
205
219
|
}),
|
|
206
220
|
});
|
|
207
221
|
}
|
|
222
|
+
/** Extend the claims this process holds. Used while a long job keeps the pulse loop busy. */
|
|
223
|
+
async heartbeat(activeClaims) {
|
|
224
|
+
return this.request('/api/agent/heartbeat', {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
body: JSON.stringify({ active_claims: activeClaims }),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
208
229
|
/**
|
|
209
230
|
* Cross-platform detection of AI coding agents installed on this computer.
|
|
210
231
|
* Checks for Cursor, Claude Code, Antigravity, Codex, Hermes, Windsurf, Aider.
|
package/dist/deploy-profile.d.ts
CHANGED
|
@@ -11,7 +11,11 @@ import type { ProjectMemory } from './deploy-memory.js';
|
|
|
11
11
|
* Precedence: explicit project file > user-set binding > last verified success > ranked evidence.
|
|
12
12
|
*/
|
|
13
13
|
export type DeployMethod = 'github_actions' | 'docker_compose' | 'deploy_script' | 'cloud_cli' | 'git_push' | 'manual' | 'none';
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* 'proposed' = a route someone ELSE set for this project on the board (a teammate, or a deployment verified
|
|
16
|
+
* on another machine). It is never "confirmed" for this computer by that alone.
|
|
17
|
+
*/
|
|
18
|
+
export type ProfileSource = 'explicit' | 'user' | 'learned' | 'verified' | 'detected' | 'proposed';
|
|
15
19
|
export type Confidence = 'high' | 'medium' | 'low';
|
|
16
20
|
export interface PushTrigger {
|
|
17
21
|
branches: string[] | null;
|
|
@@ -61,6 +65,16 @@ export interface DeployCandidate {
|
|
|
61
65
|
/** The deploy workflow only starts when dispatched (pushing alone does not deploy). */
|
|
62
66
|
dispatchOnly?: boolean;
|
|
63
67
|
}
|
|
68
|
+
/** A deploy route another account set on the board binding. The server sends it apart from the confirmed fields. */
|
|
69
|
+
export interface RouteProposal {
|
|
70
|
+
method?: string | null;
|
|
71
|
+
command?: string | null;
|
|
72
|
+
live_url?: string | null;
|
|
73
|
+
/** How it is stored on the board: 'user' (stated by that person), 'verified' (worked on their machine), 'auto'. */
|
|
74
|
+
source?: string | null;
|
|
75
|
+
set_by?: string | null;
|
|
76
|
+
set_by_name?: string | null;
|
|
77
|
+
}
|
|
64
78
|
export interface BoundProfile {
|
|
65
79
|
deploy_method?: string | null;
|
|
66
80
|
deploy_command?: string | null;
|
|
@@ -68,7 +82,18 @@ export interface BoundProfile {
|
|
|
68
82
|
build_command?: string | null;
|
|
69
83
|
/** 'user' = set by a human, 'verified' = confirmed by a successful deploy, 'auto' = a guess. */
|
|
70
84
|
deploy_source?: string | null;
|
|
85
|
+
/** The account that set deploy_method / deploy_command on the board. */
|
|
86
|
+
deploy_set_by?: string | null;
|
|
87
|
+
/** A route someone else set: used only as described at `resolveDeployProfile`. */
|
|
88
|
+
proposal?: RouteProposal | null;
|
|
71
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Second check on this side of the wire: the server only sends a binding's route as confirmed to the account
|
|
92
|
+
* that set it, but what runs in a shell on this computer is decided HERE. If the confirmed fields name a
|
|
93
|
+
* setter and it is not this machine's account, the route is demoted to a proposal.
|
|
94
|
+
* `ownerId` null = this machine's account could not be determined; the fields are left as the server sent them.
|
|
95
|
+
*/
|
|
96
|
+
export declare function trustBoundRoute(bound: BoundProfile | null | undefined, ownerId: string | null | undefined): BoundProfile | null;
|
|
72
97
|
export interface GitInfo {
|
|
73
98
|
isRepo: boolean;
|
|
74
99
|
branch: string | null;
|