@agilemonster/mcp-server 1.0.26 → 1.0.27

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 CHANGED
@@ -178,13 +178,17 @@ What the runner guarantees:
178
178
  - **Verifies before claiming "Deployed"** — deploy workflow run for that commit, container state and health, script exit code, and a smoke check of a URL you provided. A guessed URL can never fail a deploy.
179
179
  - **Asks once, then remembers** — an unconfirmed deploy script, cloud CLI or manually-dispatched workflow is not run on a guess. Reply on the card with `deploy: <command or method>` to confirm; `retry` re-runs only the deployment, not the code change.
180
180
  - **Heals what it can** — starts Docker Desktop/OrbStack if it is installed but stopped, rebases and re-pushes when the remote moved ahead, retries network errors, and falls back to the next route when the chosen one plainly does not apply.
181
+ - **Checks the branch before it starts** — fetches, and fast-forwards a folder that is only behind (Git refuses if that would touch an uncommitted edit of yours). Unpushed commits that fit are replayed on top. If unpushed commits clash with the remote, the card asks first, in plain words, instead of letting an agent work for minutes on something that cannot be pushed.
182
+ - **A refused push comes with ways out** — the card says what clashes (this card, or older commits that never left this computer) and offers **Ship only this change** or **Redo on the latest version**. Anything moved out of the way is kept on an `agilemonster/backup-…` branch; nothing is deleted. If you push the change yourself, `retry` only checks the deployment: no second commit, no second deploy.
181
183
  - **Never reports a slow pipeline as failed** — a CI run that outlives the wait is marked pending and re-checked on later pulses; the outcome is posted on the card.
182
184
 
183
185
  State lives outside your repositories: `~/.agilemonster/projects/` (per-project deployment history) and
184
186
  `~/.agilemonster/logs/daemon.jsonl` (structured daemon log).
185
187
 
186
188
  When the runner asks a question, the card shows the answers it accepts as buttons, so nobody has to type
187
- these. They still work as typed replies: `retry` · `deploy: github_actions` · `deploy: docker_compose` · `deploy: manual` · `deploy: ./deploy/deploy.sh` · `live url: https://example.com`
189
+ these. They still work as typed replies: `retry` · `deploy: github_actions` · `deploy: docker_compose` · `deploy: manual` · `deploy: ./deploy/deploy.sh` · `live url: https://example.com` · `deploy: only this change` · `branch: start fresh`
190
+
191
+ Replies that decide what runs or what moves on your computer (`deploy: …`, `branch: start fresh`) are only taken from the person whose account the runner uses.
188
192
 
189
193
  ---
190
194
 
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Is the project folder in a state where work can be built and pushed?
3
+ *
4
+ * A card used to fail only at the very end: the agent worked for minutes on a branch that was behind its remote,
5
+ * and the push then stopped on a conflict that had nothing to do with the card. This module looks BEFORE the
6
+ * agent starts, brings the folder up to date when that is safe, and otherwise says in plain words what is in
7
+ * the way and what the owner can choose.
8
+ *
9
+ * Nothing here ever discards work: commits that are moved out of the way stay on a backup branch.
10
+ */
11
+ export interface UnpushedCommit {
12
+ sha: string;
13
+ subject: string;
14
+ /** YYYY-MM-DD */
15
+ date: string;
16
+ }
17
+ export interface BranchHealth {
18
+ isRepo: boolean;
19
+ /** Empty when HEAD is detached. */
20
+ branch: string;
21
+ remote: string;
22
+ /** e.g. `origin/main`. Empty when this branch does not exist on the remote (or there is no remote). */
23
+ upstream: string;
24
+ /** Where the remote lives, in words for a card: "GitHub", "GitLab", "Bitbucket" or "the remote". */
25
+ remoteLabel: string;
26
+ /** The remote answered just now, so `ahead` / `behind` are current. */
27
+ fetched: boolean;
28
+ ahead: number;
29
+ behind: number;
30
+ /** Tracked files with uncommitted changes. (Untracked files are no obstacle to updating.) */
31
+ dirty: boolean;
32
+ /** Commits on this computer that were never pushed, newest first. */
33
+ unpushed: UnpushedCommit[];
34
+ /** Only when both sides moved: do the unpushed commits fit on top of the remote? null = could not tell. */
35
+ fitsOnRemote: boolean | null;
36
+ conflictFiles: string[];
37
+ }
38
+ export declare function remoteLabelFor(url: string): string;
39
+ export declare function inspectBranch(cwd: string, options?: {
40
+ fetch?: boolean;
41
+ }): Promise<BranchHealth>;
42
+ export type BranchPreparation = {
43
+ status: 'ready';
44
+ health: BranchHealth;
45
+ } | {
46
+ status: 'updated';
47
+ health: BranchHealth;
48
+ note: string;
49
+ } | {
50
+ status: 'blocked';
51
+ health: BranchHealth;
52
+ };
53
+ /**
54
+ * Bring the folder up to date with its remote when that cannot hurt anything:
55
+ * - only behind: fast-forward (Git itself refuses if that would touch an uncommitted edit);
56
+ * - both sides moved and the unpushed commits fit: replay them on top, but only in a clean tree;
57
+ * - both sides moved and they clash: 'blocked' — a person has to choose, before any work is built on it.
58
+ * When the remote cannot be reached, nothing is decided on stale information.
59
+ */
60
+ export declare function prepareBranch(cwd: string): Promise<BranchPreparation>;
61
+ /** The reply that picks "start from the remote's version". */
62
+ export declare function parseBranchDirective(text: string): 'fresh' | null;
63
+ export declare const START_FRESH_REPLY = "branch: start fresh";
64
+ /** The question on the card when the folder is blocked before any work started. */
65
+ export declare function describeBlockedBranch(health: BranchHealth): {
66
+ question: string;
67
+ options: Array<{
68
+ label: string;
69
+ reply: string;
70
+ }>;
71
+ };
72
+ export interface SetAsideResult {
73
+ ok: boolean;
74
+ backup: string;
75
+ setAside: number;
76
+ error?: string;
77
+ }
78
+ /**
79
+ * "Start from the remote's version": keep every unpushed commit on a backup branch, then make the local branch
80
+ * equal to the remote. Refuses when there are uncommitted changes, which a reset would destroy.
81
+ */
82
+ export declare function startFromRemote(cwd: string): Promise<SetAsideResult>;
83
+ export interface PushConflict {
84
+ branch: string;
85
+ remoteLabel: string;
86
+ behind: number;
87
+ /** Unpushed commits that are NOT this card's (older work that never left this computer). */
88
+ older: UnpushedCommit[];
89
+ /** This card's commits, oldest first. */
90
+ cardCommits: string[];
91
+ conflictFiles: string[];
92
+ /** Does this card's change fit on the remote's version without the older commits? null = could not tell. */
93
+ cardFitsAlone: boolean | null;
94
+ }
95
+ export declare const cardTag: (cardId: string) => string;
96
+ /** Work out, after a refused push, what actually clashes — the card, or older commits that were never pushed. */
97
+ export declare function explainPushConflict(cwd: string, cardId: string, unmergedFiles?: string[]): Promise<PushConflict | null>;
98
+ export declare const ONLY_THIS_REPLY = "deploy: only this change";
99
+ /** Plain words for the card, the way out, and the buttons that take it. */
100
+ export declare function describePushConflict(c: PushConflict): {
101
+ detail: string;
102
+ hint: string;
103
+ options: Array<{
104
+ label: string;
105
+ reply: string;
106
+ }>;
107
+ };
108
+ export interface ShipOnlyResult {
109
+ ok: boolean;
110
+ backup: string;
111
+ setAside: number;
112
+ error?: string;
113
+ }
114
+ /**
115
+ * "Ship only this change": keep the whole local branch on a backup branch, make the branch equal to the remote,
116
+ * and put only this card's commits back on top. If they do not apply, everything is put back as it was.
117
+ */
118
+ export declare function shipOnlyCardCommits(cwd: string, cardId: string): Promise<ShipOnlyResult>;
@@ -0,0 +1,302 @@
1
+ import { exec, log } from './runtime.js';
2
+ const GIT_ENV = () => ({ ...process.env, GIT_TERMINAL_PROMPT: '0' });
3
+ const run = (cwd, args, timeoutMs = 45000) => exec('git', args, { cwd, timeoutMs, env: GIT_ENV() });
4
+ export function remoteLabelFor(url) {
5
+ if (/github\.com/i.test(url))
6
+ return 'GitHub';
7
+ if (/gitlab/i.test(url))
8
+ return 'GitLab';
9
+ if (/bitbucket/i.test(url))
10
+ return 'Bitbucket';
11
+ return 'the remote';
12
+ }
13
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
14
+ const fileList = (files) => {
15
+ const shown = files.slice(0, 3).map((f) => `\`${f}\``);
16
+ const more = files.length - shown.length;
17
+ return shown.join(', ') + (more > 0 ? ` and ${plural(more, 'other file')}` : '');
18
+ };
19
+ async function unpushedCommits(cwd, upstream) {
20
+ const out = await run(cwd, ['log', '--max-count=30', '--format=%h%x09%cs%x09%s', `${upstream}..HEAD`]);
21
+ if (!out.ok)
22
+ return [];
23
+ return out.stdout
24
+ .split(/\r?\n/)
25
+ .filter(Boolean)
26
+ .map((line) => {
27
+ const [sha, date, ...subject] = line.split('\t');
28
+ return { sha, date: date || '', subject: subject.join('\t') };
29
+ });
30
+ }
31
+ /**
32
+ * Would merging `theirs` into `ours` conflict? Asked of Git without touching the working tree
33
+ * (`git merge-tree --write-tree`, Git 2.38+). `base` replays a single range the way a cherry-pick would.
34
+ */
35
+ async function mergeFits(cwd, ours, theirs, base) {
36
+ const args = ['merge-tree', '--write-tree', '--name-only', '--no-messages'];
37
+ if (base)
38
+ args.push(`--merge-base=${base}`);
39
+ const out = await run(cwd, [...args, ours, theirs]);
40
+ if (out.code === 0)
41
+ return { fits: true, files: [] };
42
+ if (out.code === 1) {
43
+ // First line is the resulting tree; the conflicted paths follow
44
+ const files = out.stdout.split(/\r?\n/).slice(1).map((l) => l.trim()).filter(Boolean);
45
+ return { fits: false, files: Array.from(new Set(files)) };
46
+ }
47
+ return { fits: null, files: [] }; // an older Git: we simply do not know
48
+ }
49
+ export async function inspectBranch(cwd, options = {}) {
50
+ const health = {
51
+ isRepo: false,
52
+ branch: '',
53
+ remote: '',
54
+ upstream: '',
55
+ remoteLabel: 'the remote',
56
+ fetched: false,
57
+ ahead: 0,
58
+ behind: 0,
59
+ dirty: false,
60
+ unpushed: [],
61
+ fitsOnRemote: null,
62
+ conflictFiles: [],
63
+ };
64
+ health.isRepo = (await run(cwd, ['rev-parse', '--is-inside-work-tree'])).stdout.trim() === 'true';
65
+ if (!health.isRepo)
66
+ return health;
67
+ health.branch = (await run(cwd, ['branch', '--show-current'])).stdout.trim();
68
+ health.dirty = (await run(cwd, ['status', '--porcelain', '--untracked-files=no'])).stdout.trim().length > 0;
69
+ if (!health.branch)
70
+ return health;
71
+ const remotes = (await run(cwd, ['remote'])).stdout.split(/\s+/).filter(Boolean);
72
+ if (remotes.length === 0)
73
+ return health;
74
+ const configured = (await run(cwd, ['config', '--get', `branch.${health.branch}.remote`])).stdout.trim();
75
+ health.remote = configured && remotes.includes(configured) ? configured : remotes.includes('origin') ? 'origin' : remotes[0];
76
+ health.remoteLabel = remoteLabelFor((await run(cwd, ['remote', 'get-url', health.remote])).stdout.trim());
77
+ if (options.fetch !== false) {
78
+ health.fetched = (await run(cwd, ['fetch', health.remote, health.branch], 60000)).ok;
79
+ }
80
+ const ref = `${health.remote}/${health.branch}`;
81
+ if (!(await run(cwd, ['rev-parse', '--verify', '--quiet', `refs/remotes/${ref}`])).ok)
82
+ return health;
83
+ health.upstream = ref;
84
+ const counts = (await run(cwd, ['rev-list', '--left-right', '--count', `HEAD...${ref}`])).stdout.trim().split(/\s+/);
85
+ health.ahead = Number(counts[0]) || 0;
86
+ health.behind = Number(counts[1]) || 0;
87
+ if (health.ahead > 0)
88
+ health.unpushed = await unpushedCommits(cwd, ref);
89
+ if (health.ahead > 0 && health.behind > 0) {
90
+ const merge = await mergeFits(cwd, ref, 'HEAD');
91
+ health.fitsOnRemote = merge.fits;
92
+ health.conflictFiles = merge.files;
93
+ }
94
+ return health;
95
+ }
96
+ /**
97
+ * Bring the folder up to date with its remote when that cannot hurt anything:
98
+ * - only behind: fast-forward (Git itself refuses if that would touch an uncommitted edit);
99
+ * - both sides moved and the unpushed commits fit: replay them on top, but only in a clean tree;
100
+ * - both sides moved and they clash: 'blocked' — a person has to choose, before any work is built on it.
101
+ * When the remote cannot be reached, nothing is decided on stale information.
102
+ */
103
+ export async function prepareBranch(cwd) {
104
+ const health = await inspectBranch(cwd);
105
+ if (!health.isRepo || !health.branch || !health.upstream || !health.fetched || health.behind === 0) {
106
+ return { status: 'ready', health };
107
+ }
108
+ const newer = plural(health.behind, 'newer commit');
109
+ if (health.ahead === 0) {
110
+ const ff = await run(cwd, ['merge', '--ff-only', health.upstream], 120000);
111
+ if (ff.ok) {
112
+ return { status: 'updated', health, note: `Before starting, I brought this project folder up to date with ${health.remoteLabel} (${newer} on \`${health.branch}\`).` };
113
+ }
114
+ // An uncommitted edit of the developer's is in the way of the update: leave their folder exactly as it is
115
+ log.info('branch.fast_forward_skipped', { project: cwd, branch: health.branch, behind: health.behind, reason: ff.tail.slice(0, 200) });
116
+ return { status: 'ready', health };
117
+ }
118
+ if (health.fitsOnRemote === false)
119
+ return { status: 'blocked', health };
120
+ if (health.fitsOnRemote === null || health.dirty)
121
+ return { status: 'ready', health };
122
+ const rebase = await run(cwd, ['rebase', health.upstream], 120000);
123
+ if (rebase.ok) {
124
+ return {
125
+ status: 'updated',
126
+ health,
127
+ note: `Before starting, I brought this project folder up to date with ${health.remoteLabel} (${newer} on \`${health.branch}\`); the ${plural(health.ahead, 'commit')} that had not been pushed from this computer now sit on top.`,
128
+ };
129
+ }
130
+ const unmerged = (await run(cwd, ['diff', '--name-only', '--diff-filter=U'])).stdout.split(/\r?\n/).filter(Boolean);
131
+ await run(cwd, ['rebase', '--abort']);
132
+ return { status: 'blocked', health: { ...health, fitsOnRemote: false, conflictFiles: unmerged.length > 0 ? unmerged : health.conflictFiles } };
133
+ }
134
+ /** The reply that picks "start from the remote's version". */
135
+ export function parseBranchDirective(text) {
136
+ return /^\s*branch\s*[:=]\s*`?(start(ing)? fresh|fresh|(start from|use) (the )?(github|gitlab|bitbucket|remote|latest) version)`?[\s.!]*$/im.test(text || '')
137
+ ? 'fresh'
138
+ : null;
139
+ }
140
+ export const START_FRESH_REPLY = 'branch: start fresh';
141
+ function commitLines(commits) {
142
+ const shown = commits.slice(0, 5).map((c) => ` - \`${c.sha}\` ${c.subject.slice(0, 90)}${c.date ? ` (${c.date})` : ''}`);
143
+ if (commits.length > shown.length)
144
+ shown.push(` - and ${plural(commits.length - shown.length, 'more commit')}`);
145
+ return shown;
146
+ }
147
+ /** The question on the card when the folder is blocked before any work started. */
148
+ export function describeBlockedBranch(health) {
149
+ const where = health.remoteLabel;
150
+ const lines = [
151
+ 'I have not started on this card yet: the project folder on this computer needs a decision first.',
152
+ '',
153
+ `- ${where === 'the remote' ? 'The remote' : where} has ${plural(health.behind, 'newer commit')} on \`${health.branch}\` that this computer does not have.`,
154
+ `- This computer has ${plural(health.ahead, 'commit')} that ${health.ahead === 1 ? 'was' : 'were'} never pushed:`,
155
+ ...commitLines(health.unpushed),
156
+ health.conflictFiles.length > 0
157
+ ? `- The two clash in ${fileList(health.conflictFiles)}, so anything I build here could not be pushed.`
158
+ : '- The two do not fit together, so anything I build here could not be pushed.',
159
+ '',
160
+ ];
161
+ if (health.dirty) {
162
+ lines.push('There are also uncommitted changes in the folder, so I will not move anything myself.', `Please sort out the branch (commit or put away the changes, then merge or drop the unpushed ${health.ahead === 1 ? 'commit' : 'commits'}) and reply **retry**. I will check again and then do the card.`);
163
+ return { question: lines.join('\n'), options: [{ label: 'I fixed it, check again', reply: 'retry' }] };
164
+ }
165
+ lines.push('You can choose:', `- **Start from the ${where === 'the remote' ? 'remote' : where} version**: I keep ${health.ahead === 1 ? 'that commit' : `those ${health.ahead} commits`} safe on a backup branch (nothing is deleted), bring the folder up to date, and then do this card.`, '- **Sort it out yourself** (merge or drop those commits), then reply **retry** and I will check again.');
166
+ return {
167
+ question: lines.join('\n'),
168
+ options: [
169
+ { label: `Start from the ${where === 'the remote' ? 'remote' : where} version`, reply: START_FRESH_REPLY },
170
+ { label: 'I fixed it, check again', reply: 'retry' },
171
+ ],
172
+ };
173
+ }
174
+ const backupName = (branch) => `agilemonster/backup-${branch.replace(/[^A-Za-z0-9._-]+/g, '-')}-${new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-')}`;
175
+ /**
176
+ * "Start from the remote's version": keep every unpushed commit on a backup branch, then make the local branch
177
+ * equal to the remote. Refuses when there are uncommitted changes, which a reset would destroy.
178
+ */
179
+ export async function startFromRemote(cwd) {
180
+ const health = await inspectBranch(cwd);
181
+ if (!health.upstream || !health.branch)
182
+ return { ok: false, backup: '', setAside: 0, error: 'this branch does not exist on the remote' };
183
+ if (health.dirty)
184
+ return { ok: false, backup: '', setAside: 0, error: 'there are uncommitted changes in the project folder' };
185
+ const backup = backupName(health.branch);
186
+ const saved = await run(cwd, ['branch', backup, 'HEAD']);
187
+ if (!saved.ok)
188
+ return { ok: false, backup: '', setAside: 0, error: `could not create the backup branch: ${saved.tail.slice(0, 200)}` };
189
+ const reset = await run(cwd, ['reset', '--hard', health.upstream], 120000);
190
+ if (!reset.ok)
191
+ return { ok: false, backup, setAside: 0, error: `could not update the folder: ${reset.tail.slice(0, 200)}` };
192
+ log.info('branch.started_fresh', { project: cwd, branch: health.branch, backup, set_aside: health.ahead });
193
+ return { ok: true, backup, setAside: health.ahead };
194
+ }
195
+ export const cardTag = (cardId) => `[AM-${cardId.slice(0, 8)}]`;
196
+ /** This card's commits, when they are the newest ones on the branch (the only shape that can be shipped alone). */
197
+ function cardCommitsOnTop(unpushed, cardId) {
198
+ const tag = cardTag(cardId);
199
+ const mine = [];
200
+ let i = 0;
201
+ while (i < unpushed.length && unpushed[i].subject.includes(tag))
202
+ mine.push(unpushed[i++].sha);
203
+ if (unpushed.slice(i).some((c) => c.subject.includes(tag)))
204
+ return null; // interleaved with other work
205
+ return mine.reverse();
206
+ }
207
+ /** Work out, after a refused push, what actually clashes — the card, or older commits that were never pushed. */
208
+ export async function explainPushConflict(cwd, cardId, unmergedFiles = []) {
209
+ const health = await inspectBranch(cwd, { fetch: false });
210
+ if (!health.upstream)
211
+ return null;
212
+ const mine = cardCommitsOnTop(health.unpushed, cardId);
213
+ const older = health.unpushed.filter((c) => !c.subject.includes(cardTag(cardId)));
214
+ let cardFitsAlone = null;
215
+ let files = unmergedFiles.length > 0 ? unmergedFiles : health.conflictFiles;
216
+ if (mine && mine.length > 0) {
217
+ if (older.length === 0) {
218
+ cardFitsAlone = false; // nothing else is unpushed: the card itself is what clashes
219
+ }
220
+ else {
221
+ const alone = await mergeFits(cwd, health.upstream, mine[mine.length - 1], `${mine[0]}^`);
222
+ cardFitsAlone = alone.fits;
223
+ if (alone.fits === false && alone.files.length > 0)
224
+ files = alone.files;
225
+ }
226
+ }
227
+ return {
228
+ branch: health.branch,
229
+ remoteLabel: health.remoteLabel,
230
+ behind: health.behind,
231
+ older,
232
+ cardCommits: mine || [],
233
+ conflictFiles: files,
234
+ cardFitsAlone,
235
+ };
236
+ }
237
+ export const ONLY_THIS_REPLY = 'deploy: only this change';
238
+ /** Plain words for the card, the way out, and the buttons that take it. */
239
+ export function describePushConflict(c) {
240
+ const where = c.remoteLabel;
241
+ const newer = `${where === 'the remote' ? 'The remote' : where} has ${plural(c.behind, 'newer commit')} on \`${c.branch}\``;
242
+ const inFiles = c.conflictFiles.length > 0 ? ` in ${fileList(c.conflictFiles)}` : '';
243
+ const fixed = { label: 'I fixed it, try again', reply: 'retry' };
244
+ const pushedYourself = 'If you sort out the branch or push it yourself, reply **retry**: I will not redo the work, only push what is missing and check the deployment.';
245
+ if (c.older.length > 0 && c.cardFitsAlone !== false) {
246
+ const olderText = c.older.length === 1 ? '1 older commit that was never pushed, and it clashes' : `${c.older.length} older commits that were never pushed, and they clash`;
247
+ return {
248
+ detail: `The change is made and committed on this computer, but it could not be pushed. ${newer}. This computer also holds ${olderText} with the newer ones${inFiles}.` +
249
+ (c.cardFitsAlone ? " This card's own change is not the problem: it fits on top of the newer version by itself." : ''),
250
+ hint: [
251
+ `- **Ship only this change**: I put just this card's commit on top of the ${where === 'the remote' ? 'remote' : where} version and deploy it. The ${plural(c.older.length, 'older commit')} ${c.older.length === 1 ? 'stays' : 'stay'} safe on a backup branch, not deployed.`,
252
+ `- ${pushedYourself}`,
253
+ ].join('\n'),
254
+ options: [{ label: 'Ship only this change', reply: ONLY_THIS_REPLY }, fixed],
255
+ };
256
+ }
257
+ return {
258
+ detail: `The change is made and committed on this computer, but it could not be pushed. ${newer}, and they changed the same lines as this card${inFiles}: my change was built on an older version of the project.`,
259
+ hint: [
260
+ '- **Redo on the latest version**: I keep what I did on a backup branch, bring the folder up to date, and do the card again.',
261
+ `- ${pushedYourself}`,
262
+ ].join('\n'),
263
+ options: [{ label: 'Redo on the latest version', reply: START_FRESH_REPLY }, fixed],
264
+ };
265
+ }
266
+ /**
267
+ * "Ship only this change": keep the whole local branch on a backup branch, make the branch equal to the remote,
268
+ * and put only this card's commits back on top. If they do not apply, everything is put back as it was.
269
+ */
270
+ export async function shipOnlyCardCommits(cwd, cardId) {
271
+ const none = { backup: '', setAside: 0 };
272
+ const health = await inspectBranch(cwd);
273
+ if (!health.upstream || !health.branch)
274
+ return { ok: false, ...none, error: 'this branch does not exist on the remote' };
275
+ if (health.dirty)
276
+ return { ok: false, ...none, error: 'there are uncommitted changes in the project folder' };
277
+ const mine = cardCommitsOnTop(health.unpushed, cardId);
278
+ if (!mine || mine.length === 0)
279
+ return { ok: false, ...none, error: "this card's commit is no longer the newest one on the branch" };
280
+ const setAside = health.unpushed.length - mine.length;
281
+ const original = (await run(cwd, ['rev-parse', 'HEAD'])).stdout.trim();
282
+ const backup = backupName(health.branch);
283
+ const saved = await run(cwd, ['branch', backup, 'HEAD']);
284
+ if (!saved.ok)
285
+ return { ok: false, ...none, error: `could not create the backup branch: ${saved.tail.slice(0, 200)}` };
286
+ const putBack = async (error) => {
287
+ await run(cwd, ['cherry-pick', '--abort']);
288
+ await run(cwd, ['reset', '--hard', original], 120000);
289
+ await run(cwd, ['branch', '-D', backup]);
290
+ return { ok: false, ...none, error };
291
+ };
292
+ const reset = await run(cwd, ['reset', '--hard', health.upstream], 120000);
293
+ if (!reset.ok)
294
+ return putBack(`could not update the folder: ${reset.tail.slice(0, 200)}`);
295
+ const pick = await run(cwd, ['cherry-pick', ...mine], 120000);
296
+ if (!pick.ok) {
297
+ const unmerged = (await run(cwd, ['diff', '--name-only', '--diff-filter=U'])).stdout.split(/\r?\n/).filter(Boolean);
298
+ return putBack(unmerged.length > 0 ? `this card's change itself clashes with the newer version in ${fileList(unmerged)}` : `this card's change did not apply on the newer version: ${pick.tail.slice(0, 200)}`);
299
+ }
300
+ log.info('branch.shipped_only_card', { project: cwd, branch: health.branch, backup, set_aside: setAside, picked: mine.length });
301
+ return { ok: true, backup, setAside };
302
+ }
package/dist/client.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { MachineReport } from './machine.js';
2
2
  import { ActiveClaim, AgileMonsterProjectConfig, AutonomousStepResponse, CardComment, DeployReport, Board, BoardAgentConfig, Card, ContextResponse, DiscoveredProject, PulseResponse } from './types.js';
3
- export declare const MCP_SERVER_VERSION = "1.0.26";
3
+ export declare const MCP_SERVER_VERSION = "1.0.27";
4
4
  export type QuestionOption = string | {
5
5
  label: string;
6
6
  reply: string;
package/dist/client.js CHANGED
@@ -4,7 +4,7 @@ import { execSync } from 'node:child_process';
4
4
  import { resolveDeployProfile } from './deploy-profile.js';
5
5
  import { loadMemory } from './deploy-memory.js';
6
6
  import { stripRemoteCredentials } from './redact.js';
7
- export const MCP_SERVER_VERSION = '1.0.26';
7
+ export const MCP_SERVER_VERSION = '1.0.27';
8
8
  /** An API call the server answered with an error status. `status` lets callers tell "refused" from "broken". */
9
9
  export class ApiError extends Error {
10
10
  status;
@@ -50,6 +50,11 @@ export interface CardDeployState {
50
50
  at: string;
51
51
  attempts: number;
52
52
  last_error?: string | null;
53
+ /**
54
+ * Where the last attempt stopped. 'git' = the change never reached the remote, so a retry only has to push it
55
+ * (or find that a person already did) — it must not add an empty "redeploy" commit.
56
+ */
57
+ failed_stage?: 'git' | 'deploy' | null;
53
58
  /** Set when the agent asked "may I deploy with X?": a plain "yes"/"retry" then confirms X. */
54
59
  pending_confirmation?: {
55
60
  method: string;
@@ -89,6 +94,13 @@ export interface ProjectMemory {
89
94
  method: string;
90
95
  verified: boolean;
91
96
  }>;
97
+ /**
98
+ * Cards that are waiting for the owner to decide what to do with a branch that cannot be pushed
99
+ * (asked BEFORE any work started). Only a reply written after `at` answers the question.
100
+ */
101
+ branch_questions: Record<string, {
102
+ at: string;
103
+ }>;
92
104
  }
93
105
  export declare function memoryFileFor(projectPath: string): string;
94
106
  /**
@@ -118,5 +130,10 @@ export declare function setExplicitProfile(projectPath: string, explicit: {
118
130
  }): ProjectMemory;
119
131
  export declare function setCardState(projectPath: string, cardId: string, state: CardDeployState | null): void;
120
132
  export declare function getCardState(projectPath: string, cardId: string): CardDeployState | null;
133
+ /** Remember (or, with null, forget) that this card is waiting for a decision about its branch. */
134
+ export declare function setBranchQuestion(projectPath: string, cardId: string, at: string | null): void;
135
+ export declare function getBranchQuestion(projectPath: string, cardId: string): {
136
+ at: string;
137
+ } | null;
121
138
  export declare function addPendingVerification(projectPath: string, pending: PendingVerification): void;
122
139
  export declare function removePendingVerification(projectPath: string, cardId: string): void;
@@ -71,6 +71,7 @@ export function loadMemory(projectPath) {
71
71
  pending: [],
72
72
  cards: {},
73
73
  deployed_cards: {},
74
+ branch_questions: {},
74
75
  };
75
76
  saveMemory(memory);
76
77
  return memory;
@@ -86,6 +87,7 @@ export function loadMemory(projectPath) {
86
87
  pending: Array.isArray(stored?.pending) ? stored.pending : [],
87
88
  cards: stored?.cards && typeof stored.cards === 'object' ? stored.cards : {},
88
89
  deployed_cards: stored?.deployed_cards && typeof stored.deployed_cards === 'object' ? stored.deployed_cards : {},
90
+ branch_questions: stored?.branch_questions && typeof stored.branch_questions === 'object' ? stored.branch_questions : {},
89
91
  };
90
92
  }
91
93
  export function saveMemory(memory) {
@@ -110,6 +112,10 @@ export function saveMemory(memory) {
110
112
  .slice(0, deployedIds.length - DEPLOYED_CARDS_LIMIT)
111
113
  .forEach((id) => delete memory.deployed_cards[id]);
112
114
  }
115
+ for (const id of Object.keys(memory.branch_questions)) {
116
+ if (Date.now() - (Date.parse(memory.branch_questions[id].at) || 0) > STALE_CARD_STATE_MS)
117
+ delete memory.branch_questions[id];
118
+ }
113
119
  memory.history = memory.history.slice(-HISTORY_LIMIT);
114
120
  try {
115
121
  writeJsonAtomic(memoryFileFor(memory.project_path), memory);
@@ -176,6 +182,20 @@ export function setCardState(projectPath, cardId, state) {
176
182
  export function getCardState(projectPath, cardId) {
177
183
  return loadMemory(projectPath).cards[cardId] || null;
178
184
  }
185
+ /** Remember (or, with null, forget) that this card is waiting for a decision about its branch. */
186
+ export function setBranchQuestion(projectPath, cardId, at) {
187
+ const memory = loadMemory(projectPath);
188
+ if (at)
189
+ memory.branch_questions[cardId] = { at };
190
+ else if (memory.branch_questions[cardId])
191
+ delete memory.branch_questions[cardId];
192
+ else
193
+ return;
194
+ saveMemory(memory);
195
+ }
196
+ export function getBranchQuestion(projectPath, cardId) {
197
+ return loadMemory(projectPath).branch_questions[cardId] || null;
198
+ }
179
199
  export function addPendingVerification(projectPath, pending) {
180
200
  const memory = loadMemory(projectPath);
181
201
  memory.pending = memory.pending.filter((p) => p.card_id !== pending.card_id);
@@ -1,5 +1,6 @@
1
1
  import { BoundProfile, DeployMethod, DeployProfile } from './deploy-profile.js';
2
2
  import { PendingVerification } from './deploy-memory.js';
3
+ import { PushConflict } from './branch-health.js';
3
4
  /**
4
5
  * Runs a project's deployment and proves it worked.
5
6
  *
@@ -39,6 +40,12 @@ export interface DeployResult {
39
40
  commitHash: string;
40
41
  fullSha: string;
41
42
  branch: string;
43
+ /** The commit reached the remote in this attempt (or was already there). */
44
+ pushed: boolean;
45
+ /** Where a failed attempt stopped: 'git' = before the change reached the remote; 'deploy' = after. */
46
+ stage: 'git' | 'deploy' | null;
47
+ /** A refused push, worked out: what clashes and which ways out exist. */
48
+ conflict?: PushConflict;
42
49
  deployedNote: string;
43
50
  errorDetail: string;
44
51
  errorKind: string | null;
@@ -59,6 +66,8 @@ export interface DeployDirective {
59
66
  method: DeployMethod | null;
60
67
  command: string | null;
61
68
  liveUrl: string | null;
69
+ /** "Ship only this change": leave older unpushed commits out and push just this card's commit. */
70
+ onlyThis?: boolean;
62
71
  }
63
72
  /**
64
73
  * Parse a human reply to a "deployment failed / may I deploy with X?" question.
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { composeArgs, isRunnableCommand, methodLabel, normalizeMethod, pushPathsMatch, requiresConfirmation, resolveDeployProfile, } from './deploy-profile.js';
5
5
  import { loadMemory, recordAttempt } from './deploy-memory.js';
6
+ import { describePushConflict, explainPushConflict } from './branch-health.js';
6
7
  import { ensureUserPath, exec, execShell, log, sleep, which } from './runtime.js';
7
8
  const FAILURE_TABLE = [
8
9
  [
@@ -69,6 +70,7 @@ export function classifyFailure(text) {
69
70
  }
70
71
  return { kind: 'unknown', hint: '', healable: false };
71
72
  }
73
+ const ONLY_THIS_RE = /^(ship |push |deploy )?(only|just) this( one)?( change| card| commit)?$/i;
72
74
  const AFFIRMATIVE_RE = /^(retry|re-?deploy|try again|deploy again|go ahead|fixed|yes|yep|ok|okay|confirm(ed)?|do it)[\s.!]*$/i;
73
75
  /** A directive value only counts as a command when it starts like one — prose after "Deployment:" never runs. */
74
76
  const COMMAND_START_RE = /^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:\.{1,2}\/|\/|~\/|(?:docker|docker-compose|make|npm|pnpm|yarn|npx|bun|bunx|node|deno|python3?|bash|sh|zsh|fly|flyctl|vercel|netlify|wrangler|firebase|gh|git|rsync|scp|ssh|ansible-playbook|kubectl|helm|terraform|cap|bundle|php|composer|gcloud|aws|az|heroku|task|just|cargo|go|dotnet|mvn|gradle|rake|fab|serverless|sls|cdk|pulumi)(?=\s|$))/;
@@ -91,7 +93,12 @@ export function parseDeployDirective(text) {
91
93
  if (directive) {
92
94
  const value = directive[1].replace(/^`+|`+$/g, '').trim();
93
95
  const asMethod = normalizeMethod(value);
94
- if (AFFIRMATIVE_RE.test(value)) {
96
+ if (ONLY_THIS_RE.test(value.replace(/[\s.!]+$/, ''))) {
97
+ out.onlyThis = true;
98
+ out.retry = true;
99
+ matched = true;
100
+ }
101
+ else if (AFFIRMATIVE_RE.test(value)) {
95
102
  // "deploy: yes" confirms; it is not a program called `yes`
96
103
  out.retry = true;
97
104
  matched = true;
@@ -298,12 +305,21 @@ async function commitAndPush(req, profile, pushPolicy) {
298
305
  push = await git(['push', state.remote, state.branch], 90000);
299
306
  }
300
307
  else {
308
+ const unmergedFiles = unmerged.split(/\r?\n/).filter(Boolean);
301
309
  await git(['rebase', '--abort']);
310
+ // The raw Git output is for the log. The card gets what clashes, in words, and the ways out.
311
+ log.warn('deploy.rebase_conflict', { card: req.cardId, project: cwd, branch: state.branch, files: unmergedFiles, output: pull.tail.slice(0, 600) });
312
+ if (unmergedFiles.length === 0)
313
+ return fail(pull, `Could not bring in the newer commits from ${state.remote}/${state.branch}`);
314
+ const conflict = await explainPushConflict(cwd, req.cardId, unmergedFiles);
302
315
  return {
303
316
  ...state,
304
317
  ok: false,
305
318
  errorKind: 'rebase_conflict',
306
- errorDetail: `The remote branch \`${state.branch}\` has new commits that conflict with this change, so it could not be pushed: ${pull.tail}`,
319
+ conflict: conflict || undefined,
320
+ errorDetail: conflict
321
+ ? describePushConflict(conflict).detail
322
+ : `The change is committed on this computer, but the remote branch \`${state.branch}\` has newer commits that clash with it in ${unmergedFiles.slice(0, 3).map((f) => `\`${f}\``).join(', ')}, so it could not be pushed.`,
307
323
  };
308
324
  }
309
325
  }
@@ -959,6 +975,8 @@ export async function runDeployment(req) {
959
975
  commitHash: (git?.fullSha || '').slice(0, 7),
960
976
  fullSha: git?.fullSha || '',
961
977
  branch: git?.branch || '',
978
+ pushed: Boolean(git?.pushed),
979
+ stage: null,
962
980
  deployedNote: '',
963
981
  errorDetail: '',
964
982
  errorKind: null,
@@ -983,7 +1001,14 @@ export async function runDeployment(req) {
983
1001
  const kind = git.errorKind || 'git';
984
1002
  recordAttempt(req.projectPath, { card_id: req.cardId, method: profile.method, command: profile.command, ok: false, kind, error: git.errorDetail, duration_ms: Date.now() - startedAt });
985
1003
  log.warn('deploy.git_failed', { card: req.cardId, project: req.projectPath, kind, error: git.errorDetail });
986
- return result({ errorDetail: git.errorDetail || 'Git failed', errorKind: kind, hint: classifyFailure(git.errorDetail || '').hint || HINTS[kind] || '', healed: git.healed }, git);
1004
+ return result({
1005
+ errorDetail: git.errorDetail || 'Git failed',
1006
+ errorKind: kind,
1007
+ stage: 'git',
1008
+ conflict: git.conflict,
1009
+ hint: git.conflict ? describePushConflict(git.conflict).hint : classifyFailure(git.errorDetail || '').hint || HINTS[kind] || '',
1010
+ healed: git.healed,
1011
+ }, git);
987
1012
  }
988
1013
  let step = await runMethod(req, profile, git, changedFiles);
989
1014
  const healed = [...git.healed, ...step.healed];
@@ -1019,6 +1044,7 @@ export async function runDeployment(req) {
1019
1044
  return result({
1020
1045
  errorDetail: step.errorDetail || 'Deployment failed',
1021
1046
  errorKind: kind,
1047
+ stage: 'deploy',
1022
1048
  hint: HINTS[kind] || classifyFailure(step.errorDetail || '').hint,
1023
1049
  actions: [...git.actions, ...step.actions],
1024
1050
  healed,
@@ -1035,7 +1061,7 @@ export async function runDeployment(req) {
1035
1061
  else if (trusted) {
1036
1062
  const detail = `The deployment ran, but ${profile.liveUrl} returned ${status === 0 ? 'no response' : `HTTP ${status}`}.`;
1037
1063
  recordAttempt(req.projectPath, { card_id: req.cardId, method: profile.method, command: profile.command, ok: false, kind: 'live_check_failed', error: detail, commit: git.fullSha.slice(0, 7), duration_ms: Date.now() - startedAt });
1038
- return result({ errorDetail: detail, errorKind: 'live_check_failed', hint: 'Check the site, then reply "retry".', actions, healed }, git);
1064
+ return result({ errorDetail: detail, errorKind: 'live_check_failed', stage: 'deploy', hint: 'Check the site, then reply "retry".', actions, healed }, git);
1039
1065
  }
1040
1066
  }
1041
1067
  // A human-pinned route is its own proof of intent; anything else only becomes "what worked" once verified
@@ -1097,6 +1123,19 @@ export function describeFailureForHuman(res) {
1097
1123
  `Or reply \`deploy: manual\` if you deploy this project yourself${others.length > 0 ? `, or pick another route I found: ${others.map((a) => `\`deploy: ${a.command || a.method}\``).join(', ')}` : ''}.`,
1098
1124
  ].join('\n');
1099
1125
  }
1126
+ if (res.conflict) {
1127
+ return [res.errorDetail, '', 'You can choose:', res.hint].join('\n');
1128
+ }
1129
+ if (res.stage === 'git') {
1130
+ // The change never reached the remote. Which deploy route was picked is beside the point here:
1131
+ // say what is in the way first, and keep Git's own words for whoever wants them.
1132
+ const text = res.hint
1133
+ ? [`The change is made, but Git stopped before it reached the remote. ${res.hint}`, '', `Technical detail: ${res.errorDetail.replace(/\s+/g, ' ').slice(0, 400)}`]
1134
+ : [res.errorDetail.slice(0, 900)];
1135
+ if (!/\bretry\b/i.test(text.join(' ')))
1136
+ text.push('', 'Reply **retry** once it is fixed: I will not redo the work, only push it and check the deployment.');
1137
+ return text.join('\n');
1138
+ }
1100
1139
  const lines = [`What failed: ${res.errorDetail.slice(0, 900)}`];
1101
1140
  if (res.hint)
1102
1141
  lines.push('', res.hint);
package/dist/server.d.ts CHANGED
@@ -2,6 +2,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { AgileMonsterClient, QuestionOption } from './client.js';
3
3
  import { ActiveClaim, Card } from './types.js';
4
4
  import { BoundProfile } from './deploy-profile.js';
5
+ import { PushConflict } from './branch-health.js';
5
6
  /**
6
7
  * The answers a deploy question accepts, as reply buttons: nobody should have to type `deploy: ./deploy.sh`.
7
8
  * A first-time route gets "yes, this way" and "I deploy it myself"; a failure gets "try again".
@@ -10,6 +11,8 @@ export declare function deployReplyOptions(res: {
10
11
  errorKind?: string | null;
11
12
  method?: string | null;
12
13
  command?: string | null;
14
+ stage?: 'git' | 'deploy' | null;
15
+ conflict?: PushConflict;
13
16
  }): QuestionOption[];
14
17
  export declare function createServer(client?: AgileMonsterClient): Server<{
15
18
  method: string;
package/dist/server.js CHANGED
@@ -13,7 +13,8 @@ import { managedRunnerDir, updateArgs } from './service.js';
13
13
  import { AGENT_GUIDELINES_MARKDOWN } from './guidelines.js';
14
14
  import { knownSecrets, redactResult } from './redact.js';
15
15
  import { describeProfile, methodLabel, normalizeMethod, resolveDeployProfile, trustBoundRoute, } from './deploy-profile.js';
16
- import { addPendingVerification, getCardState, loadMemory, recordAttempt, removePendingVerification, setCardState, setExplicitProfile, } from './deploy-memory.js';
16
+ import { addPendingVerification, getBranchQuestion, getCardState, loadMemory, recordAttempt, removePendingVerification, setBranchQuestion, setCardState, setExplicitProfile, } from './deploy-memory.js';
17
+ import { describeBlockedBranch, describePushConflict, parseBranchDirective, prepareBranch, shipOnlyCardCommits, startFromRemote, } from './branch-health.js';
17
18
  import { checkCiOnce, describeFailureForHuman, ensureLocalGitExcludes, parseDeployDirective, runDeployment, } from './deploy-runner.js';
18
19
  import { baselineFor, buildAgentPrompt, changesSince, clearBaseline, engineLabelFor, engineStatuses, markNewFilesForDiff, parseAgentReport, proseOf, resolveEngines, restoreWorkingTree, runEngine, } from './agent-engine.js';
19
20
  import { addWellKnownToolDirs, agileMonsterHome, configureLogger, ensureUserPath, errorMessage, exec, log, readJson, which, writeJsonAtomic, } from './runtime.js';
@@ -22,6 +23,11 @@ import { addWellKnownToolDirs, agileMonsterHome, configureLogger, ensureUserPath
22
23
  * A first-time route gets "yes, this way" and "I deploy it myself"; a failure gets "try again".
23
24
  */
24
25
  export function deployReplyOptions(res) {
26
+ // A refused push has real ways out; "try again" on its own could never succeed
27
+ if (res.conflict)
28
+ return describePushConflict(res.conflict).options;
29
+ if (res.stage === 'git')
30
+ return [{ label: 'I fixed it, try again', reply: 'retry' }];
25
31
  if (res.errorKind === 'needs_confirmation') {
26
32
  const route = (res.command || res.method || '').trim();
27
33
  const options = [];
@@ -1673,6 +1679,86 @@ function removeTaskLock(lockFile) {
1673
1679
  }
1674
1680
  catch { }
1675
1681
  }
1682
+ /**
1683
+ * Look at the project's branch before any work is built on it.
1684
+ *
1685
+ * 'go' the folder is fine (or was brought up to date, which is noted on the card)
1686
+ * 'fresh' the owner chose to start from the remote's version: unpushed commits are on a backup branch
1687
+ * 'asked' the branch cannot be pushed as it is, and the card now carries the question
1688
+ *
1689
+ * A check that cannot be made (no Git, no remote, offline) never holds a card up.
1690
+ */
1691
+ async function ensureBranchReady(client, card, projectPath, provider, workerLabel, lockFile, replyAfter) {
1692
+ let prep;
1693
+ try {
1694
+ prep = await prepareBranch(projectPath);
1695
+ }
1696
+ catch (err) {
1697
+ log.warn('branch.check_failed', { card: card.id, project: projectPath, error: errorMessage(err) });
1698
+ return 'go';
1699
+ }
1700
+ const pending = getBranchQuestion(projectPath, card.id);
1701
+ if (prep.status !== 'blocked') {
1702
+ if (pending)
1703
+ setBranchQuestion(projectPath, card.id, null);
1704
+ if (prep.status === 'updated') {
1705
+ log.info('branch.updated', { card: card.id, project: projectPath, branch: prep.health.branch, behind: prep.health.behind, ahead: prep.health.ahead });
1706
+ try {
1707
+ await client.addProgress(card.id, provider, prep.note);
1708
+ }
1709
+ catch { }
1710
+ }
1711
+ return 'go';
1712
+ }
1713
+ const health = prep.health;
1714
+ log.info('branch.blocked', { card: card.id, project: projectPath, branch: health.branch, behind: health.behind, ahead: health.ahead, files: health.conflictFiles.slice(0, 5) });
1715
+ // Only a reply written after the question (or after the failed push that led here) is an answer to it
1716
+ const since = [pending?.at, replyAfter].filter((t) => Boolean(t)).sort().pop() || null;
1717
+ let reply = null;
1718
+ if (since) {
1719
+ try {
1720
+ const human = latestHumanComment(await client.listComments(card.id));
1721
+ reply = human && commentIsAfter(human, since) ? human : null;
1722
+ }
1723
+ catch (err) {
1724
+ log.warn('card.comments_unavailable', { card: card.id, error: errorMessage(err) });
1725
+ }
1726
+ }
1727
+ let prefix = '';
1728
+ if (reply && parseBranchDirective(reply.text) === 'fresh') {
1729
+ // Moving commits around in the project folder is for the person whose computer this is to decide
1730
+ const ownerId = await machineOwnerId(client);
1731
+ const authorId = reply.user_id ? String(reply.user_id) : '';
1732
+ if (!ownerId || !authorId || authorId !== ownerId) {
1733
+ prefix = 'Only the person whose computer runs this agent can decide what happens to the commits in its project folder. Please ask them to choose here.\n\n';
1734
+ }
1735
+ else {
1736
+ const fresh = await startFromRemote(projectPath);
1737
+ if (fresh.ok) {
1738
+ setBranchQuestion(projectPath, card.id, null);
1739
+ try {
1740
+ await client.addProgress(card.id, provider, `Done: the ${fresh.setAside === 1 ? 'unpushed commit is' : `${fresh.setAside} unpushed commits are`} safe on the branch \`${fresh.backup}\`, and the project folder now matches ${health.remoteLabel}. Files that only ${fresh.setAside === 1 ? 'that commit' : 'those commits'} added are on that branch too, and no longer in the folder. Starting on the card.`);
1741
+ }
1742
+ catch { }
1743
+ return 'fresh';
1744
+ }
1745
+ prefix = `I could not do that: ${fresh.error}. Nothing was moved.\n\n`;
1746
+ }
1747
+ }
1748
+ const { question, options } = describeBlockedBranch(health);
1749
+ removeTaskLock(lockFile);
1750
+ setBranchQuestion(projectPath, card.id, new Date().toISOString());
1751
+ appendIdeActivityLog(projectPath, {
1752
+ cardId: card.id,
1753
+ cardTitle: card.title,
1754
+ agentName: workerLabel,
1755
+ model: card.agent_model || 'auto',
1756
+ status: 'waiting_for_input',
1757
+ summary: `Not started: \`${health.branch}\` is ${health.behind} behind and ${health.ahead} ahead of ${health.remoteLabel}, and the two clash.`,
1758
+ });
1759
+ await client.askQuestion(card.id, provider, prefix + question, options);
1760
+ return 'asked';
1761
+ }
1676
1762
  /**
1677
1763
  * The code change for this card already exists — only its deployment failed. When the human replies
1678
1764
  * ("retry", or "deploy: <method or command>"), redo ONLY the deployment. Previously the whole card was
@@ -1695,7 +1781,8 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1695
1781
  if (reply && !parsed)
1696
1782
  return 'continue';
1697
1783
  const pendingRoute = state.pending_confirmation || null;
1698
- const choosesRoute = Boolean(parsed && (parsed.method || parsed.liveUrl || pendingRoute));
1784
+ // (so does "ship only this change", which moves commits around in their project folder)
1785
+ const choosesRoute = Boolean(parsed && (parsed.method || parsed.liveUrl || parsed.onlyThis || pendingRoute));
1699
1786
  if (choosesRoute) {
1700
1787
  // Choosing or confirming a route decides what is executed on THIS computer, so it has to come from the
1701
1788
  // person whose account this machine runs under — not from anyone who can comment on the board.
@@ -1733,15 +1820,33 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1733
1820
  set_by: 'the machine owner',
1734
1821
  });
1735
1822
  }
1736
- await client.addProgress(card.id, provider, directive.method
1737
- ? `Got it — deploying with **${methodLabel(directive.method)}**${directive.command ? ` (\`${directive.command}\`)` : ''}. I've saved this as the deployment route for this project, so future cards use it automatically.`
1738
- : 'Retrying the deployment of the change I already made (no code is being regenerated).');
1823
+ // "Ship only this change": older unpushed commits go to a backup branch, this card's commit goes on top of the remote
1824
+ let onlyThisNote = '';
1825
+ if (directive.onlyThis) {
1826
+ const only = await shipOnlyCardCommits(projectPath, card.id);
1827
+ if (!only.ok) {
1828
+ removeTaskLock(lockFile);
1829
+ setCardState(projectPath, card.id, { ...state, at: new Date().toISOString() });
1830
+ await client.askQuestion(card.id, provider, `I could not ship only this card's change: ${only.error}. Nothing was moved; the project folder is exactly as it was.\n\nIf you sort out the branch or push it yourself, reply **retry**: I will not redo the work, only push what is missing and check the deployment.`, [{ label: 'I fixed it, try again', reply: 'retry' }]);
1831
+ return 'failed';
1832
+ }
1833
+ onlyThisNote =
1834
+ `Putting only this card's change on top of the newer version and deploying it.` +
1835
+ (only.setAside > 0 ? ` The ${only.setAside === 1 ? 'older commit is' : `${only.setAside} older commits are`} safe on the branch \`${only.backup}\` and ${only.setAside === 1 ? 'is' : 'are'} not deployed. Files that only ${only.setAside === 1 ? 'that commit' : 'those commits'} added are on that branch too, and no longer in the folder.` : '');
1836
+ }
1837
+ await client.addProgress(card.id, provider, onlyThisNote
1838
+ ? onlyThisNote
1839
+ : directive.method
1840
+ ? `Got it — deploying with **${methodLabel(directive.method)}**${directive.command ? ` (\`${directive.command}\`)` : ''}. I've saved this as the deployment route for this project, so future cards use it automatically.`
1841
+ : 'Retrying the deployment of the change I already made (no code is being regenerated).');
1739
1842
  const res = await runDeployment({
1740
1843
  projectPath,
1741
1844
  title: card.title || 'update',
1742
1845
  cardId: card.id,
1743
1846
  files: state.files,
1744
- redeploy: true,
1847
+ // When the change never reached the remote, pushing it (or finding that a person already did) IS the retry:
1848
+ // an empty "trigger redeploy" commit would deploy production a second time for nothing.
1849
+ redeploy: state.failed_stage !== 'git',
1745
1850
  bound: boundProfileOf(card, await machineOwnerId(client)),
1746
1851
  override: directive.method ? { method: directive.method, command: directive.command } : null,
1747
1852
  });
@@ -1751,6 +1856,7 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1751
1856
  ...state,
1752
1857
  attempts: state.attempts + 1,
1753
1858
  last_error: res.errorDetail,
1859
+ failed_stage: res.stage,
1754
1860
  at: new Date().toISOString(),
1755
1861
  pending_confirmation: res.errorKind === 'needs_confirmation' ? { method: res.method, command: res.command } : null,
1756
1862
  });
@@ -1764,7 +1870,7 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1764
1870
  summary: `Deployment retry failed: ${res.errorDetail.slice(0, 500)}`,
1765
1871
  files: state.files,
1766
1872
  });
1767
- await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation'
1873
+ await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation' || res.stage === 'git'
1768
1874
  ? describeFailureForHuman(res)
1769
1875
  : `The deployment still did not go through, so this card stays in progress.\n\n${describeFailureForHuman(res)}`, deployReplyOptions(res));
1770
1876
  return 'failed';
@@ -2442,6 +2548,18 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2442
2548
  if (isRedeployOnlyCard(card)) {
2443
2549
  return await runRedeployOnlyCard(client, card, projectPath, provider, workerLabel, lockFile);
2444
2550
  }
2551
+ // 1c. Can work be built and pushed from this folder? Look BEFORE the agent spends minutes on a branch that is
2552
+ // behind its remote — and ask now, in plain words, if a person has to decide something first.
2553
+ let earlierSummary = priorState?.summary || null;
2554
+ const branchReady = await ensureBranchReady(client, card, projectPath, provider, workerLabel, lockFile, priorState?.at || null);
2555
+ if (branchReady === 'asked')
2556
+ return false;
2557
+ if (branchReady === 'fresh') {
2558
+ // The local commits (this card's earlier attempt included) are on a backup branch: start the card over
2559
+ carryOverFiles = [];
2560
+ earlierSummary = null;
2561
+ setCardState(projectPath, card.id, null);
2562
+ }
2445
2563
  const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card, await machineOwnerId(client)), memory: loadMemory(projectPath) });
2446
2564
  const ownerId = await machineOwnerId(client);
2447
2565
  const run = {
@@ -2456,7 +2574,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2456
2574
  originalContents,
2457
2575
  ownerId,
2458
2576
  conversation: await cardConversation(client, card, ownerId),
2459
- earlierSummary: priorState?.summary || null,
2577
+ earlierSummary,
2460
2578
  engineNotes: [],
2461
2579
  rollback,
2462
2580
  };
@@ -2520,6 +2638,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2520
2638
  setCardState(projectPath, card.id, cardState('deploy_failed', {
2521
2639
  commit: deployInfo.commitHash || null,
2522
2640
  last_error: deployInfo.errorDetail,
2641
+ failed_stage: deployInfo.stage,
2523
2642
  pending_confirmation: deployInfo.errorKind === 'needs_confirmation' ? { method: deployInfo.method, command: deployInfo.command } : null,
2524
2643
  }));
2525
2644
  removeTaskLock(lockFile);
@@ -2536,7 +2655,9 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2536
2655
  const changed = modifiedFiles.map((f) => `\`${f}\``).join(', ');
2537
2656
  await client.askQuestion(card.id, provider, deployInfo.errorKind === 'needs_confirmation'
2538
2657
  ? `I made the code change (${changed}). One question before I deploy this project for the first time:\n\n${describeFailureForHuman(deployInfo)}`
2539
- : `I made the code change (${changed}), but the deployment could not be verified, so I kept this card in progress instead of moving it to Testing.\n\n${describeFailureForHuman(deployInfo)}`, deployReplyOptions(deployInfo));
2658
+ : deployInfo.stage === 'git'
2659
+ ? `${describeFailureForHuman(deployInfo)}\n\nFiles I changed for this card: ${changed}.`
2660
+ : `I made the code change (${changed}), but the deployment could not be verified, so I kept this card in progress instead of moving it to Testing.\n\n${describeFailureForHuman(deployInfo)}`, deployReplyOptions(deployInfo));
2540
2661
  return false;
2541
2662
  }
2542
2663
  if (deployInfo.pending && deployInfo.pendingVerification) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilemonster/mcp-server",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "type": "module",
5
5
  "description": "Official Model Context Protocol (MCP) server & Orchestrator for Agile Monster (Cursor, Claude Code, Antigravity, Codex, Hermes, Windsurf)",
6
6
  "main": "dist/server.js",