@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/README.md +33 -0
- package/dist/agent-engine.d.ts +132 -0
- package/dist/agent-engine.js +517 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/server.d.ts +5 -1
- package/dist/server.js +588 -273
- package/package.json +1 -1
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
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
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
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
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 =
|
|
2438
|
+
const baseSummary = produced.summary ||
|
|
2134
2439
|
`Updated ${modifiedFiles.join(', ')} to complete "${card.title}".`;
|
|
2135
|
-
const plainSummary = baseSummary.replace(/```[\s\S]*?```/g, '').trim() + (
|
|
2136
|
-
const
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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 });
|