@agilemonster/mcp-server 1.0.23 → 1.0.25

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.
@@ -0,0 +1,620 @@
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
+ locations: () => [path.join(os.homedir(), '.local', 'bin', 'claude'), path.join(os.homedir(), '.claude', 'local', 'claude')],
45
+ installHint: 'npm install -g @anthropic-ai/claude-code',
46
+ ready: async (bin) => {
47
+ if ((process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN || '').trim())
48
+ return null;
49
+ const status = await exec(bin, ['auth', 'status'], { timeoutMs: 20000 });
50
+ const info = lastJsonValue(status.stdout);
51
+ // Only a plain "not signed in to Anthropic" is certain. Bedrock, Vertex and older CLIs answer differently: let the run tell.
52
+ return info && typeof info === 'object' && info.loggedIn === false && (!info.apiProvider || info.apiProvider === 'firstParty')
53
+ ? 'it is installed but not signed in — run `claude auth login` once in a terminal'
54
+ : null;
55
+ },
56
+ args: (opts) => {
57
+ // The prompt directly follows -p; the list-valued flags come last so they cannot swallow it.
58
+ const args = ['-p', opts.prompt, '--output-format', 'json', '--permission-mode', 'acceptEdits'];
59
+ if (opts.resumeSessionId)
60
+ args.push('--resume', opts.resumeSessionId);
61
+ if (opts.model && /^(claude|opus|sonnet|haiku|fable)/i.test(opts.model))
62
+ args.push('--model', opts.model);
63
+ // Shell access is what lets it run the build and the tests. The card's status belongs to the runner.
64
+ args.push('--allowedTools', 'Bash', '--disallowedTools', 'mcp__agilemonster');
65
+ return args;
66
+ },
67
+ parse: (res) => {
68
+ const obj = resultObject(res.stdout);
69
+ if (!obj)
70
+ return { text: res.stdout.trim() };
71
+ const text = typeof obj.result === 'string' ? obj.result : '';
72
+ return {
73
+ text,
74
+ sessionId: typeof obj.session_id === 'string' ? obj.session_id : undefined,
75
+ costUsd: typeof obj.total_cost_usd === 'number' ? obj.total_cost_usd : undefined,
76
+ error: obj.is_error ? text || String(obj.subtype || 'the run reported an error') : undefined,
77
+ };
78
+ },
79
+ },
80
+ {
81
+ id: 'cursor',
82
+ label: 'Cursor Agent',
83
+ providers: ['cursor'],
84
+ bins: ['cursor-agent'],
85
+ locations: () => [path.join(os.homedir(), '.local', 'bin', 'cursor-agent')],
86
+ installHint: 'curl https://cursor.com/install -fsS | bash',
87
+ ready: async (bin) => {
88
+ if ((process.env.CURSOR_API_KEY || '').trim())
89
+ return null;
90
+ const status = await exec(bin, ['status'], { timeoutMs: 20000 });
91
+ return /not logged in/i.test(`${status.stdout}\n${status.stderr}`)
92
+ ? 'it is installed but not signed in — run `cursor-agent login` once in a terminal'
93
+ : null;
94
+ },
95
+ args: (opts) => {
96
+ const args = ['-p', '--force', '--trust', '--output-format', 'json', '--workspace', opts.projectPath];
97
+ if (opts.resumeSessionId)
98
+ args.push('--resume', opts.resumeSessionId);
99
+ if (opts.model)
100
+ args.push('--model', opts.model);
101
+ args.push(opts.prompt);
102
+ return args;
103
+ },
104
+ parse: (res) => {
105
+ const obj = resultObject(res.stdout);
106
+ if (!obj)
107
+ return { text: res.stdout.trim() };
108
+ const text = typeof obj.result === 'string' ? obj.result : '';
109
+ return {
110
+ text,
111
+ sessionId: typeof obj.session_id === 'string' ? obj.session_id : undefined,
112
+ error: obj.is_error ? text || 'the run reported an error' : undefined,
113
+ };
114
+ },
115
+ },
116
+ {
117
+ id: 'codex',
118
+ label: 'Codex',
119
+ providers: ['codex'],
120
+ bins: ['codex'],
121
+ // The Codex app and its IDE plugin ship the same CLI, already signed in with the developer's ChatGPT account
122
+ locations: () => [
123
+ path.join(os.homedir(), '.codex', 'plugins', '.plugin-appserver', 'codex-cli', 'bin', 'codex'),
124
+ '/Applications/Codex.app/Contents/Resources/codex',
125
+ ],
126
+ installHint: 'npm install -g @openai/codex',
127
+ ready: async (bin) => {
128
+ if ((process.env.CODEX_API_KEY || '').trim())
129
+ return null;
130
+ const status = await exec(bin, ['login', 'status'], { timeoutMs: 20000 });
131
+ return /not logged in/i.test(`${status.stdout}\n${status.stderr}`)
132
+ ? 'it is installed but not signed in — run `codex login` once in a terminal'
133
+ : null;
134
+ },
135
+ args: (opts, scratch) => {
136
+ // workspace-write keeps its edits inside the project; network stays on so it can install and build like the IDE does
137
+ const args = ['exec', '--sandbox', 'workspace-write', '-c', 'sandbox_workspace_write.network_access=true', '--skip-git-repo-check'];
138
+ if (opts.model && /^gpt-/i.test(opts.model))
139
+ args.push('-m', opts.model);
140
+ args.push('-o', scratch.lastMessageFile, opts.prompt);
141
+ return args;
142
+ },
143
+ parse: (res, scratch) => {
144
+ let text = '';
145
+ try {
146
+ text = fs.readFileSync(scratch.lastMessageFile, 'utf8').trim();
147
+ }
148
+ catch { }
149
+ return { text: text || res.stdout.trim() };
150
+ },
151
+ },
152
+ ];
153
+ const CUSTOM_ENGINE = { id: 'custom', label: 'your agent command', bin: '' };
154
+ function customCommand() {
155
+ return (process.env.AGILEMONSTER_AGENT_COMMAND || '').trim();
156
+ }
157
+ const READY_TTL_MS = 2 * 60 * 1000;
158
+ const readyCache = new Map();
159
+ /** Forget cached sign-in checks (a developer who just signed in should not wait out the cache; tests). */
160
+ export function forgetEngineReadiness() {
161
+ readyCache.clear();
162
+ }
163
+ function isExecutableFile(file) {
164
+ try {
165
+ fs.accessSync(file, fs.constants.X_OK);
166
+ return fs.statSync(file).isFile();
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }
172
+ /** The engine's CLI: on PATH, or where its installer, IDE plugin or desktop app keeps it. */
173
+ function findBin(spec) {
174
+ for (const bin of spec.bins) {
175
+ const onPath = which(bin);
176
+ if (onPath)
177
+ return onPath;
178
+ }
179
+ for (const candidate of spec.locations ? spec.locations() : []) {
180
+ if (isExecutableFile(candidate))
181
+ return candidate;
182
+ }
183
+ return null;
184
+ }
185
+ /** Every agent CLI this runner knows, and whether each can take a card on this machine right now. */
186
+ export async function engineStatuses() {
187
+ const out = [];
188
+ for (const spec of ENGINES) {
189
+ const bin = findBin(spec);
190
+ out.push({
191
+ id: spec.id,
192
+ label: spec.label,
193
+ bin,
194
+ problem: bin ? await readiness(spec, bin) : `it is not installed (\`${spec.installHint}\`)`,
195
+ });
196
+ }
197
+ return out;
198
+ }
199
+ /** What `agilemonster agents` prints: which agent works a queued card here, and the one step that would fix the others. */
200
+ export async function describeAgents() {
201
+ const pinned = (process.env.AGILEMONSTER_AGENT_ENGINE || '').trim().toLowerCase();
202
+ const lines = ['Coding agents on this machine that can work a card queued from the board:', ''];
203
+ if (customCommand())
204
+ lines.push(` ✓ Your own command AGILEMONSTER_AGENT_COMMAND=${customCommand()}`, '');
205
+ const statuses = await engineStatuses();
206
+ for (const s of statuses) {
207
+ lines.push(` ${s.problem ? '✗' : '✓'} ${s.label.padEnd(14)} ${s.problem ? s.problem : s.bin}`);
208
+ }
209
+ lines.push('');
210
+ const usable = statuses.filter((s) => !s.problem);
211
+ if (['builtin', 'built-in', 'off', 'none', '0', 'false'].includes(pinned)) {
212
+ lines.push('AGILEMONSTER_AGENT_ENGINE switches agents off: every card runs on the built-in engine (a few files, one pass).');
213
+ }
214
+ else if (customCommand()) {
215
+ lines.push('Every card you created is handed to your own command.');
216
+ }
217
+ else if (usable.length === 0) {
218
+ lines.push('None is ready, so cards run on the built-in engine, which edits a few files in one pass and cannot explore or build.');
219
+ lines.push('Fix any one line above and the next card is worked by a real agent.');
220
+ }
221
+ else {
222
+ for (const s of statuses) {
223
+ const worker = s.problem ? usable[0] : s;
224
+ lines.push(`A card assigned to ${s.label.replace(/ Agent$/, '')} is worked by ${worker.label}${s.problem ? ` (standing in)` : ''}.`);
225
+ }
226
+ lines.push('Cards a teammate created always use the built-in engine: an agent with a shell only works cards you wrote.');
227
+ }
228
+ return lines.join('\n');
229
+ }
230
+ async function readiness(spec, bin) {
231
+ if (!spec.ready)
232
+ return null;
233
+ const cached = readyCache.get(bin);
234
+ if (cached && Date.now() - cached.at < READY_TTL_MS)
235
+ return cached.reason;
236
+ let reason = null;
237
+ try {
238
+ reason = await spec.ready(bin);
239
+ }
240
+ catch {
241
+ reason = null; // an unanswerable check must not hide a working agent: the run itself will tell
242
+ }
243
+ readyCache.set(bin, { at: Date.now(), reason });
244
+ return reason;
245
+ }
246
+ export function engineLabelFor(provider) {
247
+ const spec = ENGINES.find((e) => e.providers.includes((provider || '').toLowerCase()));
248
+ return spec ? spec.label : null;
249
+ }
250
+ /**
251
+ * The agent CLIs this machine can run for a card, best first: the card's own agent, then any other one that
252
+ * is installed. `AGILEMONSTER_AGENT_ENGINE` pins the choice (`claude`, `cursor`, `codex`) or switches the
253
+ * whole thing off (`builtin`); `AGILEMONSTER_AGENT_COMMAND` replaces it with a command of the developer's own.
254
+ *
255
+ * `notes` say, in words for the card, why a better engine was passed over — an installed CLI that is not
256
+ * signed in is one command away from working, and the developer should hear that.
257
+ */
258
+ export async function resolveEngines(provider) {
259
+ const pinned = (process.env.AGILEMONSTER_AGENT_ENGINE || '').trim().toLowerCase();
260
+ if (['builtin', 'built-in', 'off', 'none', '0', 'false'].includes(pinned))
261
+ return { engines: [], notes: [] };
262
+ if (customCommand() && (!pinned || pinned === 'auto' || pinned === 'custom'))
263
+ return { engines: [CUSTOM_ENGINE], notes: [] };
264
+ const prov = (provider || '').toLowerCase();
265
+ const native = ENGINES.filter((e) => e.providers.includes(prov));
266
+ let ordered = [...native, ...ENGINES.filter((e) => !native.includes(e))];
267
+ if (pinned && pinned !== 'auto')
268
+ ordered = ENGINES.filter((e) => e.id === pinned);
269
+ const engines = [];
270
+ const notes = [];
271
+ for (const spec of ordered) {
272
+ const isNative = native.includes(spec);
273
+ const bin = findBin(spec);
274
+ if (!bin) {
275
+ if (isNative)
276
+ notes.push(`${spec.label}'s command line agent is not installed on this machine (\`${spec.installHint}\`)`);
277
+ continue;
278
+ }
279
+ const reason = await readiness(spec, bin);
280
+ if (reason) {
281
+ notes.push(`${spec.label}: ${reason}`);
282
+ continue;
283
+ }
284
+ engines.push({ id: spec.id, label: spec.label, bin });
285
+ }
286
+ return { engines, notes };
287
+ }
288
+ export function agentTimeoutMs() {
289
+ const minutes = Number(process.env.AGILEMONSTER_AGENT_TIMEOUT_MINUTES || '');
290
+ return (Number.isFinite(minutes) && minutes >= 1 ? Math.min(minutes, 180) : 30) * 60 * 1000;
291
+ }
292
+ function failureTail(res) {
293
+ return (res.stderr.trim() || res.stdout.trim() || res.tail).split('\n').filter((l) => l.trim()).slice(-4).join(' | ').slice(0, 500);
294
+ }
295
+ /** Run one agent to completion. Never throws: a CLI that is missing, logged out or crashed comes back as `ok: false`. */
296
+ export async function runEngine(engine, opts) {
297
+ const started = Date.now();
298
+ const timeoutMs = opts.timeoutMs || agentTimeoutMs();
299
+ const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'am-agent-'));
300
+ const scratch = { lastMessageFile: path.join(scratchDir, 'last-message.txt') };
301
+ // The agent works on the code. The card belongs to the runner, so its API key stays out of the agent's reach.
302
+ const env = { ...process.env };
303
+ delete env.AGILEMONSTER_API_KEY;
304
+ try {
305
+ if (engine.id === 'custom') {
306
+ const promptFile = path.join(scratchDir, 'prompt.md');
307
+ fs.writeFileSync(promptFile, opts.prompt, 'utf8');
308
+ const res = await execShell(customCommand(), {
309
+ cwd: opts.projectPath,
310
+ timeoutMs,
311
+ env: { ...env, AGILEMONSTER_PROMPT_FILE: promptFile, AGILEMONSTER_PROJECT_PATH: opts.projectPath },
312
+ });
313
+ const text = res.stdout.trim();
314
+ return {
315
+ ok: res.ok,
316
+ text,
317
+ timedOut: res.timedOut,
318
+ durationMs: Date.now() - started,
319
+ failure: res.ok ? undefined : res.timedOut ? `timed out after ${Math.round(timeoutMs / 60000)} minutes` : failureTail(res),
320
+ };
321
+ }
322
+ const spec = ENGINES.find((e) => e.id === engine.id);
323
+ if (!spec)
324
+ return { ok: false, text: '', timedOut: false, durationMs: 0, failure: `unknown engine ${engine.id}` };
325
+ let res = await exec(engine.bin, spec.args(opts, scratch), { cwd: opts.projectPath, timeoutMs, env });
326
+ // A model name the CLI does not know fails within seconds: the card's preference must not cost the whole run
327
+ if (!res.ok && !res.timedOut && opts.model && Date.now() - started < 45000 && /model/i.test(`${res.stderr}\n${res.stdout}`)) {
328
+ res = await exec(engine.bin, spec.args({ ...opts, model: null }, scratch), { cwd: opts.projectPath, timeoutMs, env });
329
+ }
330
+ const parsed = spec.parse(res, scratch);
331
+ const failed = !res.ok || !!parsed.error;
332
+ return {
333
+ ok: !failed,
334
+ text: parsed.text,
335
+ sessionId: parsed.sessionId,
336
+ costUsd: parsed.costUsd,
337
+ timedOut: res.timedOut,
338
+ durationMs: Date.now() - started,
339
+ failure: !failed ? undefined : res.timedOut ? `timed out after ${Math.round(timeoutMs / 60000)} minutes` : (parsed.error || failureTail(res)).slice(0, 500),
340
+ };
341
+ }
342
+ finally {
343
+ try {
344
+ fs.rmSync(scratchDir, { recursive: true, force: true });
345
+ }
346
+ catch { }
347
+ }
348
+ }
349
+ /**
350
+ * What the agent is told. It reads like a request typed into the IDE — the card, the thread, and the few
351
+ * rules that differ from an interactive session (nobody is there to answer, and the runner does the shipping).
352
+ */
353
+ export function buildAgentPrompt(brief) {
354
+ const lines = [];
355
+ 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)');
356
+ if (brief.conversation.length > 0) {
357
+ lines.push('', '## Conversation on the card (oldest first)');
358
+ for (const turn of brief.conversation) {
359
+ lines.push(`${turn.who === 'requester' ? 'Requester' : 'Agent (an earlier attempt)'}: ${turn.text.trim()}`, '');
360
+ }
361
+ 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.');
362
+ }
363
+ 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.');
364
+ if (brief.developerFiles.length > 0) {
365
+ lines.push(`- These files hold the developer's own uncommitted work. Do not modify them: ${brief.developerFiles.slice(0, 40).map((f) => `\`${f}\``).join(', ')}`);
366
+ }
367
+ if (brief.earlierFiles.length > 0) {
368
+ 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.` +
369
+ (brief.earlierSummary ? ` It reported: ${brief.earlierSummary.trim().slice(0, 600)}` : ''));
370
+ }
371
+ 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"}');
372
+ return lines.join('\n');
373
+ }
374
+ /** Read the closing JSON object out of the agent's final message. Tolerates code fences and prose around it. */
375
+ export function parseAgentReport(text) {
376
+ const clean = (text || '').trim();
377
+ const starts = [];
378
+ const re = /\{\s*"status"\s*:/g;
379
+ let m;
380
+ while ((m = re.exec(clean)))
381
+ starts.push(m.index);
382
+ for (let i = starts.length - 1; i >= 0; i--) {
383
+ let end = clean.lastIndexOf('}');
384
+ while (end > starts[i]) {
385
+ try {
386
+ const obj = JSON.parse(clean.slice(starts[i], end + 1));
387
+ const status = String(obj.status || '').toLowerCase();
388
+ const steps = Array.isArray(obj.verification_steps)
389
+ ? obj.verification_steps.map((s) => String(s).trim()).filter(Boolean)
390
+ : typeof obj.verification_steps === 'string' && obj.verification_steps.trim()
391
+ ? [obj.verification_steps.trim()]
392
+ : [];
393
+ return {
394
+ status: status === 'done' ? 'done' : status === 'blocked' ? 'blocked' : 'unknown',
395
+ summary: String(obj.summary || '').trim(),
396
+ verificationSteps: steps,
397
+ question: String(obj.question || '').trim(),
398
+ };
399
+ }
400
+ catch { }
401
+ end = clean.lastIndexOf('}', end - 1);
402
+ }
403
+ }
404
+ return { status: 'unknown', summary: '', verificationSteps: [], question: '' };
405
+ }
406
+ /** The agent's prose without the closing JSON object: what to show a human when the report is missing a field. */
407
+ export function proseOf(text, maxChars = 900) {
408
+ const clean = (text || '').replace(/```(?:json)?\s*\{\s*"status"[\s\S]*?```/g, '').replace(/\{\s*"status"\s*:[\s\S]*$/, '').trim();
409
+ return clean.length > maxChars ? `…${clean.slice(-maxChars)}` : clean;
410
+ }
411
+ const ABSENT = 'absent';
412
+ const MAX_FINGERPRINT_BYTES = 8 * 1024 * 1024;
413
+ const MAX_SAVED_BYTES = 2 * 1024 * 1024;
414
+ const git = (cwd, args, timeoutMs = 30000) => exec('git', ['--literal-pathspecs', ...args], { cwd, timeoutMs });
415
+ function fingerprint(abs) {
416
+ try {
417
+ const stat = fs.statSync(abs);
418
+ if (!stat.isFile())
419
+ return `other:${stat.mtimeMs}`;
420
+ if (stat.size > MAX_FINGERPRINT_BYTES)
421
+ return `big:${stat.size}:${stat.mtimeMs}`;
422
+ return crypto.createHash('sha1').update(fs.readFileSync(abs)).digest('hex');
423
+ }
424
+ catch {
425
+ return ABSENT;
426
+ }
427
+ }
428
+ /** Paths from `git status --porcelain -z`. A rename reports both its new and its old path. */
429
+ export function parsePorcelainZ(out) {
430
+ const paths = [];
431
+ const tokens = out.split('\0');
432
+ for (let i = 0; i < tokens.length; i++) {
433
+ const token = tokens[i];
434
+ if (token.length < 4)
435
+ continue;
436
+ paths.push(token.slice(3));
437
+ if (token[0] === 'R' || token[0] === 'C') {
438
+ i++;
439
+ if (tokens[i])
440
+ paths.push(tokens[i]);
441
+ }
442
+ }
443
+ return paths;
444
+ }
445
+ async function statusPaths(projectPath) {
446
+ const res = await git(projectPath, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
447
+ return res.ok ? parsePorcelainZ(res.stdout) : null;
448
+ }
449
+ /** Record the working tree before the agent starts. Null when the folder is not a Git repository. */
450
+ export async function snapshotWorkingTree(projectPath) {
451
+ const head = await git(projectPath, ['rev-parse', 'HEAD']);
452
+ const paths = await statusPaths(projectPath);
453
+ if (!head.ok || !paths)
454
+ return null;
455
+ const entries = new Map();
456
+ const saved = new Map();
457
+ for (const rel of paths) {
458
+ const abs = path.join(projectPath, rel);
459
+ const print = fingerprint(abs);
460
+ entries.set(rel, print);
461
+ if (print === ABSENT) {
462
+ saved.set(rel, null);
463
+ }
464
+ else {
465
+ try {
466
+ if (fs.statSync(abs).size <= MAX_SAVED_BYTES)
467
+ saved.set(rel, fs.readFileSync(abs));
468
+ }
469
+ catch { }
470
+ }
471
+ }
472
+ return { head: head.stdout.trim(), entries, saved };
473
+ }
474
+ /** Files an agent's own tooling drops into a project. New ones are never part of a card's change. */
475
+ function isAgentToolingPath(rel) {
476
+ return /^(\.claude|\.cursor|\.codex|\.agilemonster|\.specstory)(\/|$)/.test(rel) || rel === '.agilemonster.json';
477
+ }
478
+ /**
479
+ * Compare the working tree with the snapshot. If the agent committed despite its instructions, the commits
480
+ * are undone first (soft, so nothing is lost) — unless they were pushed, which only a human can sort out.
481
+ */
482
+ export async function changesSince(projectPath, snap) {
483
+ let pushedCommit;
484
+ const head = await git(projectPath, ['rev-parse', 'HEAD']);
485
+ const nowHead = head.ok ? head.stdout.trim() : snap.head;
486
+ if (nowHead !== snap.head) {
487
+ const descends = await git(projectPath, ['merge-base', '--is-ancestor', snap.head, nowHead]);
488
+ const onRemote = await git(projectPath, ['branch', '-r', '--contains', nowHead]);
489
+ if (onRemote.ok && onRemote.stdout.trim() !== '') {
490
+ pushedCommit = nowHead;
491
+ }
492
+ else if (descends.ok) {
493
+ await git(projectPath, ['reset', '--soft', snap.head]);
494
+ }
495
+ }
496
+ const changed = [];
497
+ const overlapping = [];
498
+ const now = new Set((await statusPaths(projectPath)) || []);
499
+ for (const rel of now) {
500
+ const before = snap.entries.get(rel);
501
+ if (before === undefined) {
502
+ if (!isAgentToolingPath(rel))
503
+ changed.push(rel);
504
+ }
505
+ else if (fingerprint(path.join(projectPath, rel)) !== before) {
506
+ changed.push(rel);
507
+ overlapping.push(rel);
508
+ }
509
+ }
510
+ // A file that was dirty and is clean again was put back to HEAD by the agent: the developer's change is gone
511
+ for (const rel of snap.entries.keys()) {
512
+ if (!now.has(rel)) {
513
+ changed.push(rel);
514
+ overlapping.push(rel);
515
+ }
516
+ }
517
+ return { changed: changed.sort(), overlapping: overlapping.sort(), pushedCommit };
518
+ }
519
+ /**
520
+ * Put the given files back the way the snapshot found them. Returns the files that could not be restored
521
+ * (only possible for a file the developer had changed that was too large to keep a copy of).
522
+ */
523
+ export async function restoreWorkingTree(projectPath, snap, paths) {
524
+ const failed = [];
525
+ const root = path.resolve(projectPath);
526
+ for (const rel of paths) {
527
+ const abs = path.resolve(root, rel);
528
+ if (abs !== root && !abs.startsWith(root + path.sep))
529
+ continue;
530
+ try {
531
+ if (snap.entries.has(rel)) {
532
+ if (!snap.saved.has(rel)) {
533
+ failed.push(rel);
534
+ continue;
535
+ }
536
+ const original = snap.saved.get(rel);
537
+ if (original === null || original === undefined) {
538
+ fs.rmSync(abs, { force: true });
539
+ }
540
+ else {
541
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
542
+ fs.writeFileSync(abs, original);
543
+ }
544
+ continue;
545
+ }
546
+ // Clean before the run: either a tracked file (take it from HEAD) or one that did not exist (remove it)
547
+ const tracked = await git(projectPath, ['cat-file', '-e', `${snap.head}:${rel}`]);
548
+ if (tracked.ok) {
549
+ const back = await git(projectPath, ['checkout', snap.head, '--', rel]);
550
+ if (!back.ok)
551
+ failed.push(rel);
552
+ }
553
+ else {
554
+ await git(projectPath, ['rm', '--cached', '--quiet', '--ignore-unmatch', '--', rel]);
555
+ fs.rmSync(abs, { force: true });
556
+ let dir = path.dirname(abs);
557
+ while (dir !== root && dir.startsWith(root + path.sep)) {
558
+ try {
559
+ fs.rmdirSync(dir); // only succeeds on a directory the agent created and we just emptied
560
+ }
561
+ catch {
562
+ break;
563
+ }
564
+ dir = path.dirname(dir);
565
+ }
566
+ }
567
+ }
568
+ catch {
569
+ failed.push(rel);
570
+ }
571
+ }
572
+ return failed;
573
+ }
574
+ /**
575
+ * `git diff` does not show files Git has never seen. Mark the agent's new files as "to be added" (no content
576
+ * is staged) so the reviewer's diff includes them.
577
+ */
578
+ export async function markNewFilesForDiff(projectPath, files) {
579
+ if (files.length === 0)
580
+ return;
581
+ const untracked = await git(projectPath, ['ls-files', '--others', '--exclude-standard', '-z', '--', ...files]);
582
+ const fresh = untracked.ok ? untracked.stdout.split('\0').filter(Boolean) : [];
583
+ if (fresh.length > 0)
584
+ await git(projectPath, ['add', '--intent-to-add', '--', ...fresh]);
585
+ }
586
+ function runRecordFile(cardId) {
587
+ return path.join(agileMonsterHome(), 'agent-runs', `${cardId.replace(/[^a-zA-Z0-9-]/g, '')}.json`);
588
+ }
589
+ /**
590
+ * Snapshot the tree before this card's agent starts, and note that a run is in progress.
591
+ *
592
+ * `interrupted` is true when an earlier run of the same card never reached a conclusion (the machine slept,
593
+ * the daemon was killed). Whatever that run left in the tree cannot be told apart from the developer's own
594
+ * edits, so it is treated as theirs — never reverted, never committed — and the card can say where the
595
+ * leftovers probably came from.
596
+ */
597
+ export async function baselineFor(cardId, projectPath) {
598
+ const snapshot = await snapshotWorkingTree(projectPath);
599
+ if (!snapshot)
600
+ return null;
601
+ const prior = readJson(runRecordFile(cardId));
602
+ const interrupted = !!prior && prior.project_path === projectPath;
603
+ try {
604
+ writeJsonAtomic(runRecordFile(cardId), {
605
+ card_id: cardId,
606
+ project_path: projectPath,
607
+ pid: process.pid,
608
+ at: new Date().toISOString(),
609
+ });
610
+ }
611
+ catch { }
612
+ return { snapshot, interrupted };
613
+ }
614
+ /** The card's run reached a conclusion (shipped, reverted, or handed to the human): it is no longer in progress. */
615
+ export function clearBaseline(cardId) {
616
+ try {
617
+ fs.rmSync(runRecordFile(cardId), { force: true });
618
+ }
619
+ catch { }
620
+ }
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
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.23";
2
+ export declare const MCP_SERVER_VERSION = "1.0.25";
3
3
  /** An API call the server answered with an error status. `status` lets callers tell "refused" from "broken". */
4
4
  export declare class ApiError extends Error {
5
5
  readonly status: number;
package/dist/client.js CHANGED
@@ -4,7 +4,7 @@ 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.23';
7
+ export const MCP_SERVER_VERSION = '1.0.25';
8
8
  /** An API call the server answered with an error status. `status` lets callers tell "refused" from "broken". */
9
9
  export class ApiError extends Error {
10
10
  status;
package/dist/server.d.ts CHANGED
@@ -36,7 +36,7 @@ export declare function createServer(client?: AgileMonsterClient): Server<{
36
36
  } | undefined;
37
37
  } | undefined;
38
38
  }>;
39
- export declare function collectProjectSourceFiles(projectPath: string, keywords: string[]): string[];
39
+ export declare function collectProjectSourceFiles(projectPath: string, keywords: string[], phrases?: string[]): string[];
40
40
  /**
41
41
  * The board binding's deploy profile for a card, as this machine may use it. A route that another account
42
42
  * set arrives as a proposal (or is demoted to one here): see trustBoundRoute and resolveDeployProfile.
@@ -64,6 +64,10 @@ export declare function recoverEditJournals(): number;
64
64
  * Anchored to the start of the title so "Add a restart button to the server page" is not one.
65
65
  */
66
66
  export declare function isRedeployOnlyCard(card: Pick<Card, 'title'>): boolean;
67
+ /** The part of a build log that names the problem, without colour codes. Falls back to the last lines. */
68
+ export declare function buildErrorExcerpt(output: string, maxLines?: number, maxChars?: number): string;
69
+ /** Exact phrases the requester put in quotes ("Recently active projects"): the strongest pointer to the right file. */
70
+ export declare function quotedPhrases(text: string): string[];
67
71
  export declare function executeQueuedCardAutonomously(client: AgileMonsterClient, card: Card, projectPath: string): Promise<boolean>;
68
72
  /**
69
73
  * Finish a card the human approved into Done (or flagged as `deploying`).