@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/dist/server.js CHANGED
@@ -7,12 +7,13 @@ import crypto from 'node:crypto';
7
7
  import { spawn, execSync, execFileSync } from 'node:child_process';
8
8
  import { isUtf8 } from 'node:buffer';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { AgileMonsterClient, MCP_SERVER_VERSION } from './client.js';
10
+ import { AgileMonsterClient, MCP_SERVER_VERSION, isClaimConflict } from './client.js';
11
11
  import { AGENT_GUIDELINES_MARKDOWN } from './guidelines.js';
12
12
  import { knownSecrets, redactResult } from './redact.js';
13
- import { describeProfile, methodLabel, normalizeMethod, resolveDeployProfile, } from './deploy-profile.js';
13
+ import { describeProfile, methodLabel, normalizeMethod, resolveDeployProfile, trustBoundRoute, } from './deploy-profile.js';
14
14
  import { addPendingVerification, getCardState, loadMemory, recordAttempt, removePendingVerification, setCardState, setExplicitProfile, } from './deploy-memory.js';
15
15
  import { checkCiOnce, describeFailureForHuman, ensureLocalGitExcludes, parseDeployDirective, runDeployment, } from './deploy-runner.js';
16
+ import { baselineFor, buildAgentPrompt, changesSince, clearBaseline, engineLabelFor, markNewFilesForDiff, parseAgentReport, proseOf, resolveEngines, restoreWorkingTree, runEngine, } from './agent-engine.js';
16
17
  import { addWellKnownToolDirs, agileMonsterHome, configureLogger, ensureUserPath, errorMessage, exec, log, readJson, which, writeJsonAtomic, } from './runtime.js';
17
18
  export function createServer(client = new AgileMonsterClient()) {
18
19
  const server = new Server({
@@ -608,7 +609,8 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
608
609
  case 'agilemonster_claim_task': {
609
610
  const cardId = args.card_id;
610
611
  const provider = args.provider || projectConfig?.provider || 'antigravity';
611
- const runRef = args.run_ref;
612
+ // One id per IDE session, so the server can tell this session from the background daemon
613
+ const runRef = args.run_ref?.trim() || sessionRunRef('interactive', provider);
612
614
  const expectedBoardId = projectConfig?.board_id;
613
615
  if (!expectedBoardId) {
614
616
  return {
@@ -623,6 +625,8 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
623
625
  };
624
626
  }
625
627
  const card = await client.claimTask(cardId, provider, runRef, expectedBoardId);
628
+ // Reported with every pulse from now on: the claim stays ours while this IDE's MCP process lives
629
+ holdClaim(cardId, runRef);
626
630
  return {
627
631
  content: [
628
632
  {
@@ -637,6 +641,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
637
641
  const provider = args.provider || projectConfig?.provider || 'antigravity';
638
642
  const question = args.question;
639
643
  const card = await client.askQuestion(cardId, provider, question);
644
+ releaseClaim(cardId);
640
645
  return {
641
646
  content: [
642
647
  {
@@ -653,6 +658,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
653
658
  const tokensUsed = args.tokens_used;
654
659
  const costUsd = args.cost_usd;
655
660
  const card = await client.addProgress(cardId, provider, update, tokensUsed, costUsd);
661
+ touchClaim(cardId);
656
662
  return {
657
663
  content: [
658
664
  {
@@ -669,6 +675,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
669
675
  const costUsd = args.cost_usd || 0;
670
676
  const note = args.note;
671
677
  const card = await client.recordSpend(cardId, provider, tokens, costUsd, note);
678
+ touchClaim(cardId);
672
679
  return {
673
680
  content: [
674
681
  {
@@ -700,6 +707,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
700
707
  const targetList = args.target_list || args.target_list_id || undefined;
701
708
  const assignToUserId = args.assign_to_user_id;
702
709
  const card = await client.submitReview(cardId, provider, summary, verificationSteps, targetList, assignToUserId);
710
+ releaseClaim(cardId);
703
711
  return {
704
712
  content: [
705
713
  {
@@ -728,6 +736,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
728
736
  const provider = args.provider || projectConfig?.provider || 'antigravity';
729
737
  const comment = args.comment;
730
738
  const card = await client.blockTask(cardId, provider, comment);
739
+ releaseClaim(cardId);
731
740
  return {
732
741
  content: [
733
742
  {
@@ -742,6 +751,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
742
751
  const provider = args.provider || projectConfig?.provider || 'antigravity';
743
752
  const comment = args.comment;
744
753
  const card = await client.completeTask(cardId, provider, comment);
754
+ releaseClaim(cardId);
745
755
  return {
746
756
  content: [
747
757
  {
@@ -1208,7 +1218,7 @@ function recentGitActivity(projectPath) {
1208
1218
  maxCommits = Math.max(maxCommits, n);
1209
1219
  return { files, dirCommits, maxCommits };
1210
1220
  }
1211
- export function collectProjectSourceFiles(projectPath, keywords) {
1221
+ export function collectProjectSourceFiles(projectPath, keywords, phrases = []) {
1212
1222
  const isFlutterProject = fs.existsSync(path.join(projectPath, 'pubspec.yaml'));
1213
1223
  const activity = recentGitActivity(projectPath);
1214
1224
  let docsText = '';
@@ -1288,6 +1298,11 @@ export function collectProjectSourceFiles(projectPath, keywords) {
1288
1298
  if (kw.length >= 4 && contentLower.includes(kw))
1289
1299
  c.score += 10;
1290
1300
  }
1301
+ // Text the requester quoted from the screen: the file that renders it is almost certainly the target
1302
+ for (const phrase of phrases) {
1303
+ if (contentLower.includes(phrase))
1304
+ c.score += 60;
1305
+ }
1291
1306
  }
1292
1307
  }
1293
1308
  catch { }
@@ -1351,14 +1366,61 @@ function appendIdeActivityLog(projectPath, entry) {
1351
1366
  }
1352
1367
  catch { }
1353
1368
  }
1354
- function boundProfileOf(card) {
1355
- return {
1369
+ /**
1370
+ * The board binding's deploy profile for a card, as this machine may use it. A route that another account
1371
+ * set arrives as a proposal (or is demoted to one here): see trustBoundRoute and resolveDeployProfile.
1372
+ */
1373
+ export function boundProfileOf(card, ownerId) {
1374
+ return (trustBoundRoute({
1356
1375
  deploy_method: card.deploy_method,
1357
1376
  deploy_command: card.deploy_command,
1358
1377
  live_url: card.live_url,
1359
1378
  build_command: card.build_command,
1360
1379
  deploy_source: card.deploy_source,
1361
- };
1380
+ deploy_set_by: card.deploy_set_by,
1381
+ proposal: card.deploy_proposal || null,
1382
+ }, ownerId) || {});
1383
+ }
1384
+ // ---------------------------------------------------------------------------
1385
+ // Claims held by this process
1386
+ // ---------------------------------------------------------------------------
1387
+ const PROCESS_STARTED_AT = Date.now();
1388
+ /** An interactive claim nobody touched for this long is no longer reported as alive. */
1389
+ const CLAIM_REPORT_MAX_IDLE_MS = 4 * 60 * 60 * 1000;
1390
+ const heldClaims = new Map();
1391
+ /**
1392
+ * The id under which this process claims cards. The server keeps a claim for the session that made it, so
1393
+ * the id is stable for the life of the process. Background runs start with `auto-pulse-`: only those are
1394
+ * re-queued automatically when they stop responding.
1395
+ */
1396
+ export function sessionRunRef(kind, provider) {
1397
+ return kind === 'background'
1398
+ ? `auto-pulse-${process.pid}-${PROCESS_STARTED_AT}`
1399
+ : `ide-${(provider || 'agent').replace(/[^a-z0-9_]+/gi, '-').slice(0, 24)}-${process.pid}-${PROCESS_STARTED_AT}`;
1400
+ }
1401
+ export function holdClaim(cardId, runRef) {
1402
+ heldClaims.set(cardId, { runRef, touchedAt: Date.now() });
1403
+ }
1404
+ /** The agent did something with the card: its claim is still in use. */
1405
+ export function touchClaim(cardId) {
1406
+ const held = heldClaims.get(cardId);
1407
+ if (held)
1408
+ held.touchedAt = Date.now();
1409
+ }
1410
+ export function releaseClaim(cardId) {
1411
+ heldClaims.delete(cardId);
1412
+ }
1413
+ /** What the next pulse reports as held by this process. */
1414
+ export function activeClaims(now = Date.now()) {
1415
+ const out = [];
1416
+ for (const [cardId, held] of heldClaims.entries()) {
1417
+ if (now - held.touchedAt > CLAIM_REPORT_MAX_IDLE_MS) {
1418
+ heldClaims.delete(cardId);
1419
+ continue;
1420
+ }
1421
+ out.push({ card_id: cardId, run_ref: held.runRef });
1422
+ }
1423
+ return out;
1362
1424
  }
1363
1425
  /** Feed the outcome back to the server so the board's binding converges on the route that really works. */
1364
1426
  async function reportOutcome(client, card, projectPath, res) {
@@ -1497,7 +1559,7 @@ async function runRedeployOnlyCard(client, card, projectPath, provider, workerLa
1497
1559
  title: card.title || 'redeploy',
1498
1560
  cardId: card.id,
1499
1561
  redeploy: true,
1500
- bound: boundProfileOf(card),
1562
+ bound: boundProfileOf(card, await machineOwnerId(client)),
1501
1563
  });
1502
1564
  await reportOutcome(client, card, projectPath, res);
1503
1565
  const blankState = (extra) => ({
@@ -1650,7 +1712,7 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1650
1712
  cardId: card.id,
1651
1713
  files: state.files,
1652
1714
  redeploy: true,
1653
- bound: boundProfileOf(card),
1715
+ bound: boundProfileOf(card, await machineOwnerId(client)),
1654
1716
  override: directive.method ? { method: directive.method, command: directive.command } : null,
1655
1717
  });
1656
1718
  await reportOutcome(client, card, projectPath, res);
@@ -1727,6 +1789,543 @@ function frontendBuildDirs(projectPath, modifiedFiles) {
1727
1789
  }
1728
1790
  return dirs;
1729
1791
  }
1792
+ /** The part of a build log that names the problem, without colour codes. Falls back to the last lines. */
1793
+ export function buildErrorExcerpt(output, maxLines = 12, maxChars = 1200) {
1794
+ const lines = output
1795
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
1796
+ .split('\n')
1797
+ .map((l) => l.trimEnd())
1798
+ .filter((l) => l.trim() !== '');
1799
+ const at = lines.findIndex((l) => /(failed to compile|type error|error TS\d+|syntax ?error|module not found|cannot find|^\s*error\b|\berror:)/i.test(l));
1800
+ const picked = at >= 0 ? lines.slice(Math.max(0, at - 1), at - 1 + maxLines) : lines.slice(-maxLines);
1801
+ return picked.join('\n').slice(0, maxChars) || '(the build printed nothing)';
1802
+ }
1803
+ /** Pre-submit build gate: build the package that owns each changed front-end file (works for any monorepo layout). */
1804
+ async function runBuildGate(projectPath, files) {
1805
+ for (const dir of frontendBuildDirs(projectPath, files)) {
1806
+ const buildCmd = fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) ? 'pnpm' : 'npm';
1807
+ const res = await exec(buildCmd, ['run', 'build'], { cwd: dir, timeoutMs: 6 * 60 * 1000 });
1808
+ if (res.timedOut) {
1809
+ // A slow build is not a compile error: do not throw the work away over it
1810
+ return { ok: true, note: `The build in \`${path.relative(projectPath, dir) || '.'}\` did not finish within 6 minutes, so it was not verified locally.` };
1811
+ }
1812
+ if (!res.ok) {
1813
+ return { ok: false, errorOutput: `${res.stdout}\n${res.stderr}`.trim().slice(-8000) };
1814
+ }
1815
+ }
1816
+ return { ok: true };
1817
+ }
1818
+ function brokenBaselineNote(errorOutput) {
1819
+ return `⚠️ This project's build already fails on this machine without my change, so the change could not be build-verified here:\n\`\`\`\n${buildErrorExcerpt(errorOutput, 6, 500)}\n\`\`\``;
1820
+ }
1821
+ /** Why this card ran on the built-in pipeline, and the one thing that would put a real agent on the next one. */
1822
+ function builtInLimitsNote(run) {
1823
+ const native = engineLabelFor(run.provider);
1824
+ const fix = run.engineNotes.length > 0
1825
+ ? run.engineNotes.map((n) => `- ${n}`).join('\n')
1826
+ : `- No agent command line tool was found on this machine. Install ${native ? `${native}'s` : "Cursor's or Claude Code's"} CLI and sign in once.`;
1827
+ return `This card ran on Agile Monster's built-in engine, which edits a handful of files in one pass and cannot explore the project or debug a build the way ${native || 'your coding agent'} does in the IDE. To have cards worked by the real agent:\n${fix}`;
1828
+ }
1829
+ /**
1830
+ * The card's thread as the agent should read it: what the requester wrote, and the questions and results of
1831
+ * earlier attempts. Status chatter is dropped. So is anything written by someone other than the person this
1832
+ * machine belongs to: a comment is an instruction to an agent that can run commands here.
1833
+ */
1834
+ async function cardConversation(client, card, ownerId) {
1835
+ let comments = [];
1836
+ try {
1837
+ comments = await client.listComments(card.id);
1838
+ }
1839
+ catch {
1840
+ return [];
1841
+ }
1842
+ const turns = [];
1843
+ for (const c of Array.isArray(comments) ? comments : []) {
1844
+ const text = (c.text || '').trim();
1845
+ if (!text)
1846
+ continue;
1847
+ const fromAgent = (c.author_kind || 'member').toLowerCase() === 'agent' || /^(🤖|🛠️|✅|🚀|🔄|🛡️|🚨|⚠️|❓|⏳|📦|📤|💓)/u.test(text);
1848
+ if (fromAgent) {
1849
+ // Questions and review summaries are context; "claimed", "selected files", heartbeats are noise
1850
+ if (!/^(❓|✅|⚠️ \*\*\[Agent Needs Input\]\*\*)/u.test(text))
1851
+ continue;
1852
+ turns.push({ who: 'agent', text: text.slice(0, 1500) });
1853
+ }
1854
+ else {
1855
+ if (ownerId && c.user_id && c.user_id !== ownerId)
1856
+ continue;
1857
+ turns.push({ who: 'requester', text: text.slice(0, 3000) });
1858
+ }
1859
+ }
1860
+ return turns.slice(-12);
1861
+ }
1862
+ /** Exact phrases the requester put in quotes ("Recently active projects"): the strongest pointer to the right file. */
1863
+ export function quotedPhrases(text) {
1864
+ const out = new Set();
1865
+ for (const m of text.matchAll(/["“”'‘’`]([^"“”'‘’`\n]{4,80})["“”'‘’`]/g)) {
1866
+ const phrase = m[1].trim().toLowerCase();
1867
+ if (phrase.split(/\s+/).length >= 2)
1868
+ out.add(phrase);
1869
+ }
1870
+ return Array.from(out).slice(0, 12);
1871
+ }
1872
+ /**
1873
+ * Hand the card to a real coding agent (Cursor, Claude Code, Codex — whichever this machine can run) and
1874
+ * report what it changed. 'unavailable' means no agent could take it and the built-in pipeline should;
1875
+ * 'handled' means the card went back to the human with a specific question or an honest failure report.
1876
+ */
1877
+ async function produceChangeWithAgent(run) {
1878
+ const { client, card, projectPath, provider, workerLabel, lockFile, carryOverFiles } = run;
1879
+ const { engines, notes } = await resolveEngines(provider);
1880
+ run.engineNotes.push(...notes);
1881
+ if (engines.length === 0)
1882
+ return 'unavailable';
1883
+ // The agent gets a shell. Like a deploy command, what runs on this machine is for its owner to decide:
1884
+ // only cards its owner wrote are handed over (and only the owner's replies were kept in the conversation).
1885
+ if (!run.ownerId || !card.created_by || card.created_by !== run.ownerId) {
1886
+ run.engineNotes.push(card.created_by
1887
+ ? 'This card was written by another member of the board. An agent that can run commands on this machine only works cards its owner created.'
1888
+ : 'The Agile Monster server did not say who created this card (it predates that field), so the agent on this machine was not started for it.');
1889
+ log.info('agent.skipped', { card: card.id, reason: card.created_by ? 'card_not_from_owner' : 'author_unknown' });
1890
+ return 'unavailable';
1891
+ }
1892
+ const started = await baselineFor(card.id, projectPath);
1893
+ if (!started) {
1894
+ run.engineNotes.push('The project folder is not a Git repository, so an agent\'s changes could not be tracked.');
1895
+ return 'unavailable';
1896
+ }
1897
+ const baseline = started.snapshot;
1898
+ const revert = async (files) => {
1899
+ if (files.length > 0)
1900
+ await restoreWorkingTree(projectPath, baseline, files);
1901
+ clearBaseline(card.id);
1902
+ return files.length > 0;
1903
+ };
1904
+ run.rollback.fn = async () => revert((await changesSince(projectPath, baseline)).changed);
1905
+ const backToHuman = async (question, summary, files) => {
1906
+ run.rollback.fn = null;
1907
+ removeTaskLock(lockFile);
1908
+ appendIdeActivityLog(projectPath, {
1909
+ cardId: card.id,
1910
+ cardTitle: card.title,
1911
+ agentName: workerLabel,
1912
+ model: card.agent_model || 'auto',
1913
+ status: 'waiting_for_input',
1914
+ summary,
1915
+ files,
1916
+ });
1917
+ await client.askQuestion(card.id, provider, question);
1918
+ return 'handled';
1919
+ };
1920
+ const prompt = buildAgentPrompt({
1921
+ title: card.title || '',
1922
+ description: card.description || '',
1923
+ projectPath,
1924
+ boardName: card.board_name,
1925
+ conversation: run.conversation,
1926
+ developerFiles: Array.from(baseline.entries.keys()).filter((f) => !carryOverFiles.includes(f)),
1927
+ earlierFiles: carryOverFiles,
1928
+ earlierSummary: run.earlierSummary,
1929
+ });
1930
+ const model = card.agent_model && card.agent_model !== 'auto' ? card.agent_model : null;
1931
+ // 1. Run the first agent that actually starts. One that is logged out or crashes on launch touches nothing.
1932
+ let engine = engines[0];
1933
+ let result = null;
1934
+ for (const candidate of engines) {
1935
+ await client.addProgress(card.id, provider, `Autonomous background runner claimed task in \`${projectPath}\` and moved card to **In Progress**. **${candidate.label}** is working on it in the project folder — reading, searching, editing and building, as it would in the IDE.`);
1936
+ log.info('agent.start', { card: card.id, engine: candidate.id, project: projectPath });
1937
+ const attempt = await runEngine(candidate, { prompt, projectPath, model });
1938
+ log.info('agent.end', { card: card.id, engine: candidate.id, ok: attempt.ok, seconds: Math.round(attempt.durationMs / 1000), failure: attempt.failure });
1939
+ if (!attempt.ok && (await changesSince(projectPath, baseline)).changed.length === 0) {
1940
+ run.engineNotes.push(`${candidate.label} could not run: ${attempt.failure || 'it exited without a result'}`);
1941
+ continue;
1942
+ }
1943
+ engine = candidate;
1944
+ result = attempt;
1945
+ break;
1946
+ }
1947
+ if (!result) {
1948
+ run.rollback.fn = null;
1949
+ clearBaseline(card.id);
1950
+ return 'unavailable';
1951
+ }
1952
+ if (result.costUsd && result.costUsd > 0) {
1953
+ try {
1954
+ await client.recordSpend(card.id, provider, 0, result.costUsd, `${engine.label} headless run`);
1955
+ }
1956
+ catch { }
1957
+ }
1958
+ let change = await changesSince(projectPath, baseline);
1959
+ if (change.pushedCommit) {
1960
+ clearBaseline(card.id);
1961
+ return backToHuman(`${engine.label} committed and pushed its work itself (\`${change.pushedCommit.slice(0, 10)}\`) instead of leaving that to me, so I did not run the usual build check or deployment for this card.\n\nPlease look at that commit. Reply here if it needs changes.`, `${engine.label} pushed ${change.pushedCommit.slice(0, 10)} on its own`, change.changed);
1962
+ }
1963
+ // Never ship the developer's own half-finished work under this card's name
1964
+ const foreign = change.overlapping.filter((f) => !carryOverFiles.includes(f));
1965
+ if (foreign.length > 0) {
1966
+ await revert(change.changed);
1967
+ return backToHuman(`I need to change ${foreign.map((f) => `\`${f}\``).join(', ')}, but ${foreign.length === 1 ? 'that file has' : 'those files have'} uncommitted changes in \`${projectPath}\` that are not mine. Committing my edit would also commit that unfinished work, so I stopped and put everything back.\n\n${started.interrupted
1968
+ ? 'An earlier run of this card was interrupted before it finished, so these may be its leftovers rather than your work. If you did not make them, discard them (`git checkout -- <file>`); otherwise commit or stash them. Then reply here and I will continue.'
1969
+ : 'Commit or stash those changes, then reply here and I will continue.'}`, `Stopped: ${foreign.join(', ')} had uncommitted changes`, foreign);
1970
+ }
1971
+ if (!result.ok) {
1972
+ await revert(change.changed);
1973
+ return backToHuman(`${engine.label} worked on this for ${Math.max(1, Math.round(result.durationMs / 60000))} minute(s) but did not finish: ${result.failure || 'it stopped without a result'}.\n\nI put the files back the way they were. Reply here to have it try again — if the card is large, splitting it into smaller cards helps.`, `${engine.label} did not finish: ${result.failure || 'no result'}`, []);
1974
+ }
1975
+ let report = parseAgentReport(result.text);
1976
+ if (report.status === 'blocked' || change.changed.length === 0) {
1977
+ await revert(change.changed);
1978
+ const said = report.question || report.summary || proseOf(result.text);
1979
+ return backToHuman(report.status === 'blocked' && report.question
1980
+ ? report.question
1981
+ : `${engine.label} finished without changing any files.${said ? `\n\n${said}` : ''}\n\nIf something is still missing, reply here with what you expected to change.`, `Needs input: ${said.slice(0, 400)}`, []);
1982
+ }
1983
+ // 2. The runner's own build gate. The agent built it too, so a failure here is usually this machine's
1984
+ // environment — check that before sending the agent after an error that was never its own.
1985
+ let gate = await runBuildGate(projectPath, change.changed);
1986
+ if (!gate.ok) {
1987
+ const mine = new Map();
1988
+ for (const rel of change.changed) {
1989
+ try {
1990
+ mine.set(rel, fs.readFileSync(path.join(projectPath, rel)));
1991
+ }
1992
+ catch {
1993
+ mine.set(rel, null);
1994
+ }
1995
+ }
1996
+ await restoreWorkingTree(projectPath, baseline, change.changed);
1997
+ const pristine = await runBuildGate(projectPath, change.changed);
1998
+ for (const [rel, content] of mine) {
1999
+ const abs = path.join(projectPath, rel);
2000
+ if (content === null) {
2001
+ fs.rmSync(abs, { force: true });
2002
+ }
2003
+ else {
2004
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
2005
+ fs.writeFileSync(abs, content);
2006
+ }
2007
+ }
2008
+ if (!pristine.ok) {
2009
+ gate = { ok: true, note: brokenBaselineNote(pristine.errorOutput || '') };
2010
+ }
2011
+ else {
2012
+ await client.addProgress(card.id, provider, `The build failed after the change. Sending the error back to ${engine.label} to fix.`);
2013
+ const repair = await runEngine(engine, {
2014
+ prompt: `The Agile Monster runner built the project after your change and the build failed:\n\n${buildErrorExcerpt(gate.errorOutput || '', 60, 5000)}\n\nYour changes are still in the working tree. Fix the build, run it yourself to confirm, and keep to the same rules as before (no commits, no Agile Monster tools). End with the same closing JSON object.\n\nFor reference, the original brief:\n\n${prompt}`,
2015
+ projectPath,
2016
+ model,
2017
+ resumeSessionId: result.sessionId,
2018
+ });
2019
+ change = await changesSince(projectPath, baseline);
2020
+ const repairedReport = parseAgentReport(repair.text);
2021
+ if (repairedReport.status === 'done' && repairedReport.summary)
2022
+ report = repairedReport;
2023
+ gate = change.changed.length > 0 ? await runBuildGate(projectPath, change.changed) : gate;
2024
+ const stillForeign = change.overlapping.filter((f) => !carryOverFiles.includes(f));
2025
+ if (!gate.ok || change.changed.length === 0 || stillForeign.length > 0) {
2026
+ await revert(change.changed);
2027
+ return backToHuman(`${engine.label} made the change, but the project did not build with it, and its attempt to fix that did not get the build green. I reverted everything rather than ship a broken build.\n\n\`\`\`\n${buildErrorExcerpt(gate.errorOutput || '')}\n\`\`\`\n\nYour description was clear — nothing more is needed from you on that. Reply here to have it try again.`, `Reverted: the build failed after ${engine.label}'s change`, []);
2028
+ }
2029
+ }
2030
+ }
2031
+ await markNewFilesForDiff(projectPath, change.changed);
2032
+ return {
2033
+ modifiedFiles: change.changed,
2034
+ summary: report.summary || proseOf(result.text, 700),
2035
+ verificationSteps: report.verificationSteps.length > 0 ? report.verificationSteps : undefined,
2036
+ buildNote: gate.note,
2037
+ inspectedFiles: [],
2038
+ engine: engine.label,
2039
+ };
2040
+ }
2041
+ /**
2042
+ * The built-in pipeline: rank the project's files, let the server-side model pick a handful, and apply the
2043
+ * search/replace edits it returns. It cannot explore or run anything, so it is the fallback for machines
2044
+ * without an agent CLI. Returns null when the card was handed back to the human.
2045
+ */
2046
+ async function produceChangeBuiltIn(run) {
2047
+ const { client, card, projectPath, provider, workerLabel, lockFile, profile, carryOverFiles, originalContents } = run;
2048
+ await client.addProgress(card.id, provider, `Autonomous background runner claimed task in \`${projectPath}\` and moved card to **In Progress**. Scanning repository files...` +
2049
+ (run.engineNotes.length > 0 ? `\n\nUsing the built-in engine for this card:\n${run.engineNotes.map((n) => `- ${n}`).join('\n')}` : ''));
2050
+ // 2. Build keyword list from the card and what the requester wrote on it, and collect project source files.
2051
+ // Their replies count: "the Recently active projects table on the Operations page" is where the answer is.
2052
+ const requestText = [card.title || '', card.description || '', ...run.conversation.filter((t) => t.who === 'requester').map((t) => t.text)].join(' ');
2053
+ const rawText = requestText.toLowerCase();
2054
+ const stopWords = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'need', 'make', 'user', 'use', 'into', 'when', 'through']);
2055
+ const keywords = Array.from(new Set(rawText
2056
+ .split(/[^a-z0-9]+/)
2057
+ .filter((w) => w.length >= 3 && !stopWords.has(w))));
2058
+ const fileIndex = collectProjectSourceFiles(projectPath, keywords, quotedPhrases(requestText));
2059
+ if (fileIndex.length === 0) {
2060
+ removeTaskLock(lockFile);
2061
+ await client.askQuestion(card.id, provider, `I could not find any source files to work on in \`${projectPath}\`. Is this the right folder for the "${card.board_name || 'this'}" board? If not, connect the correct project folder in the board's AI Agents settings and reply here.`);
2062
+ return null;
2063
+ }
2064
+ // 3. Ask Agile Monster Autonomous Step to pick the 1-6 most relevant files
2065
+ const projectContext = readProjectInstructionsContext(projectPath, profile);
2066
+ const selectionRes = await client.autonomousStep({
2067
+ card_id: card.id,
2068
+ action: 'select_files',
2069
+ file_index: fileIndex,
2070
+ project_context: projectContext,
2071
+ });
2072
+ // The model may only choose from the files it was offered: a path it made up (../../.ssh/…, .env) is dropped
2073
+ const selectedFiles = (selectionRes.target_files || selectionRes.selected_files || [])
2074
+ .filter((f) => typeof f === 'string' && fileIndex.includes(f))
2075
+ .slice(0, 6);
2076
+ if (selectedFiles.length === 0) {
2077
+ selectedFiles.push(...fileIndex.slice(0, 4));
2078
+ }
2079
+ // Automatically include project type definition files (e.g. types.ts,frontend/src/lib/types.ts) so the model knows exact schemas
2080
+ for (const candidateTypeFile of [
2081
+ 'frontend/src/lib/types.ts',
2082
+ 'src/lib/types.ts',
2083
+ 'src/types.ts',
2084
+ 'src/types/index.ts',
2085
+ 'frontend/src/types.ts',
2086
+ ]) {
2087
+ if (fileIndex.includes(candidateTypeFile) &&
2088
+ !selectedFiles.includes(candidateTypeFile) &&
2089
+ selectedFiles.length < 7) {
2090
+ selectedFiles.push(candidateTypeFile);
2091
+ }
2092
+ }
2093
+ const filesPayload = {};
2094
+ for (const rel of selectedFiles) {
2095
+ const full = path.join(projectPath, rel);
2096
+ if (fs.existsSync(full)) {
2097
+ try {
2098
+ const raw = fs.readFileSync(full);
2099
+ // A file that is not valid UTF-8 would be silently corrupted by a text round-trip: leave it alone
2100
+ if (isUtf8(raw))
2101
+ filesPayload[rel] = raw.toString('utf8');
2102
+ }
2103
+ catch { }
2104
+ }
2105
+ }
2106
+ if (Object.keys(filesPayload).length === 0) {
2107
+ removeTaskLock(lockFile);
2108
+ await client.askQuestion(card.id, provider, 'I could not read the files I selected for this task. Could you point me at the page, screen or file this should change?');
2109
+ return null;
2110
+ }
2111
+ await client.addProgress(card.id, provider, `Selected target files for implementation: ${Object.keys(filesPayload)
2112
+ .map((f) => `\`${f}\``)
2113
+ .join(', ')}. Generating code changes...`);
2114
+ // 4. Generate surgical code edits
2115
+ let editsRes = await client.autonomousStep({
2116
+ card_id: card.id,
2117
+ action: 'generate_edits',
2118
+ files: filesPayload,
2119
+ project_context: projectContext,
2120
+ });
2121
+ // Gate A: Strict truth — if the model is not confident / cannot find the target, ask the human instead of guessing
2122
+ if (editsRes.needs_clarification && (editsRes.clarification_question || '').trim() !== '') {
2123
+ removeTaskLock(lockFile);
2124
+ appendIdeActivityLog(projectPath, {
2125
+ cardId: card.id,
2126
+ cardTitle: card.title,
2127
+ agentName: workerLabel,
2128
+ model: card.agent_model || 'auto',
2129
+ status: 'waiting_for_input',
2130
+ summary: `Needs clarification: ${editsRes.clarification_question}`,
2131
+ files: Object.keys(filesPayload),
2132
+ });
2133
+ await client.askQuestion(card.id, provider, (editsRes.clarification_question || '').trim());
2134
+ return null;
2135
+ }
2136
+ // Only the files that were actually sent to the model may be edited (never .git/, never a path it invented)
2137
+ const editableFiles = new Set(Object.keys(filesPayload));
2138
+ const projectRoot = fs.realpathSync(projectPath);
2139
+ /**
2140
+ * Apply one set of edits. Returns the files changed and a reason for every edit that could not be applied.
2141
+ * A set of edits is one coordinated change: the caller ships it only if ALL of it applied.
2142
+ */
2143
+ const applyEditsPass = async (editsList) => {
2144
+ const modFiles = [];
2145
+ const failed = [];
2146
+ for (const edit of editsList) {
2147
+ const relPath = (edit.file || edit.path || '').replace(/\\/g, '/').replace(/^\.\//, '');
2148
+ if (!relPath || !edit.search || typeof edit.replace !== 'string') {
2149
+ failed.push(`${relPath || '(no file)'}: the edit was incomplete`);
2150
+ continue;
2151
+ }
2152
+ if (!editableFiles.has(relPath)) {
2153
+ failed.push(`${relPath}: not one of the files provided`);
2154
+ continue;
2155
+ }
2156
+ const fullPath = path.resolve(projectPath, relPath);
2157
+ let realPath = '';
2158
+ try {
2159
+ realPath = fs.realpathSync(fullPath);
2160
+ }
2161
+ catch { }
2162
+ if (!realPath || !(realPath === projectRoot || realPath.startsWith(projectRoot + path.sep))) {
2163
+ failed.push(`${relPath}: outside the project`);
2164
+ continue;
2165
+ }
2166
+ const currentContent = fs.readFileSync(fullPath, 'utf8');
2167
+ if (!currentContent.includes(edit.search)) {
2168
+ failed.push(`${relPath}: the \`search\` text was not found verbatim in the file`);
2169
+ continue;
2170
+ }
2171
+ const updatedContent = currentContent.replace(edit.search, () => edit.replace);
2172
+ if (updatedContent === currentContent)
2173
+ continue; // nothing to do is not a failure
2174
+ const countChar = (s, ch) => s.split(ch).length - 1;
2175
+ const searchBraceDelta = countChar(edit.search, '{') - countChar(edit.search, '}');
2176
+ const replaceBraceDelta = countChar(edit.replace, '{') - countChar(edit.replace, '}');
2177
+ const searchParenDelta = countChar(edit.search, '(') - countChar(edit.search, ')');
2178
+ const replaceParenDelta = countChar(edit.replace, '(') - countChar(edit.replace, ')');
2179
+ if (searchBraceDelta !== replaceBraceDelta || searchParenDelta !== replaceParenDelta) {
2180
+ failed.push(`${relPath}: the replacement would leave braces or parentheses unbalanced`);
2181
+ continue;
2182
+ }
2183
+ if (!originalContents.has(fullPath))
2184
+ originalContents.set(fullPath, currentContent);
2185
+ fs.writeFileSync(fullPath, updatedContent, 'utf8');
2186
+ journalEdits(card.id, projectPath, originalContents);
2187
+ // Per-file syntax checks run without blocking the event loop (the worker heartbeat must keep ticking)
2188
+ if (relPath.endsWith('.dart')) {
2189
+ const lint = await exec('dart', ['analyze', fullPath], { cwd: projectPath, timeoutMs: 40000 });
2190
+ if (!lint.ok && /\berror\s+[•-]/i.test(lint.stdout + lint.stderr)) {
2191
+ fs.writeFileSync(fullPath, currentContent, 'utf8');
2192
+ failed.push(`${relPath}: dart analyze reported an error after the edit: ${lint.tail.slice(0, 300)}`);
2193
+ continue;
2194
+ }
2195
+ }
2196
+ else if (relPath.endsWith('.php')) {
2197
+ const lint = await exec('php', ['-l', fullPath], { cwd: projectPath, timeoutMs: 15000 });
2198
+ // exit code null = `php` is not installed here: that must not discard valid work
2199
+ if (!lint.ok && lint.code !== null && !lint.timedOut) {
2200
+ fs.writeFileSync(fullPath, currentContent, 'utf8');
2201
+ failed.push(`${relPath}: PHP syntax error after the edit: ${lint.tail.slice(0, 300)}`);
2202
+ continue;
2203
+ }
2204
+ }
2205
+ if (!modFiles.includes(relPath))
2206
+ modFiles.push(relPath);
2207
+ }
2208
+ return { modFiles, failed };
2209
+ };
2210
+ const revertAll = () => {
2211
+ for (const [fPath, orig] of originalContents.entries()) {
2212
+ try {
2213
+ fs.writeFileSync(fPath, orig, 'utf8');
2214
+ }
2215
+ catch { }
2216
+ }
2217
+ originalContents.clear();
2218
+ clearEditJournal(card.id);
2219
+ };
2220
+ // Never build on top of the developer's own uncommitted work in the same file: a whole-file commit would
2221
+ // ship their half-finished changes under this card's name.
2222
+ const targetFiles = Array.from(new Set((editsRes.edits || []).map((e) => (e.file || e.path || '').replace(/\\/g, '/').replace(/^\.\//, '')).filter((f) => editableFiles.has(f))));
2223
+ if (targetFiles.length > 0) {
2224
+ const status = await exec('git', ['--literal-pathspecs', 'status', '--porcelain', '--', ...targetFiles], { cwd: projectPath, timeoutMs: 20000 });
2225
+ const dirty = status.ok
2226
+ ? status.stdout
2227
+ .split('\n')
2228
+ .filter((l) => l.trim() && !l.startsWith('??'))
2229
+ .map((l) => l.slice(3).trim())
2230
+ .filter((f) => !carryOverFiles.includes(f))
2231
+ : [];
2232
+ if (dirty.length > 0) {
2233
+ removeTaskLock(lockFile);
2234
+ await client.askQuestion(card.id, provider, `I need to change ${dirty.map((f) => `\`${f}\``).join(', ')}, but ${dirty.length === 1 ? 'that file has' : 'those files have'} uncommitted changes in \`${projectPath}\` that are not mine. Committing my edit would also commit that unfinished work, so I stopped.\n\nCommit or stash those changes, then reply here and I will continue.`);
2235
+ return null;
2236
+ }
2237
+ }
2238
+ let pass = await applyEditsPass(editsRes.edits || []);
2239
+ if (pass.failed.length > 0) {
2240
+ // Half of a coordinated change is worse than none: undo it and give the model one chance to correct itself
2241
+ revertAll();
2242
+ editsRes = await client.autonomousStep({
2243
+ card_id: card.id,
2244
+ action: 'generate_edits',
2245
+ files: {
2246
+ ...filesPayload,
2247
+ '__EDITS_THAT_DID_NOT_APPLY__.txt': `Your previous edits were NOT applied because some of them could not be applied, and a partial change must never ship:\n${pass.failed.join('\n')}\nReturn the complete set of edits again. Every \`search\` must be copied verbatim from the current file contents, and only files listed in \`files\` may be edited.`,
2248
+ },
2249
+ project_context: projectContext,
2250
+ });
2251
+ pass = await applyEditsPass(editsRes.edits || []);
2252
+ if (pass.failed.length > 0) {
2253
+ revertAll();
2254
+ pass = { modFiles: [], failed: pass.failed };
2255
+ }
2256
+ }
2257
+ const modifiedFiles = pass.modFiles;
2258
+ const applyFailures = pass.failed;
2259
+ let buildCheck = await runBuildGate(projectPath, modifiedFiles);
2260
+ if (!buildCheck.ok) {
2261
+ const firstEdits = editsRes.edits || [];
2262
+ const builtFiles = [...modifiedFiles];
2263
+ revertAll();
2264
+ modifiedFiles.length = 0;
2265
+ // Is it this change, or does the project not build on this machine at all? Only the first is a reason to
2266
+ // throw the work away — a build that was already red says nothing about the edit.
2267
+ const pristine = await runBuildGate(projectPath, builtFiles);
2268
+ if (!pristine.ok) {
2269
+ const again = await applyEditsPass(firstEdits);
2270
+ if (again.failed.length === 0) {
2271
+ modifiedFiles.push(...again.modFiles);
2272
+ buildCheck = { ok: true, note: brokenBaselineNote(pristine.errorOutput || '') };
2273
+ }
2274
+ else {
2275
+ revertAll();
2276
+ applyFailures.push(...again.failed);
2277
+ }
2278
+ }
2279
+ else {
2280
+ for (let attempt = 0; attempt < 2 && !buildCheck.ok; attempt++) {
2281
+ editsRes = await client.autonomousStep({
2282
+ card_id: card.id,
2283
+ action: 'generate_edits',
2284
+ files: {
2285
+ ...filesPayload,
2286
+ '__COMPILER_ERROR_TO_FIX__.txt': `Your previous edits were reverted because the project no longer built:\n${buildErrorExcerpt(buildCheck.errorOutput || '', 40, 3500)}\nReturn the complete set of edits again with this fixed. Use only identifiers, props and fields that exist in the files provided.`,
2287
+ },
2288
+ project_context: projectContext,
2289
+ });
2290
+ const retryPass = await applyEditsPass(editsRes.edits || []);
2291
+ if (retryPass.failed.length > 0) {
2292
+ revertAll();
2293
+ applyFailures.push(...retryPass.failed);
2294
+ break;
2295
+ }
2296
+ modifiedFiles.push(...retryPass.modFiles);
2297
+ buildCheck = await runBuildGate(projectPath, modifiedFiles);
2298
+ if (!buildCheck.ok) {
2299
+ revertAll();
2300
+ modifiedFiles.length = 0;
2301
+ }
2302
+ }
2303
+ }
2304
+ }
2305
+ if (modifiedFiles.length === 0) {
2306
+ removeTaskLock(lockFile);
2307
+ revertAll();
2308
+ // Say what actually went wrong. The requester already described the task (possibly twice): asking them
2309
+ // "where should this appear?" again would be a question about my limits dressed up as one about theirs.
2310
+ const reason = !buildCheck.ok && buildCheck.errorOutput
2311
+ ? `The change I wrote did not build, so I reverted it. The build said:\n\`\`\`\n${buildErrorExcerpt(buildCheck.errorOutput)}\n\`\`\``
2312
+ : applyFailures.length > 0
2313
+ ? `The change I generated could not be applied cleanly, so nothing was modified:\n${applyFailures.slice(0, 4).map((f) => `- ${f}`).join('\n').slice(0, 700)}`
2314
+ : 'The model returned no change for these files.';
2315
+ await client.askQuestion(card.id, provider, `I could not finish this card. I worked in ${Object.keys(filesPayload)
2316
+ .map((f) => `\`${f}\``)
2317
+ .join(', ')}.\n\n${reason}\n\n${builtInLimitsNote(run)}\n\nYour description was clear — nothing more is needed from you on that. Reply here to have me try again.`);
2318
+ return null;
2319
+ }
2320
+ return {
2321
+ modifiedFiles,
2322
+ summary: editsRes.summary || '',
2323
+ verificationSteps: editsRes.verification_steps,
2324
+ buildNote: buildCheck.note,
2325
+ inspectedFiles: Object.keys(filesPayload),
2326
+ engine: 'built-in',
2327
+ };
2328
+ }
1730
2329
  export async function executeQueuedCardAutonomously(client, card, projectPath) {
1731
2330
  if (!projectPath || !fs.existsSync(projectPath))
1732
2331
  return false;
@@ -1764,11 +2363,27 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1764
2363
  const workerLabel = card.agent_display_name || provider;
1765
2364
  // Original contents of every file this run touches, so an interrupted run never leaves half-applied edits behind
1766
2365
  const originalContents = new Map();
2366
+ // The agent path cannot know its files up front: it registers how to put the tree back instead
2367
+ const rollback = { fn: null };
1767
2368
  let editsHandedOff = false;
1768
2369
  log.info('card.start', { card: card.id, title: card.title, project: projectPath, provider });
1769
2370
  try {
1770
2371
  // 1. Claim the card (automatically moves it from ToDo -> In Progress on the board!)
1771
- await client.claimTask(card.id, provider, `auto-pulse-${Date.now()}`, card.board_id);
2372
+ const runRef = sessionRunRef('background');
2373
+ try {
2374
+ await client.claimTask(card.id, provider, runRef, card.board_id);
2375
+ }
2376
+ catch (err) {
2377
+ if (isClaimConflict(err)) {
2378
+ // Another session (an IDE agent, or a teammate's machine with the same folder) holds this card.
2379
+ // That is the claim doing its job, not a failure: leave the card and the board alone.
2380
+ removeTaskLock(lockFile);
2381
+ log.info('card.claim_lost', { card: card.id, project: projectPath, reason: errorMessage(err) });
2382
+ return false;
2383
+ }
2384
+ throw err;
2385
+ }
2386
+ holdClaim(card.id, runRef);
1772
2387
  sendDesktopNotification(`Agile Monster -> ${workerLabel}`, `Claimed & moved to In Progress: "${card.title}"`);
1773
2388
  // 1b. Deploy-only resume: the change exists, only shipping it is outstanding
1774
2389
  const priorState = getCardState(projectPath, card.id);
@@ -1788,285 +2403,46 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1788
2403
  if (isRedeployOnlyCard(card)) {
1789
2404
  return await runRedeployOnlyCard(client, card, projectPath, provider, workerLabel, lockFile);
1790
2405
  }
1791
- const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card), memory: loadMemory(projectPath) });
1792
- await client.addProgress(card.id, provider, `Autonomous background runner claimed task in \`${projectPath}\` and moved card to **In Progress**. Scanning repository files...`);
1793
- // 2. Build keyword list from card title + description and collect project source files
1794
- const rawText = `${card.title || ''} ${card.description || ''}`.toLowerCase();
1795
- const stopWords = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'need', 'make', 'user', 'use', 'into', 'when', 'through']);
1796
- const keywords = Array.from(new Set(rawText
1797
- .split(/[^a-z0-9]+/)
1798
- .filter((w) => w.length >= 3 && !stopWords.has(w))));
1799
- const fileIndex = collectProjectSourceFiles(projectPath, keywords);
1800
- if (fileIndex.length === 0) {
1801
- removeTaskLock(lockFile);
1802
- await client.askQuestion(card.id, provider, `I could not find any source files to work on in \`${projectPath}\`. Is this the right folder for the "${card.board_name || 'this'}" board? If not, connect the correct project folder in the board's AI Agents settings and reply here.`);
1803
- return false;
1804
- }
1805
- // 3. Ask Agile Monster Autonomous Step to pick the 1-6 most relevant files
1806
- const projectContext = readProjectInstructionsContext(projectPath, profile);
1807
- const selectionRes = await client.autonomousStep({
1808
- card_id: card.id,
1809
- action: 'select_files',
1810
- file_index: fileIndex,
1811
- project_context: projectContext,
1812
- });
1813
- // The model may only choose from the files it was offered: a path it made up (../../.ssh/…, .env) is dropped
1814
- const selectedFiles = (selectionRes.target_files || selectionRes.selected_files || [])
1815
- .filter((f) => typeof f === 'string' && fileIndex.includes(f))
1816
- .slice(0, 6);
1817
- if (selectedFiles.length === 0) {
1818
- selectedFiles.push(...fileIndex.slice(0, 4));
1819
- }
1820
- // Automatically include project type definition files (e.g. types.ts,frontend/src/lib/types.ts) so the model knows exact schemas
1821
- for (const candidateTypeFile of [
1822
- 'frontend/src/lib/types.ts',
1823
- 'src/lib/types.ts',
1824
- 'src/types.ts',
1825
- 'src/types/index.ts',
1826
- 'frontend/src/types.ts',
1827
- ]) {
1828
- if (fileIndex.includes(candidateTypeFile) &&
1829
- !selectedFiles.includes(candidateTypeFile) &&
1830
- selectedFiles.length < 7) {
1831
- selectedFiles.push(candidateTypeFile);
1832
- }
1833
- }
1834
- const filesPayload = {};
1835
- for (const rel of selectedFiles) {
1836
- const full = path.join(projectPath, rel);
1837
- if (fs.existsSync(full)) {
1838
- try {
1839
- const raw = fs.readFileSync(full);
1840
- // A file that is not valid UTF-8 would be silently corrupted by a text round-trip: leave it alone
1841
- if (isUtf8(raw))
1842
- filesPayload[rel] = raw.toString('utf8');
1843
- }
1844
- catch { }
1845
- }
1846
- }
1847
- if (Object.keys(filesPayload).length === 0) {
1848
- removeTaskLock(lockFile);
1849
- await client.askQuestion(card.id, provider, 'I could not read the files I selected for this task. Could you point me at the page, screen or file this should change?');
1850
- return false;
1851
- }
1852
- await client.addProgress(card.id, provider, `Selected target files for implementation: ${Object.keys(filesPayload)
1853
- .map((f) => `\`${f}\``)
1854
- .join(', ')}. Generating code changes...`);
1855
- // 4. Generate surgical code edits
1856
- let editsRes = await client.autonomousStep({
1857
- card_id: card.id,
1858
- action: 'generate_edits',
1859
- files: filesPayload,
1860
- project_context: projectContext,
1861
- });
1862
- // Gate A: Strict truth — if the model is not confident / cannot find the target, ask the human instead of guessing
1863
- if (editsRes.needs_clarification && (editsRes.clarification_question || '').trim() !== '') {
1864
- removeTaskLock(lockFile);
1865
- appendIdeActivityLog(projectPath, {
1866
- cardId: card.id,
1867
- cardTitle: card.title,
1868
- agentName: workerLabel,
1869
- model: card.agent_model || 'auto',
1870
- status: 'waiting_for_input',
1871
- summary: `Needs clarification: ${editsRes.clarification_question}`,
1872
- files: Object.keys(filesPayload),
1873
- });
1874
- await client.askQuestion(card.id, provider, (editsRes.clarification_question || '').trim());
1875
- return false;
1876
- }
1877
- // Only the files that were actually sent to the model may be edited (never .git/, never a path it invented)
1878
- const editableFiles = new Set(Object.keys(filesPayload));
1879
- const projectRoot = fs.realpathSync(projectPath);
1880
- /**
1881
- * Apply one set of edits. Returns the files changed and a reason for every edit that could not be applied.
1882
- * A set of edits is one coordinated change: the caller ships it only if ALL of it applied.
1883
- */
1884
- const applyEditsPass = async (editsList) => {
1885
- const modFiles = [];
1886
- const failed = [];
1887
- for (const edit of editsList) {
1888
- const relPath = (edit.file || edit.path || '').replace(/\\/g, '/').replace(/^\.\//, '');
1889
- if (!relPath || !edit.search || typeof edit.replace !== 'string') {
1890
- failed.push(`${relPath || '(no file)'}: the edit was incomplete`);
1891
- continue;
1892
- }
1893
- if (!editableFiles.has(relPath)) {
1894
- failed.push(`${relPath}: not one of the files provided`);
1895
- continue;
1896
- }
1897
- const fullPath = path.resolve(projectPath, relPath);
1898
- let realPath = '';
1899
- try {
1900
- realPath = fs.realpathSync(fullPath);
1901
- }
1902
- catch { }
1903
- if (!realPath || !(realPath === projectRoot || realPath.startsWith(projectRoot + path.sep))) {
1904
- failed.push(`${relPath}: outside the project`);
1905
- continue;
1906
- }
1907
- const currentContent = fs.readFileSync(fullPath, 'utf8');
1908
- if (!currentContent.includes(edit.search)) {
1909
- failed.push(`${relPath}: the \`search\` text was not found verbatim in the file`);
1910
- continue;
1911
- }
1912
- const updatedContent = currentContent.replace(edit.search, () => edit.replace);
1913
- if (updatedContent === currentContent)
1914
- continue; // nothing to do is not a failure
1915
- const countChar = (s, ch) => s.split(ch).length - 1;
1916
- const searchBraceDelta = countChar(edit.search, '{') - countChar(edit.search, '}');
1917
- const replaceBraceDelta = countChar(edit.replace, '{') - countChar(edit.replace, '}');
1918
- const searchParenDelta = countChar(edit.search, '(') - countChar(edit.search, ')');
1919
- const replaceParenDelta = countChar(edit.replace, '(') - countChar(edit.replace, ')');
1920
- if (searchBraceDelta !== replaceBraceDelta || searchParenDelta !== replaceParenDelta) {
1921
- failed.push(`${relPath}: the replacement would leave braces or parentheses unbalanced`);
1922
- continue;
1923
- }
1924
- if (!originalContents.has(fullPath))
1925
- originalContents.set(fullPath, currentContent);
1926
- fs.writeFileSync(fullPath, updatedContent, 'utf8');
1927
- journalEdits(card.id, projectPath, originalContents);
1928
- // Per-file syntax checks run without blocking the event loop (the worker heartbeat must keep ticking)
1929
- if (relPath.endsWith('.dart')) {
1930
- const lint = await exec('dart', ['analyze', fullPath], { cwd: projectPath, timeoutMs: 40000 });
1931
- if (!lint.ok && /\berror\s+[•-]/i.test(lint.stdout + lint.stderr)) {
1932
- fs.writeFileSync(fullPath, currentContent, 'utf8');
1933
- failed.push(`${relPath}: dart analyze reported an error after the edit: ${lint.tail.slice(0, 300)}`);
1934
- continue;
1935
- }
1936
- }
1937
- else if (relPath.endsWith('.php')) {
1938
- const lint = await exec('php', ['-l', fullPath], { cwd: projectPath, timeoutMs: 15000 });
1939
- // exit code null = `php` is not installed here: that must not discard valid work
1940
- if (!lint.ok && lint.code !== null && !lint.timedOut) {
1941
- fs.writeFileSync(fullPath, currentContent, 'utf8');
1942
- failed.push(`${relPath}: PHP syntax error after the edit: ${lint.tail.slice(0, 300)}`);
1943
- continue;
1944
- }
1945
- }
1946
- if (!modFiles.includes(relPath))
1947
- modFiles.push(relPath);
1948
- }
1949
- return { modFiles, failed };
1950
- };
1951
- const revertAll = () => {
1952
- for (const [fPath, orig] of originalContents.entries()) {
1953
- try {
1954
- fs.writeFileSync(fPath, orig, 'utf8');
1955
- }
1956
- catch { }
1957
- }
1958
- originalContents.clear();
1959
- clearEditJournal(card.id);
1960
- };
1961
- // Never build on top of the developer's own uncommitted work in the same file: a whole-file commit would
1962
- // ship their half-finished changes under this card's name.
1963
- const targetFiles = Array.from(new Set((editsRes.edits || []).map((e) => (e.file || e.path || '').replace(/\\/g, '/').replace(/^\.\//, '')).filter((f) => editableFiles.has(f))));
1964
- if (targetFiles.length > 0) {
1965
- const status = await exec('git', ['--literal-pathspecs', 'status', '--porcelain', '--', ...targetFiles], { cwd: projectPath, timeoutMs: 20000 });
1966
- const dirty = status.ok
1967
- ? status.stdout
1968
- .split('\n')
1969
- .filter((l) => l.trim() && !l.startsWith('??'))
1970
- .map((l) => l.slice(3).trim())
1971
- .filter((f) => !carryOverFiles.includes(f))
1972
- : [];
1973
- if (dirty.length > 0) {
1974
- removeTaskLock(lockFile);
1975
- await client.askQuestion(card.id, provider, `I need to change ${dirty.map((f) => `\`${f}\``).join(', ')}, but ${dirty.length === 1 ? 'that file has' : 'those files have'} uncommitted changes in \`${projectPath}\` that are not mine. Committing my edit would also commit that unfinished work, so I stopped.\n\nCommit or stash those changes, then reply here and I will continue.`);
1976
- return false;
1977
- }
1978
- }
1979
- let pass = await applyEditsPass(editsRes.edits || []);
1980
- if (pass.failed.length > 0) {
1981
- // Half of a coordinated change is worse than none: undo it and give the model one chance to correct itself
1982
- revertAll();
1983
- editsRes = await client.autonomousStep({
1984
- card_id: card.id,
1985
- action: 'generate_edits',
1986
- files: {
1987
- ...filesPayload,
1988
- '__EDITS_THAT_DID_NOT_APPLY__.txt': `Your previous edits were NOT applied because some of them could not be applied, and a partial change must never ship:\n${pass.failed.join('\n')}\nReturn the complete set of edits again. Every \`search\` must be copied verbatim from the current file contents, and only files listed in \`files\` may be edited.`,
1989
- },
1990
- project_context: projectContext,
1991
- });
1992
- pass = await applyEditsPass(editsRes.edits || []);
1993
- if (pass.failed.length > 0) {
1994
- revertAll();
1995
- pass = { modFiles: [], failed: pass.failed };
1996
- }
1997
- }
1998
- const modifiedFiles = pass.modFiles;
1999
- const applyFailures = pass.failed;
2000
- // Pre-submit build gate: build the package that owns each changed front-end file (works for any monorepo layout)
2001
- const verifyFrontendBuilds = async () => {
2002
- for (const dir of frontendBuildDirs(projectPath, modifiedFiles)) {
2003
- const buildCmd = fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) ? 'pnpm' : 'npm';
2004
- const res = await exec(buildCmd, ['run', 'build'], { cwd: dir, timeoutMs: 6 * 60 * 1000 });
2005
- if (res.timedOut) {
2006
- // A slow build is not a compile error: do not throw the work away over it
2007
- return { ok: true, note: `The build in \`${path.relative(projectPath, dir) || '.'}\` did not finish within 6 minutes, so it was not verified locally.` };
2008
- }
2009
- if (!res.ok) {
2010
- return { ok: false, errorOutput: `${res.stdout}\n${res.stderr}`.trim().slice(-1500) };
2011
- }
2012
- }
2013
- return { ok: true };
2406
+ const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card, await machineOwnerId(client)), memory: loadMemory(projectPath) });
2407
+ const ownerId = await machineOwnerId(client);
2408
+ const run = {
2409
+ client,
2410
+ card,
2411
+ projectPath,
2412
+ provider,
2413
+ workerLabel,
2414
+ lockFile,
2415
+ profile,
2416
+ carryOverFiles,
2417
+ originalContents,
2418
+ ownerId,
2419
+ conversation: await cardConversation(client, card, ownerId),
2420
+ earlierSummary: priorState?.summary || null,
2421
+ engineNotes: [],
2422
+ rollback,
2014
2423
  };
2015
- let buildCheck = await verifyFrontendBuilds();
2016
- if (!buildCheck.ok) {
2017
- // Revert first pass, attach compiler error context, and do 1 self-correction pass
2018
- revertAll();
2019
- modifiedFiles.length = 0;
2020
- const retryFiles = {
2021
- ...filesPayload,
2022
- '__COMPILER_ERROR_TO_FIX__.txt': `The previous edit failed to compile with:\n${buildCheck.errorOutput}\nPlease fix the TypeScript/build error using the exact property names from types.ts.`,
2023
- };
2024
- editsRes = await client.autonomousStep({
2025
- card_id: card.id,
2026
- action: 'generate_edits',
2027
- files: retryFiles,
2028
- project_context: projectContext,
2029
- });
2030
- const retryPass = await applyEditsPass(editsRes.edits || []);
2031
- if (retryPass.failed.length > 0) {
2032
- revertAll();
2033
- applyFailures.push(...retryPass.failed);
2034
- }
2035
- else {
2036
- modifiedFiles.push(...retryPass.modFiles);
2037
- buildCheck = await verifyFrontendBuilds();
2038
- if (!buildCheck.ok) {
2039
- revertAll();
2040
- modifiedFiles.length = 0;
2041
- }
2042
- }
2043
- }
2044
- if (modifiedFiles.length === 0) {
2045
- removeTaskLock(lockFile);
2046
- revertAll();
2047
- const buildNote = buildCheck && !buildCheck.ok && buildCheck.errorOutput
2048
- ? `\n\nThe attempted change did not compile, so it was reverted. Last build error:\n${buildCheck.errorOutput.split('\n').filter((l) => /error/i.test(l)).slice(0, 4).join('\n').slice(0, 600)}`
2049
- : applyFailures.length > 0
2050
- ? `\n\nThe change I generated could not be applied cleanly, so nothing was modified:\n${applyFailures.slice(0, 4).map((f) => `- ${f}`).join('\n').slice(0, 700)}`
2051
- : '';
2052
- await client.askQuestion(card.id, provider, `I inspected ${Object.keys(filesPayload)
2053
- .map((f) => `\`${f}\``)
2054
- .join(', ')} but could not make a verified change automatically.${buildNote}\n\nCould you describe exactly where in the app this should appear (page or screen name) and what you expect to see?`);
2424
+ // A real coding agent when this machine has one; the built-in pipeline otherwise
2425
+ const viaAgent = await produceChangeWithAgent(run);
2426
+ if (viaAgent === 'handled')
2055
2427
  return false;
2056
- }
2428
+ const produced = viaAgent === 'unavailable' ? await produceChangeBuiltIn(run) : viaAgent;
2429
+ if (!produced)
2430
+ return false;
2431
+ const modifiedFiles = produced.modifiedFiles;
2057
2432
  const commitFiles = Array.from(new Set([...carryOverFiles, ...modifiedFiles]));
2058
2433
  let rawDiff = '';
2059
2434
  try {
2060
2435
  rawDiff = (await exec('git', ['--literal-pathspecs', 'diff', '--', ...modifiedFiles], { cwd: projectPath, timeoutMs: 20000 })).stdout.trim();
2061
2436
  }
2062
2437
  catch { }
2063
- const baseSummary = editsRes.summary ||
2438
+ const baseSummary = produced.summary ||
2064
2439
  `Updated ${modifiedFiles.join(', ')} to complete "${card.title}".`;
2065
- const plainSummary = baseSummary.replace(/```[\s\S]*?```/g, '').trim() + (buildCheck.note ? `\n\n${buildCheck.note}` : '');
2066
- const verificationSteps = Array.isArray(editsRes.verification_steps)
2067
- ? editsRes.verification_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')
2068
- : (typeof editsRes.verification_steps === 'string' && editsRes.verification_steps.trim() !== ''
2069
- ? editsRes.verification_steps
2440
+ const plainSummary = baseSummary.replace(/```[\s\S]*?```/g, '').trim() + (produced.buildNote ? `\n\n${produced.buildNote}` : '');
2441
+ const stepsFromAgent = produced.verificationSteps;
2442
+ const verificationSteps = Array.isArray(stepsFromAgent)
2443
+ ? stepsFromAgent.map((s, i) => `${i + 1}. ${String(s).replace(/^\d+[.)]\s*/, '')}`).join('\n')
2444
+ : (typeof stepsFromAgent === 'string' && stepsFromAgent.trim() !== ''
2445
+ ? stepsFromAgent
2070
2446
  : `1. Check the live update for "${card.title}".\n2. Click **Approve & Deploy to Done** when verified.`);
2071
2447
  const cardState = (stage, extra = {}) => ({
2072
2448
  stage,
@@ -2085,7 +2461,9 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2085
2461
  let deployedNote = '';
2086
2462
  // From here on the edits are owned by the card's saved state (deploy / approval), not by this run's revert safety net
2087
2463
  editsHandedOff = true;
2464
+ rollback.fn = null;
2088
2465
  clearEditJournal(card.id);
2466
+ clearBaseline(card.id);
2089
2467
  if (deployStage === 'review') {
2090
2468
  // Persist first: if this process dies mid-deploy, the re-queued card resumes with the deployment only
2091
2469
  setCardState(projectPath, card.id, cardState('deploy_failed', { last_error: 'The deployment was interrupted before it finished.' }));
@@ -2095,7 +2473,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2095
2473
  title: card.title || 'update',
2096
2474
  cardId: card.id,
2097
2475
  files: commitFiles,
2098
- bound: boundProfileOf(card),
2476
+ bound: boundProfileOf(card, await machineOwnerId(client)),
2099
2477
  });
2100
2478
  await reportOutcome(client, card, projectPath, deployInfo);
2101
2479
  if (!deployInfo.ok) {
@@ -2139,7 +2517,8 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2139
2517
  const logLines = [
2140
2518
  `Claimed card "${card.title}" (${card.id}) in ${projectPath}`,
2141
2519
  `Agent: ${workerLabel} | Model: ${card.agent_model || 'auto'}`,
2142
- `Selected files: ${Object.keys(filesPayload).join(', ')}`,
2520
+ `Engine: ${produced.engine}`,
2521
+ ...(produced.inspectedFiles.length > 0 ? [`Selected files: ${produced.inspectedFiles.join(', ')}`] : []),
2143
2522
  `Modified & verified files: ${modifiedFiles.join(', ')}`,
2144
2523
  `Deployment route: ${profile.method} (${profile.confidence} confidence, ${profile.source})`,
2145
2524
  `Deployment: ${deployedNote}`,
@@ -2162,13 +2541,13 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2162
2541
  });
2163
2542
  writeTaskLock(lockFile, card, 'completed');
2164
2543
  sendDesktopNotification(`Agile Monster -> Ready for Review`, `Completed "${card.title}" (${modifiedFiles.length} file(s) updated) and moved to Testing / Review!`);
2165
- log.info('card.review', { card: card.id, files: modifiedFiles.length, deploy: profile.method });
2544
+ log.info('card.review', { card: card.id, files: modifiedFiles.length, deploy: profile.method, engine: produced.engine });
2166
2545
  return true;
2167
2546
  }
2168
2547
  catch (err) {
2169
2548
  removeTaskLock(lockFile);
2170
2549
  // An interrupted run must not leave half-applied, uncommitted edits in the developer's working tree
2171
- const rolledBack = !editsHandedOff && originalContents.size > 0;
2550
+ let rolledBack = !editsHandedOff && originalContents.size > 0;
2172
2551
  if (rolledBack) {
2173
2552
  for (const [fPath, orig] of originalContents.entries()) {
2174
2553
  try {
@@ -2177,6 +2556,12 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2177
2556
  catch { }
2178
2557
  }
2179
2558
  }
2559
+ if (!editsHandedOff && rollback.fn) {
2560
+ try {
2561
+ rolledBack = (await rollback.fn()) || rolledBack;
2562
+ }
2563
+ catch { }
2564
+ }
2180
2565
  if (!editsHandedOff)
2181
2566
  clearEditJournal(card.id);
2182
2567
  log.error('card.failed', { card: card.id, project: projectPath, error: errorMessage(err), reverted: rolledBack });
@@ -2187,6 +2572,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2187
2572
  return false;
2188
2573
  }
2189
2574
  finally {
2575
+ releaseClaim(card.id);
2190
2576
  activeAutonomousCards.delete(card.id);
2191
2577
  activeWork--;
2192
2578
  }
@@ -2288,7 +2674,7 @@ export async function deployApprovedCardIfNeeded(client, card, projectPath) {
2288
2674
  const earlierSuccess = memory.deployed_cards[card.id] || null;
2289
2675
  // The deployment started at review time is still running in CI: wait for its verdict rather than deploying again
2290
2676
  if (stillPending && !isContainerRestartCard) {
2291
- const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card), memory });
2677
+ const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card, await machineOwnerId(client)), memory });
2292
2678
  const check = await checkCiOnce(projectPath, stillPending.sha, stillPending.workflow_names, profile.workflows.some((w) => w.isDeploy && w.workflowRun));
2293
2679
  if (check.verdict === 'success') {
2294
2680
  removePendingVerification(projectPath, card.id);
@@ -2335,7 +2721,7 @@ export async function deployApprovedCardIfNeeded(client, card, projectPath) {
2335
2721
  cardId: card.id,
2336
2722
  files: state?.files,
2337
2723
  redeploy: isContainerRestartCard,
2338
- bound: boundProfileOf(card),
2724
+ bound: boundProfileOf(card, await machineOwnerId(client)),
2339
2725
  });
2340
2726
  await reportOutcome(client, card, projectPath, res);
2341
2727
  if (!res.ok) {
@@ -2559,6 +2945,12 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
2559
2945
  if (pulseRunning) {
2560
2946
  if (holdsWorkerLock)
2561
2947
  acquireWorkerLock(role);
2948
+ // The same goes for the cards being worked on: while this pulse is busy nothing else tells the server
2949
+ // they are still held, and a build or deploy step can outlast the claim's lease.
2950
+ const held = activeClaims();
2951
+ if (held.length > 0) {
2952
+ void client.heartbeat(held).catch((err) => log.warn('claim.heartbeat_failed', { error: errorMessage(err) }));
2953
+ }
2562
2954
  return;
2563
2955
  }
2564
2956
  pulseRunning = true;
@@ -2577,6 +2969,7 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
2577
2969
  projectPath: config.project_path || process.cwd(),
2578
2970
  discoveredProjects: includeDiscovery ? client.discoverLocalProjects() : undefined,
2579
2971
  discoveredModels: includeDiscovery ? client.discoverLocalAgentModels() : undefined,
2972
+ activeClaims: activeClaims(),
2580
2973
  });
2581
2974
  if (consecutiveFailures > 0) {
2582
2975
  log.info('pulse.recovered', { after_failures: consecutiveFailures });