@agilemonster/mcp-server 1.0.23 → 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
@@ -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, 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,543 @@ 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
+ 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
+ }
1786
2329
  export async function executeQueuedCardAutonomously(client, card, projectPath) {
1787
2330
  if (!projectPath || !fs.existsSync(projectPath))
1788
2331
  return false;
@@ -1820,6 +2363,8 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1820
2363
  const workerLabel = card.agent_display_name || provider;
1821
2364
  // Original contents of every file this run touches, so an interrupted run never leaves half-applied edits behind
1822
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 };
1823
2368
  let editsHandedOff = false;
1824
2369
  log.info('card.start', { card: card.id, title: card.title, project: projectPath, provider });
1825
2370
  try {
@@ -1859,284 +2404,45 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1859
2404
  return await runRedeployOnlyCard(client, card, projectPath, provider, workerLabel, lockFile);
1860
2405
  }
1861
2406
  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 };
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,
2084
2423
  };
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?`);
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')
2125
2427
  return false;
2126
- }
2428
+ const produced = viaAgent === 'unavailable' ? await produceChangeBuiltIn(run) : viaAgent;
2429
+ if (!produced)
2430
+ return false;
2431
+ const modifiedFiles = produced.modifiedFiles;
2127
2432
  const commitFiles = Array.from(new Set([...carryOverFiles, ...modifiedFiles]));
2128
2433
  let rawDiff = '';
2129
2434
  try {
2130
2435
  rawDiff = (await exec('git', ['--literal-pathspecs', 'diff', '--', ...modifiedFiles], { cwd: projectPath, timeoutMs: 20000 })).stdout.trim();
2131
2436
  }
2132
2437
  catch { }
2133
- const baseSummary = editsRes.summary ||
2438
+ const baseSummary = produced.summary ||
2134
2439
  `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
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
2140
2446
  : `1. Check the live update for "${card.title}".\n2. Click **Approve & Deploy to Done** when verified.`);
2141
2447
  const cardState = (stage, extra = {}) => ({
2142
2448
  stage,
@@ -2155,7 +2461,9 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2155
2461
  let deployedNote = '';
2156
2462
  // From here on the edits are owned by the card's saved state (deploy / approval), not by this run's revert safety net
2157
2463
  editsHandedOff = true;
2464
+ rollback.fn = null;
2158
2465
  clearEditJournal(card.id);
2466
+ clearBaseline(card.id);
2159
2467
  if (deployStage === 'review') {
2160
2468
  // Persist first: if this process dies mid-deploy, the re-queued card resumes with the deployment only
2161
2469
  setCardState(projectPath, card.id, cardState('deploy_failed', { last_error: 'The deployment was interrupted before it finished.' }));
@@ -2209,7 +2517,8 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2209
2517
  const logLines = [
2210
2518
  `Claimed card "${card.title}" (${card.id}) in ${projectPath}`,
2211
2519
  `Agent: ${workerLabel} | Model: ${card.agent_model || 'auto'}`,
2212
- `Selected files: ${Object.keys(filesPayload).join(', ')}`,
2520
+ `Engine: ${produced.engine}`,
2521
+ ...(produced.inspectedFiles.length > 0 ? [`Selected files: ${produced.inspectedFiles.join(', ')}`] : []),
2213
2522
  `Modified & verified files: ${modifiedFiles.join(', ')}`,
2214
2523
  `Deployment route: ${profile.method} (${profile.confidence} confidence, ${profile.source})`,
2215
2524
  `Deployment: ${deployedNote}`,
@@ -2232,13 +2541,13 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2232
2541
  });
2233
2542
  writeTaskLock(lockFile, card, 'completed');
2234
2543
  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 });
2544
+ log.info('card.review', { card: card.id, files: modifiedFiles.length, deploy: profile.method, engine: produced.engine });
2236
2545
  return true;
2237
2546
  }
2238
2547
  catch (err) {
2239
2548
  removeTaskLock(lockFile);
2240
2549
  // An interrupted run must not leave half-applied, uncommitted edits in the developer's working tree
2241
- const rolledBack = !editsHandedOff && originalContents.size > 0;
2550
+ let rolledBack = !editsHandedOff && originalContents.size > 0;
2242
2551
  if (rolledBack) {
2243
2552
  for (const [fPath, orig] of originalContents.entries()) {
2244
2553
  try {
@@ -2247,6 +2556,12 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2247
2556
  catch { }
2248
2557
  }
2249
2558
  }
2559
+ if (!editsHandedOff && rollback.fn) {
2560
+ try {
2561
+ rolledBack = (await rollback.fn()) || rolledBack;
2562
+ }
2563
+ catch { }
2564
+ }
2250
2565
  if (!editsHandedOff)
2251
2566
  clearEditJournal(card.id);
2252
2567
  log.error('card.failed', { card: card.id, project: projectPath, error: errorMessage(err), reverted: rolledBack });