@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 +5 -1
- package/dist/branch-health.d.ts +118 -0
- package/dist/branch-health.js +302 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/deploy-memory.d.ts +17 -0
- package/dist/deploy-memory.js +20 -0
- package/dist/deploy-runner.d.ts +9 -0
- package/dist/deploy-runner.js +43 -4
- package/dist/server.d.ts +3 -0
- package/dist/server.js +130 -9
- package/package.json +1 -1
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.
|
|
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.
|
|
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;
|
package/dist/deploy-memory.d.ts
CHANGED
|
@@ -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;
|
package/dist/deploy-memory.js
CHANGED
|
@@ -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);
|
package/dist/deploy-runner.d.ts
CHANGED
|
@@ -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.
|
package/dist/deploy-runner.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
:
|
|
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.
|
|
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",
|