@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.
package/dist/server.js CHANGED
@@ -13,6 +13,7 @@ import { knownSecrets, redactResult } from './redact.js';
13
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, engineStatuses, 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({
@@ -1217,7 +1218,7 @@ function recentGitActivity(projectPath) {
1217
1218
  maxCommits = Math.max(maxCommits, n);
1218
1219
  return { files, dirCommits, maxCommits };
1219
1220
  }
1220
- export function collectProjectSourceFiles(projectPath, keywords) {
1221
+ export function collectProjectSourceFiles(projectPath, keywords, phrases = []) {
1221
1222
  const isFlutterProject = fs.existsSync(path.join(projectPath, 'pubspec.yaml'));
1222
1223
  const activity = recentGitActivity(projectPath);
1223
1224
  let docsText = '';
@@ -1297,6 +1298,11 @@ export function collectProjectSourceFiles(projectPath, keywords) {
1297
1298
  if (kw.length >= 4 && contentLower.includes(kw))
1298
1299
  c.score += 10;
1299
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
+ }
1300
1306
  }
1301
1307
  }
1302
1308
  catch { }
@@ -1783,6 +1789,551 @@ function frontendBuildDirs(projectPath, modifiedFiles) {
1783
1789
  }
1784
1790
  return dirs;
1785
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
+ log.info('agent.unavailable', { card: card.id, provider, notes });
1883
+ return 'unavailable';
1884
+ }
1885
+ // The agent gets a shell. Like a deploy command, what runs on this machine is for its owner to decide:
1886
+ // only cards its owner wrote are handed over (and only the owner's replies were kept in the conversation).
1887
+ if (!run.ownerId || !card.created_by || card.created_by !== run.ownerId) {
1888
+ run.engineNotes.push(card.created_by
1889
+ ? '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.'
1890
+ : '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.');
1891
+ log.info('agent.skipped', { card: card.id, reason: card.created_by ? 'card_not_from_owner' : 'author_unknown' });
1892
+ return 'unavailable';
1893
+ }
1894
+ const started = await baselineFor(card.id, projectPath);
1895
+ if (!started) {
1896
+ run.engineNotes.push('The project folder is not a Git repository, so an agent\'s changes could not be tracked.');
1897
+ return 'unavailable';
1898
+ }
1899
+ const baseline = started.snapshot;
1900
+ const revert = async (files) => {
1901
+ if (files.length > 0)
1902
+ await restoreWorkingTree(projectPath, baseline, files);
1903
+ clearBaseline(card.id);
1904
+ return files.length > 0;
1905
+ };
1906
+ run.rollback.fn = async () => revert((await changesSince(projectPath, baseline)).changed);
1907
+ const backToHuman = async (question, summary, files) => {
1908
+ run.rollback.fn = null;
1909
+ removeTaskLock(lockFile);
1910
+ appendIdeActivityLog(projectPath, {
1911
+ cardId: card.id,
1912
+ cardTitle: card.title,
1913
+ agentName: workerLabel,
1914
+ model: card.agent_model || 'auto',
1915
+ status: 'waiting_for_input',
1916
+ summary,
1917
+ files,
1918
+ });
1919
+ await client.askQuestion(card.id, provider, question);
1920
+ return 'handled';
1921
+ };
1922
+ const prompt = buildAgentPrompt({
1923
+ title: card.title || '',
1924
+ description: card.description || '',
1925
+ projectPath,
1926
+ boardName: card.board_name,
1927
+ conversation: run.conversation,
1928
+ developerFiles: Array.from(baseline.entries.keys()).filter((f) => !carryOverFiles.includes(f)),
1929
+ earlierFiles: carryOverFiles,
1930
+ earlierSummary: run.earlierSummary,
1931
+ });
1932
+ const model = card.agent_model && card.agent_model !== 'auto' ? card.agent_model : null;
1933
+ // 1. Run the first agent that actually starts. One that is logged out or crashes on launch touches nothing.
1934
+ let engine = engines[0];
1935
+ let result = null;
1936
+ const nativeLabel = engineLabelFor(provider);
1937
+ for (const candidate of engines) {
1938
+ // A card assigned to Cursor that Codex ends up working must say so, with the one step that fixes it
1939
+ const passedOver = nativeLabel && candidate.label !== nativeLabel ? run.engineNotes.filter((n) => n.startsWith(nativeLabel)) : [];
1940
+ 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.` +
1941
+ (passedOver.length > 0
1942
+ ? `\n\n${candidate.label} is standing in for ${nativeLabel}, which cannot run on this machine yet:\n${passedOver.map((n) => `- ${n}`).join('\n')}`
1943
+ : ''));
1944
+ log.info('agent.start', { card: card.id, engine: candidate.id, project: projectPath });
1945
+ const attempt = await runEngine(candidate, { prompt, projectPath, model });
1946
+ log.info('agent.end', { card: card.id, engine: candidate.id, ok: attempt.ok, seconds: Math.round(attempt.durationMs / 1000), failure: attempt.failure });
1947
+ if (!attempt.ok && (await changesSince(projectPath, baseline)).changed.length === 0) {
1948
+ run.engineNotes.push(`${candidate.label} could not run: ${attempt.failure || 'it exited without a result'}`);
1949
+ continue;
1950
+ }
1951
+ engine = candidate;
1952
+ result = attempt;
1953
+ break;
1954
+ }
1955
+ if (!result) {
1956
+ run.rollback.fn = null;
1957
+ clearBaseline(card.id);
1958
+ return 'unavailable';
1959
+ }
1960
+ if (result.costUsd && result.costUsd > 0) {
1961
+ try {
1962
+ await client.recordSpend(card.id, provider, 0, result.costUsd, `${engine.label} headless run`);
1963
+ }
1964
+ catch { }
1965
+ }
1966
+ let change = await changesSince(projectPath, baseline);
1967
+ if (change.pushedCommit) {
1968
+ clearBaseline(card.id);
1969
+ 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);
1970
+ }
1971
+ // Never ship the developer's own half-finished work under this card's name
1972
+ const foreign = change.overlapping.filter((f) => !carryOverFiles.includes(f));
1973
+ if (foreign.length > 0) {
1974
+ await revert(change.changed);
1975
+ 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
1976
+ ? '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.'
1977
+ : 'Commit or stash those changes, then reply here and I will continue.'}`, `Stopped: ${foreign.join(', ')} had uncommitted changes`, foreign);
1978
+ }
1979
+ if (!result.ok) {
1980
+ await revert(change.changed);
1981
+ 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'}`, []);
1982
+ }
1983
+ let report = parseAgentReport(result.text);
1984
+ if (report.status === 'blocked' || change.changed.length === 0) {
1985
+ await revert(change.changed);
1986
+ const said = report.question || report.summary || proseOf(result.text);
1987
+ return backToHuman(report.status === 'blocked' && report.question
1988
+ ? report.question
1989
+ : `${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)}`, []);
1990
+ }
1991
+ // 2. The runner's own build gate. The agent built it too, so a failure here is usually this machine's
1992
+ // environment — check that before sending the agent after an error that was never its own.
1993
+ let gate = await runBuildGate(projectPath, change.changed);
1994
+ if (!gate.ok) {
1995
+ const mine = new Map();
1996
+ for (const rel of change.changed) {
1997
+ try {
1998
+ mine.set(rel, fs.readFileSync(path.join(projectPath, rel)));
1999
+ }
2000
+ catch {
2001
+ mine.set(rel, null);
2002
+ }
2003
+ }
2004
+ await restoreWorkingTree(projectPath, baseline, change.changed);
2005
+ const pristine = await runBuildGate(projectPath, change.changed);
2006
+ for (const [rel, content] of mine) {
2007
+ const abs = path.join(projectPath, rel);
2008
+ if (content === null) {
2009
+ fs.rmSync(abs, { force: true });
2010
+ }
2011
+ else {
2012
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
2013
+ fs.writeFileSync(abs, content);
2014
+ }
2015
+ }
2016
+ if (!pristine.ok) {
2017
+ gate = { ok: true, note: brokenBaselineNote(pristine.errorOutput || '') };
2018
+ }
2019
+ else {
2020
+ await client.addProgress(card.id, provider, `The build failed after the change. Sending the error back to ${engine.label} to fix.`);
2021
+ const repair = await runEngine(engine, {
2022
+ 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}`,
2023
+ projectPath,
2024
+ model,
2025
+ resumeSessionId: result.sessionId,
2026
+ });
2027
+ change = await changesSince(projectPath, baseline);
2028
+ const repairedReport = parseAgentReport(repair.text);
2029
+ if (repairedReport.status === 'done' && repairedReport.summary)
2030
+ report = repairedReport;
2031
+ gate = change.changed.length > 0 ? await runBuildGate(projectPath, change.changed) : gate;
2032
+ const stillForeign = change.overlapping.filter((f) => !carryOverFiles.includes(f));
2033
+ if (!gate.ok || change.changed.length === 0 || stillForeign.length > 0) {
2034
+ await revert(change.changed);
2035
+ 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`, []);
2036
+ }
2037
+ }
2038
+ }
2039
+ await markNewFilesForDiff(projectPath, change.changed);
2040
+ return {
2041
+ modifiedFiles: change.changed,
2042
+ summary: report.summary || proseOf(result.text, 700),
2043
+ verificationSteps: report.verificationSteps.length > 0 ? report.verificationSteps : undefined,
2044
+ buildNote: gate.note,
2045
+ inspectedFiles: [],
2046
+ engine: engine.label,
2047
+ };
2048
+ }
2049
+ /**
2050
+ * The built-in pipeline: rank the project's files, let the server-side model pick a handful, and apply the
2051
+ * search/replace edits it returns. It cannot explore or run anything, so it is the fallback for machines
2052
+ * without an agent CLI. Returns null when the card was handed back to the human.
2053
+ */
2054
+ async function produceChangeBuiltIn(run) {
2055
+ const { client, card, projectPath, provider, workerLabel, lockFile, profile, carryOverFiles, originalContents } = run;
2056
+ await client.addProgress(card.id, provider, `Autonomous background runner claimed task in \`${projectPath}\` and moved card to **In Progress**. Scanning repository files...` +
2057
+ (run.engineNotes.length > 0 ? `\n\nUsing the built-in engine for this card:\n${run.engineNotes.map((n) => `- ${n}`).join('\n')}` : ''));
2058
+ // 2. Build keyword list from the card and what the requester wrote on it, and collect project source files.
2059
+ // Their replies count: "the Recently active projects table on the Operations page" is where the answer is.
2060
+ const requestText = [card.title || '', card.description || '', ...run.conversation.filter((t) => t.who === 'requester').map((t) => t.text)].join(' ');
2061
+ const rawText = requestText.toLowerCase();
2062
+ const stopWords = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'need', 'make', 'user', 'use', 'into', 'when', 'through']);
2063
+ const keywords = Array.from(new Set(rawText
2064
+ .split(/[^a-z0-9]+/)
2065
+ .filter((w) => w.length >= 3 && !stopWords.has(w))));
2066
+ const fileIndex = collectProjectSourceFiles(projectPath, keywords, quotedPhrases(requestText));
2067
+ if (fileIndex.length === 0) {
2068
+ removeTaskLock(lockFile);
2069
+ 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.`);
2070
+ return null;
2071
+ }
2072
+ // 3. Ask Agile Monster Autonomous Step to pick the 1-6 most relevant files
2073
+ const projectContext = readProjectInstructionsContext(projectPath, profile);
2074
+ const selectionRes = await client.autonomousStep({
2075
+ card_id: card.id,
2076
+ action: 'select_files',
2077
+ file_index: fileIndex,
2078
+ project_context: projectContext,
2079
+ });
2080
+ // The model may only choose from the files it was offered: a path it made up (../../.ssh/…, .env) is dropped
2081
+ const selectedFiles = (selectionRes.target_files || selectionRes.selected_files || [])
2082
+ .filter((f) => typeof f === 'string' && fileIndex.includes(f))
2083
+ .slice(0, 6);
2084
+ if (selectedFiles.length === 0) {
2085
+ selectedFiles.push(...fileIndex.slice(0, 4));
2086
+ }
2087
+ // Automatically include project type definition files (e.g. types.ts,frontend/src/lib/types.ts) so the model knows exact schemas
2088
+ for (const candidateTypeFile of [
2089
+ 'frontend/src/lib/types.ts',
2090
+ 'src/lib/types.ts',
2091
+ 'src/types.ts',
2092
+ 'src/types/index.ts',
2093
+ 'frontend/src/types.ts',
2094
+ ]) {
2095
+ if (fileIndex.includes(candidateTypeFile) &&
2096
+ !selectedFiles.includes(candidateTypeFile) &&
2097
+ selectedFiles.length < 7) {
2098
+ selectedFiles.push(candidateTypeFile);
2099
+ }
2100
+ }
2101
+ const filesPayload = {};
2102
+ for (const rel of selectedFiles) {
2103
+ const full = path.join(projectPath, rel);
2104
+ if (fs.existsSync(full)) {
2105
+ try {
2106
+ const raw = fs.readFileSync(full);
2107
+ // A file that is not valid UTF-8 would be silently corrupted by a text round-trip: leave it alone
2108
+ if (isUtf8(raw))
2109
+ filesPayload[rel] = raw.toString('utf8');
2110
+ }
2111
+ catch { }
2112
+ }
2113
+ }
2114
+ if (Object.keys(filesPayload).length === 0) {
2115
+ removeTaskLock(lockFile);
2116
+ 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?');
2117
+ return null;
2118
+ }
2119
+ await client.addProgress(card.id, provider, `Selected target files for implementation: ${Object.keys(filesPayload)
2120
+ .map((f) => `\`${f}\``)
2121
+ .join(', ')}. Generating code changes...`);
2122
+ // 4. Generate surgical code edits
2123
+ let editsRes = await client.autonomousStep({
2124
+ card_id: card.id,
2125
+ action: 'generate_edits',
2126
+ files: filesPayload,
2127
+ project_context: projectContext,
2128
+ });
2129
+ // Gate A: Strict truth — if the model is not confident / cannot find the target, ask the human instead of guessing
2130
+ if (editsRes.needs_clarification && (editsRes.clarification_question || '').trim() !== '') {
2131
+ removeTaskLock(lockFile);
2132
+ appendIdeActivityLog(projectPath, {
2133
+ cardId: card.id,
2134
+ cardTitle: card.title,
2135
+ agentName: workerLabel,
2136
+ model: card.agent_model || 'auto',
2137
+ status: 'waiting_for_input',
2138
+ summary: `Needs clarification: ${editsRes.clarification_question}`,
2139
+ files: Object.keys(filesPayload),
2140
+ });
2141
+ await client.askQuestion(card.id, provider, (editsRes.clarification_question || '').trim());
2142
+ return null;
2143
+ }
2144
+ // Only the files that were actually sent to the model may be edited (never .git/, never a path it invented)
2145
+ const editableFiles = new Set(Object.keys(filesPayload));
2146
+ const projectRoot = fs.realpathSync(projectPath);
2147
+ /**
2148
+ * Apply one set of edits. Returns the files changed and a reason for every edit that could not be applied.
2149
+ * A set of edits is one coordinated change: the caller ships it only if ALL of it applied.
2150
+ */
2151
+ const applyEditsPass = async (editsList) => {
2152
+ const modFiles = [];
2153
+ const failed = [];
2154
+ for (const edit of editsList) {
2155
+ const relPath = (edit.file || edit.path || '').replace(/\\/g, '/').replace(/^\.\//, '');
2156
+ if (!relPath || !edit.search || typeof edit.replace !== 'string') {
2157
+ failed.push(`${relPath || '(no file)'}: the edit was incomplete`);
2158
+ continue;
2159
+ }
2160
+ if (!editableFiles.has(relPath)) {
2161
+ failed.push(`${relPath}: not one of the files provided`);
2162
+ continue;
2163
+ }
2164
+ const fullPath = path.resolve(projectPath, relPath);
2165
+ let realPath = '';
2166
+ try {
2167
+ realPath = fs.realpathSync(fullPath);
2168
+ }
2169
+ catch { }
2170
+ if (!realPath || !(realPath === projectRoot || realPath.startsWith(projectRoot + path.sep))) {
2171
+ failed.push(`${relPath}: outside the project`);
2172
+ continue;
2173
+ }
2174
+ const currentContent = fs.readFileSync(fullPath, 'utf8');
2175
+ if (!currentContent.includes(edit.search)) {
2176
+ failed.push(`${relPath}: the \`search\` text was not found verbatim in the file`);
2177
+ continue;
2178
+ }
2179
+ const updatedContent = currentContent.replace(edit.search, () => edit.replace);
2180
+ if (updatedContent === currentContent)
2181
+ continue; // nothing to do is not a failure
2182
+ const countChar = (s, ch) => s.split(ch).length - 1;
2183
+ const searchBraceDelta = countChar(edit.search, '{') - countChar(edit.search, '}');
2184
+ const replaceBraceDelta = countChar(edit.replace, '{') - countChar(edit.replace, '}');
2185
+ const searchParenDelta = countChar(edit.search, '(') - countChar(edit.search, ')');
2186
+ const replaceParenDelta = countChar(edit.replace, '(') - countChar(edit.replace, ')');
2187
+ if (searchBraceDelta !== replaceBraceDelta || searchParenDelta !== replaceParenDelta) {
2188
+ failed.push(`${relPath}: the replacement would leave braces or parentheses unbalanced`);
2189
+ continue;
2190
+ }
2191
+ if (!originalContents.has(fullPath))
2192
+ originalContents.set(fullPath, currentContent);
2193
+ fs.writeFileSync(fullPath, updatedContent, 'utf8');
2194
+ journalEdits(card.id, projectPath, originalContents);
2195
+ // Per-file syntax checks run without blocking the event loop (the worker heartbeat must keep ticking)
2196
+ if (relPath.endsWith('.dart')) {
2197
+ const lint = await exec('dart', ['analyze', fullPath], { cwd: projectPath, timeoutMs: 40000 });
2198
+ if (!lint.ok && /\berror\s+[•-]/i.test(lint.stdout + lint.stderr)) {
2199
+ fs.writeFileSync(fullPath, currentContent, 'utf8');
2200
+ failed.push(`${relPath}: dart analyze reported an error after the edit: ${lint.tail.slice(0, 300)}`);
2201
+ continue;
2202
+ }
2203
+ }
2204
+ else if (relPath.endsWith('.php')) {
2205
+ const lint = await exec('php', ['-l', fullPath], { cwd: projectPath, timeoutMs: 15000 });
2206
+ // exit code null = `php` is not installed here: that must not discard valid work
2207
+ if (!lint.ok && lint.code !== null && !lint.timedOut) {
2208
+ fs.writeFileSync(fullPath, currentContent, 'utf8');
2209
+ failed.push(`${relPath}: PHP syntax error after the edit: ${lint.tail.slice(0, 300)}`);
2210
+ continue;
2211
+ }
2212
+ }
2213
+ if (!modFiles.includes(relPath))
2214
+ modFiles.push(relPath);
2215
+ }
2216
+ return { modFiles, failed };
2217
+ };
2218
+ const revertAll = () => {
2219
+ for (const [fPath, orig] of originalContents.entries()) {
2220
+ try {
2221
+ fs.writeFileSync(fPath, orig, 'utf8');
2222
+ }
2223
+ catch { }
2224
+ }
2225
+ originalContents.clear();
2226
+ clearEditJournal(card.id);
2227
+ };
2228
+ // Never build on top of the developer's own uncommitted work in the same file: a whole-file commit would
2229
+ // ship their half-finished changes under this card's name.
2230
+ const targetFiles = Array.from(new Set((editsRes.edits || []).map((e) => (e.file || e.path || '').replace(/\\/g, '/').replace(/^\.\//, '')).filter((f) => editableFiles.has(f))));
2231
+ if (targetFiles.length > 0) {
2232
+ const status = await exec('git', ['--literal-pathspecs', 'status', '--porcelain', '--', ...targetFiles], { cwd: projectPath, timeoutMs: 20000 });
2233
+ const dirty = status.ok
2234
+ ? status.stdout
2235
+ .split('\n')
2236
+ .filter((l) => l.trim() && !l.startsWith('??'))
2237
+ .map((l) => l.slice(3).trim())
2238
+ .filter((f) => !carryOverFiles.includes(f))
2239
+ : [];
2240
+ if (dirty.length > 0) {
2241
+ removeTaskLock(lockFile);
2242
+ 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.`);
2243
+ return null;
2244
+ }
2245
+ }
2246
+ let pass = await applyEditsPass(editsRes.edits || []);
2247
+ if (pass.failed.length > 0) {
2248
+ // Half of a coordinated change is worse than none: undo it and give the model one chance to correct itself
2249
+ revertAll();
2250
+ editsRes = await client.autonomousStep({
2251
+ card_id: card.id,
2252
+ action: 'generate_edits',
2253
+ files: {
2254
+ ...filesPayload,
2255
+ '__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.`,
2256
+ },
2257
+ project_context: projectContext,
2258
+ });
2259
+ pass = await applyEditsPass(editsRes.edits || []);
2260
+ if (pass.failed.length > 0) {
2261
+ revertAll();
2262
+ pass = { modFiles: [], failed: pass.failed };
2263
+ }
2264
+ }
2265
+ const modifiedFiles = pass.modFiles;
2266
+ const applyFailures = pass.failed;
2267
+ let buildCheck = await runBuildGate(projectPath, modifiedFiles);
2268
+ if (!buildCheck.ok) {
2269
+ const firstEdits = editsRes.edits || [];
2270
+ const builtFiles = [...modifiedFiles];
2271
+ revertAll();
2272
+ modifiedFiles.length = 0;
2273
+ // Is it this change, or does the project not build on this machine at all? Only the first is a reason to
2274
+ // throw the work away — a build that was already red says nothing about the edit.
2275
+ const pristine = await runBuildGate(projectPath, builtFiles);
2276
+ if (!pristine.ok) {
2277
+ const again = await applyEditsPass(firstEdits);
2278
+ if (again.failed.length === 0) {
2279
+ modifiedFiles.push(...again.modFiles);
2280
+ buildCheck = { ok: true, note: brokenBaselineNote(pristine.errorOutput || '') };
2281
+ }
2282
+ else {
2283
+ revertAll();
2284
+ applyFailures.push(...again.failed);
2285
+ }
2286
+ }
2287
+ else {
2288
+ for (let attempt = 0; attempt < 2 && !buildCheck.ok; attempt++) {
2289
+ editsRes = await client.autonomousStep({
2290
+ card_id: card.id,
2291
+ action: 'generate_edits',
2292
+ files: {
2293
+ ...filesPayload,
2294
+ '__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.`,
2295
+ },
2296
+ project_context: projectContext,
2297
+ });
2298
+ const retryPass = await applyEditsPass(editsRes.edits || []);
2299
+ if (retryPass.failed.length > 0) {
2300
+ revertAll();
2301
+ applyFailures.push(...retryPass.failed);
2302
+ break;
2303
+ }
2304
+ modifiedFiles.push(...retryPass.modFiles);
2305
+ buildCheck = await runBuildGate(projectPath, modifiedFiles);
2306
+ if (!buildCheck.ok) {
2307
+ revertAll();
2308
+ modifiedFiles.length = 0;
2309
+ }
2310
+ }
2311
+ }
2312
+ }
2313
+ if (modifiedFiles.length === 0) {
2314
+ removeTaskLock(lockFile);
2315
+ revertAll();
2316
+ // Say what actually went wrong. The requester already described the task (possibly twice): asking them
2317
+ // "where should this appear?" again would be a question about my limits dressed up as one about theirs.
2318
+ const reason = !buildCheck.ok && buildCheck.errorOutput
2319
+ ? `The change I wrote did not build, so I reverted it. The build said:\n\`\`\`\n${buildErrorExcerpt(buildCheck.errorOutput)}\n\`\`\``
2320
+ : applyFailures.length > 0
2321
+ ? `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)}`
2322
+ : 'The model returned no change for these files.';
2323
+ await client.askQuestion(card.id, provider, `I could not finish this card. I worked in ${Object.keys(filesPayload)
2324
+ .map((f) => `\`${f}\``)
2325
+ .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.`);
2326
+ return null;
2327
+ }
2328
+ return {
2329
+ modifiedFiles,
2330
+ summary: editsRes.summary || '',
2331
+ verificationSteps: editsRes.verification_steps,
2332
+ buildNote: buildCheck.note,
2333
+ inspectedFiles: Object.keys(filesPayload),
2334
+ engine: 'built-in',
2335
+ };
2336
+ }
1786
2337
  export async function executeQueuedCardAutonomously(client, card, projectPath) {
1787
2338
  if (!projectPath || !fs.existsSync(projectPath))
1788
2339
  return false;
@@ -1820,6 +2371,8 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1820
2371
  const workerLabel = card.agent_display_name || provider;
1821
2372
  // Original contents of every file this run touches, so an interrupted run never leaves half-applied edits behind
1822
2373
  const originalContents = new Map();
2374
+ // The agent path cannot know its files up front: it registers how to put the tree back instead
2375
+ const rollback = { fn: null };
1823
2376
  let editsHandedOff = false;
1824
2377
  log.info('card.start', { card: card.id, title: card.title, project: projectPath, provider });
1825
2378
  try {
@@ -1859,284 +2412,45 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1859
2412
  return await runRedeployOnlyCard(client, card, projectPath, provider, workerLabel, lockFile);
1860
2413
  }
1861
2414
  const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card, await machineOwnerId(client)), memory: loadMemory(projectPath) });
1862
- await client.addProgress(card.id, provider, `Autonomous background runner claimed task in \`${projectPath}\` and moved card to **In Progress**. Scanning repository files...`);
1863
- // 2. Build keyword list from card title + description and collect project source files
1864
- const rawText = `${card.title || ''} ${card.description || ''}`.toLowerCase();
1865
- const stopWords = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'need', 'make', 'user', 'use', 'into', 'when', 'through']);
1866
- const keywords = Array.from(new Set(rawText
1867
- .split(/[^a-z0-9]+/)
1868
- .filter((w) => w.length >= 3 && !stopWords.has(w))));
1869
- const fileIndex = collectProjectSourceFiles(projectPath, keywords);
1870
- if (fileIndex.length === 0) {
1871
- removeTaskLock(lockFile);
1872
- 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.`);
1873
- return false;
1874
- }
1875
- // 3. Ask Agile Monster Autonomous Step to pick the 1-6 most relevant files
1876
- const projectContext = readProjectInstructionsContext(projectPath, profile);
1877
- const selectionRes = await client.autonomousStep({
1878
- card_id: card.id,
1879
- action: 'select_files',
1880
- file_index: fileIndex,
1881
- project_context: projectContext,
1882
- });
1883
- // The model may only choose from the files it was offered: a path it made up (../../.ssh/…, .env) is dropped
1884
- const selectedFiles = (selectionRes.target_files || selectionRes.selected_files || [])
1885
- .filter((f) => typeof f === 'string' && fileIndex.includes(f))
1886
- .slice(0, 6);
1887
- if (selectedFiles.length === 0) {
1888
- selectedFiles.push(...fileIndex.slice(0, 4));
1889
- }
1890
- // Automatically include project type definition files (e.g. types.ts,frontend/src/lib/types.ts) so the model knows exact schemas
1891
- for (const candidateTypeFile of [
1892
- 'frontend/src/lib/types.ts',
1893
- 'src/lib/types.ts',
1894
- 'src/types.ts',
1895
- 'src/types/index.ts',
1896
- 'frontend/src/types.ts',
1897
- ]) {
1898
- if (fileIndex.includes(candidateTypeFile) &&
1899
- !selectedFiles.includes(candidateTypeFile) &&
1900
- selectedFiles.length < 7) {
1901
- selectedFiles.push(candidateTypeFile);
1902
- }
1903
- }
1904
- const filesPayload = {};
1905
- for (const rel of selectedFiles) {
1906
- const full = path.join(projectPath, rel);
1907
- if (fs.existsSync(full)) {
1908
- try {
1909
- const raw = fs.readFileSync(full);
1910
- // A file that is not valid UTF-8 would be silently corrupted by a text round-trip: leave it alone
1911
- if (isUtf8(raw))
1912
- filesPayload[rel] = raw.toString('utf8');
1913
- }
1914
- catch { }
1915
- }
1916
- }
1917
- if (Object.keys(filesPayload).length === 0) {
1918
- removeTaskLock(lockFile);
1919
- 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?');
1920
- return false;
1921
- }
1922
- await client.addProgress(card.id, provider, `Selected target files for implementation: ${Object.keys(filesPayload)
1923
- .map((f) => `\`${f}\``)
1924
- .join(', ')}. Generating code changes...`);
1925
- // 4. Generate surgical code edits
1926
- let editsRes = await client.autonomousStep({
1927
- card_id: card.id,
1928
- action: 'generate_edits',
1929
- files: filesPayload,
1930
- project_context: projectContext,
1931
- });
1932
- // Gate A: Strict truth — if the model is not confident / cannot find the target, ask the human instead of guessing
1933
- if (editsRes.needs_clarification && (editsRes.clarification_question || '').trim() !== '') {
1934
- removeTaskLock(lockFile);
1935
- appendIdeActivityLog(projectPath, {
1936
- cardId: card.id,
1937
- cardTitle: card.title,
1938
- agentName: workerLabel,
1939
- model: card.agent_model || 'auto',
1940
- status: 'waiting_for_input',
1941
- summary: `Needs clarification: ${editsRes.clarification_question}`,
1942
- files: Object.keys(filesPayload),
1943
- });
1944
- await client.askQuestion(card.id, provider, (editsRes.clarification_question || '').trim());
1945
- return false;
1946
- }
1947
- // Only the files that were actually sent to the model may be edited (never .git/, never a path it invented)
1948
- const editableFiles = new Set(Object.keys(filesPayload));
1949
- const projectRoot = fs.realpathSync(projectPath);
1950
- /**
1951
- * Apply one set of edits. Returns the files changed and a reason for every edit that could not be applied.
1952
- * A set of edits is one coordinated change: the caller ships it only if ALL of it applied.
1953
- */
1954
- const applyEditsPass = async (editsList) => {
1955
- const modFiles = [];
1956
- const failed = [];
1957
- for (const edit of editsList) {
1958
- const relPath = (edit.file || edit.path || '').replace(/\\/g, '/').replace(/^\.\//, '');
1959
- if (!relPath || !edit.search || typeof edit.replace !== 'string') {
1960
- failed.push(`${relPath || '(no file)'}: the edit was incomplete`);
1961
- continue;
1962
- }
1963
- if (!editableFiles.has(relPath)) {
1964
- failed.push(`${relPath}: not one of the files provided`);
1965
- continue;
1966
- }
1967
- const fullPath = path.resolve(projectPath, relPath);
1968
- let realPath = '';
1969
- try {
1970
- realPath = fs.realpathSync(fullPath);
1971
- }
1972
- catch { }
1973
- if (!realPath || !(realPath === projectRoot || realPath.startsWith(projectRoot + path.sep))) {
1974
- failed.push(`${relPath}: outside the project`);
1975
- continue;
1976
- }
1977
- const currentContent = fs.readFileSync(fullPath, 'utf8');
1978
- if (!currentContent.includes(edit.search)) {
1979
- failed.push(`${relPath}: the \`search\` text was not found verbatim in the file`);
1980
- continue;
1981
- }
1982
- const updatedContent = currentContent.replace(edit.search, () => edit.replace);
1983
- if (updatedContent === currentContent)
1984
- continue; // nothing to do is not a failure
1985
- const countChar = (s, ch) => s.split(ch).length - 1;
1986
- const searchBraceDelta = countChar(edit.search, '{') - countChar(edit.search, '}');
1987
- const replaceBraceDelta = countChar(edit.replace, '{') - countChar(edit.replace, '}');
1988
- const searchParenDelta = countChar(edit.search, '(') - countChar(edit.search, ')');
1989
- const replaceParenDelta = countChar(edit.replace, '(') - countChar(edit.replace, ')');
1990
- if (searchBraceDelta !== replaceBraceDelta || searchParenDelta !== replaceParenDelta) {
1991
- failed.push(`${relPath}: the replacement would leave braces or parentheses unbalanced`);
1992
- continue;
1993
- }
1994
- if (!originalContents.has(fullPath))
1995
- originalContents.set(fullPath, currentContent);
1996
- fs.writeFileSync(fullPath, updatedContent, 'utf8');
1997
- journalEdits(card.id, projectPath, originalContents);
1998
- // Per-file syntax checks run without blocking the event loop (the worker heartbeat must keep ticking)
1999
- if (relPath.endsWith('.dart')) {
2000
- const lint = await exec('dart', ['analyze', fullPath], { cwd: projectPath, timeoutMs: 40000 });
2001
- if (!lint.ok && /\berror\s+[•-]/i.test(lint.stdout + lint.stderr)) {
2002
- fs.writeFileSync(fullPath, currentContent, 'utf8');
2003
- failed.push(`${relPath}: dart analyze reported an error after the edit: ${lint.tail.slice(0, 300)}`);
2004
- continue;
2005
- }
2006
- }
2007
- else if (relPath.endsWith('.php')) {
2008
- const lint = await exec('php', ['-l', fullPath], { cwd: projectPath, timeoutMs: 15000 });
2009
- // exit code null = `php` is not installed here: that must not discard valid work
2010
- if (!lint.ok && lint.code !== null && !lint.timedOut) {
2011
- fs.writeFileSync(fullPath, currentContent, 'utf8');
2012
- failed.push(`${relPath}: PHP syntax error after the edit: ${lint.tail.slice(0, 300)}`);
2013
- continue;
2014
- }
2015
- }
2016
- if (!modFiles.includes(relPath))
2017
- modFiles.push(relPath);
2018
- }
2019
- return { modFiles, failed };
2020
- };
2021
- const revertAll = () => {
2022
- for (const [fPath, orig] of originalContents.entries()) {
2023
- try {
2024
- fs.writeFileSync(fPath, orig, 'utf8');
2025
- }
2026
- catch { }
2027
- }
2028
- originalContents.clear();
2029
- clearEditJournal(card.id);
2030
- };
2031
- // Never build on top of the developer's own uncommitted work in the same file: a whole-file commit would
2032
- // ship their half-finished changes under this card's name.
2033
- const targetFiles = Array.from(new Set((editsRes.edits || []).map((e) => (e.file || e.path || '').replace(/\\/g, '/').replace(/^\.\//, '')).filter((f) => editableFiles.has(f))));
2034
- if (targetFiles.length > 0) {
2035
- const status = await exec('git', ['--literal-pathspecs', 'status', '--porcelain', '--', ...targetFiles], { cwd: projectPath, timeoutMs: 20000 });
2036
- const dirty = status.ok
2037
- ? status.stdout
2038
- .split('\n')
2039
- .filter((l) => l.trim() && !l.startsWith('??'))
2040
- .map((l) => l.slice(3).trim())
2041
- .filter((f) => !carryOverFiles.includes(f))
2042
- : [];
2043
- if (dirty.length > 0) {
2044
- removeTaskLock(lockFile);
2045
- 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.`);
2046
- return false;
2047
- }
2048
- }
2049
- let pass = await applyEditsPass(editsRes.edits || []);
2050
- if (pass.failed.length > 0) {
2051
- // Half of a coordinated change is worse than none: undo it and give the model one chance to correct itself
2052
- revertAll();
2053
- editsRes = await client.autonomousStep({
2054
- card_id: card.id,
2055
- action: 'generate_edits',
2056
- files: {
2057
- ...filesPayload,
2058
- '__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.`,
2059
- },
2060
- project_context: projectContext,
2061
- });
2062
- pass = await applyEditsPass(editsRes.edits || []);
2063
- if (pass.failed.length > 0) {
2064
- revertAll();
2065
- pass = { modFiles: [], failed: pass.failed };
2066
- }
2067
- }
2068
- const modifiedFiles = pass.modFiles;
2069
- const applyFailures = pass.failed;
2070
- // Pre-submit build gate: build the package that owns each changed front-end file (works for any monorepo layout)
2071
- const verifyFrontendBuilds = async () => {
2072
- for (const dir of frontendBuildDirs(projectPath, modifiedFiles)) {
2073
- const buildCmd = fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) ? 'pnpm' : 'npm';
2074
- const res = await exec(buildCmd, ['run', 'build'], { cwd: dir, timeoutMs: 6 * 60 * 1000 });
2075
- if (res.timedOut) {
2076
- // A slow build is not a compile error: do not throw the work away over it
2077
- return { ok: true, note: `The build in \`${path.relative(projectPath, dir) || '.'}\` did not finish within 6 minutes, so it was not verified locally.` };
2078
- }
2079
- if (!res.ok) {
2080
- return { ok: false, errorOutput: `${res.stdout}\n${res.stderr}`.trim().slice(-1500) };
2081
- }
2082
- }
2083
- return { ok: true };
2415
+ const ownerId = await machineOwnerId(client);
2416
+ const run = {
2417
+ client,
2418
+ card,
2419
+ projectPath,
2420
+ provider,
2421
+ workerLabel,
2422
+ lockFile,
2423
+ profile,
2424
+ carryOverFiles,
2425
+ originalContents,
2426
+ ownerId,
2427
+ conversation: await cardConversation(client, card, ownerId),
2428
+ earlierSummary: priorState?.summary || null,
2429
+ engineNotes: [],
2430
+ rollback,
2084
2431
  };
2085
- let buildCheck = await verifyFrontendBuilds();
2086
- if (!buildCheck.ok) {
2087
- // Revert first pass, attach compiler error context, and do 1 self-correction pass
2088
- revertAll();
2089
- modifiedFiles.length = 0;
2090
- const retryFiles = {
2091
- ...filesPayload,
2092
- '__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.`,
2093
- };
2094
- editsRes = await client.autonomousStep({
2095
- card_id: card.id,
2096
- action: 'generate_edits',
2097
- files: retryFiles,
2098
- project_context: projectContext,
2099
- });
2100
- const retryPass = await applyEditsPass(editsRes.edits || []);
2101
- if (retryPass.failed.length > 0) {
2102
- revertAll();
2103
- applyFailures.push(...retryPass.failed);
2104
- }
2105
- else {
2106
- modifiedFiles.push(...retryPass.modFiles);
2107
- buildCheck = await verifyFrontendBuilds();
2108
- if (!buildCheck.ok) {
2109
- revertAll();
2110
- modifiedFiles.length = 0;
2111
- }
2112
- }
2113
- }
2114
- if (modifiedFiles.length === 0) {
2115
- removeTaskLock(lockFile);
2116
- revertAll();
2117
- const buildNote = buildCheck && !buildCheck.ok && buildCheck.errorOutput
2118
- ? `\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)}`
2119
- : applyFailures.length > 0
2120
- ? `\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)}`
2121
- : '';
2122
- await client.askQuestion(card.id, provider, `I inspected ${Object.keys(filesPayload)
2123
- .map((f) => `\`${f}\``)
2124
- .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?`);
2432
+ // A real coding agent when this machine has one; the built-in pipeline otherwise
2433
+ const viaAgent = await produceChangeWithAgent(run);
2434
+ if (viaAgent === 'handled')
2125
2435
  return false;
2126
- }
2436
+ const produced = viaAgent === 'unavailable' ? await produceChangeBuiltIn(run) : viaAgent;
2437
+ if (!produced)
2438
+ return false;
2439
+ const modifiedFiles = produced.modifiedFiles;
2127
2440
  const commitFiles = Array.from(new Set([...carryOverFiles, ...modifiedFiles]));
2128
2441
  let rawDiff = '';
2129
2442
  try {
2130
2443
  rawDiff = (await exec('git', ['--literal-pathspecs', 'diff', '--', ...modifiedFiles], { cwd: projectPath, timeoutMs: 20000 })).stdout.trim();
2131
2444
  }
2132
2445
  catch { }
2133
- const baseSummary = editsRes.summary ||
2446
+ const baseSummary = produced.summary ||
2134
2447
  `Updated ${modifiedFiles.join(', ')} to complete "${card.title}".`;
2135
- const plainSummary = baseSummary.replace(/```[\s\S]*?```/g, '').trim() + (buildCheck.note ? `\n\n${buildCheck.note}` : '');
2136
- const verificationSteps = Array.isArray(editsRes.verification_steps)
2137
- ? editsRes.verification_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')
2138
- : (typeof editsRes.verification_steps === 'string' && editsRes.verification_steps.trim() !== ''
2139
- ? editsRes.verification_steps
2448
+ const plainSummary = baseSummary.replace(/```[\s\S]*?```/g, '').trim() + (produced.buildNote ? `\n\n${produced.buildNote}` : '');
2449
+ const stepsFromAgent = produced.verificationSteps;
2450
+ const verificationSteps = Array.isArray(stepsFromAgent)
2451
+ ? stepsFromAgent.map((s, i) => `${i + 1}. ${String(s).replace(/^\d+[.)]\s*/, '')}`).join('\n')
2452
+ : (typeof stepsFromAgent === 'string' && stepsFromAgent.trim() !== ''
2453
+ ? stepsFromAgent
2140
2454
  : `1. Check the live update for "${card.title}".\n2. Click **Approve & Deploy to Done** when verified.`);
2141
2455
  const cardState = (stage, extra = {}) => ({
2142
2456
  stage,
@@ -2155,7 +2469,9 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2155
2469
  let deployedNote = '';
2156
2470
  // From here on the edits are owned by the card's saved state (deploy / approval), not by this run's revert safety net
2157
2471
  editsHandedOff = true;
2472
+ rollback.fn = null;
2158
2473
  clearEditJournal(card.id);
2474
+ clearBaseline(card.id);
2159
2475
  if (deployStage === 'review') {
2160
2476
  // Persist first: if this process dies mid-deploy, the re-queued card resumes with the deployment only
2161
2477
  setCardState(projectPath, card.id, cardState('deploy_failed', { last_error: 'The deployment was interrupted before it finished.' }));
@@ -2209,7 +2525,8 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2209
2525
  const logLines = [
2210
2526
  `Claimed card "${card.title}" (${card.id}) in ${projectPath}`,
2211
2527
  `Agent: ${workerLabel} | Model: ${card.agent_model || 'auto'}`,
2212
- `Selected files: ${Object.keys(filesPayload).join(', ')}`,
2528
+ `Engine: ${produced.engine}`,
2529
+ ...(produced.inspectedFiles.length > 0 ? [`Selected files: ${produced.inspectedFiles.join(', ')}`] : []),
2213
2530
  `Modified & verified files: ${modifiedFiles.join(', ')}`,
2214
2531
  `Deployment route: ${profile.method} (${profile.confidence} confidence, ${profile.source})`,
2215
2532
  `Deployment: ${deployedNote}`,
@@ -2232,13 +2549,13 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2232
2549
  });
2233
2550
  writeTaskLock(lockFile, card, 'completed');
2234
2551
  sendDesktopNotification(`Agile Monster -> Ready for Review`, `Completed "${card.title}" (${modifiedFiles.length} file(s) updated) and moved to Testing / Review!`);
2235
- log.info('card.review', { card: card.id, files: modifiedFiles.length, deploy: profile.method });
2552
+ log.info('card.review', { card: card.id, files: modifiedFiles.length, deploy: profile.method, engine: produced.engine });
2236
2553
  return true;
2237
2554
  }
2238
2555
  catch (err) {
2239
2556
  removeTaskLock(lockFile);
2240
2557
  // An interrupted run must not leave half-applied, uncommitted edits in the developer's working tree
2241
- const rolledBack = !editsHandedOff && originalContents.size > 0;
2558
+ let rolledBack = !editsHandedOff && originalContents.size > 0;
2242
2559
  if (rolledBack) {
2243
2560
  for (const [fPath, orig] of originalContents.entries()) {
2244
2561
  try {
@@ -2247,6 +2564,12 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2247
2564
  catch { }
2248
2565
  }
2249
2566
  }
2567
+ if (!editsHandedOff && rollback.fn) {
2568
+ try {
2569
+ rolledBack = (await rollback.fn()) || rolledBack;
2570
+ }
2571
+ catch { }
2572
+ }
2250
2573
  if (!editsHandedOff)
2251
2574
  clearEditJournal(card.id);
2252
2575
  log.error('card.failed', { card: card.id, project: projectPath, error: errorMessage(err), reverted: rolledBack });
@@ -2764,6 +3087,10 @@ export async function runBackgroundPulseDaemon(intervalMs = 20000) {
2764
3087
  log.error('daemon.uncaught_exception', { error: errorMessage(err), stack: err instanceof Error ? err.stack : undefined });
2765
3088
  process.exit(1);
2766
3089
  });
3090
+ // Which agent will work a queued card here, on record before the first card arrives
3091
+ engineStatuses()
3092
+ .then((agents) => log.info('daemon.agents', { agents: agents.map((a) => ({ id: a.id, ready: !a.problem, bin: a.bin, problem: a.problem })) }))
3093
+ .catch(() => { });
2767
3094
  startBuiltInPullPulse(null, client, intervalMs, 'daemon');
2768
3095
  // Watch dist/server.js for updates so launchd reloads new code after a rebuild — but only between jobs,
2769
3096
  // never in the middle of an edit or a deployment.