agentmash 0.3.0
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/LICENSE +21 -0
- package/README.md +72 -0
- package/agentmash.mjs +1798 -0
- package/clients/cursor/after_file_edit.mjs +115 -0
- package/clients/git/post_commit.mjs +205 -0
- package/hooks/lib.mjs +1957 -0
- package/hooks/mcp_launcher.mjs +386 -0
- package/hooks/post_tool_use.mjs +78 -0
- package/hooks/pre_tool_use.mjs +191 -0
- package/hooks/session_end.mjs +76 -0
- package/hooks/stop.mjs +88 -0
- package/hooks/user_prompt_submit.mjs +65 -0
- package/mcp/coordination.mjs +126 -0
- package/mcp/render.mjs +358 -0
- package/mcp/server.mjs +452 -0
- package/package.json +52 -0
package/mcp/render.mjs
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// Turning coordination-server payloads into text an agent can act on.
|
|
2
|
+
//
|
|
3
|
+
// Two rules shape everything here:
|
|
4
|
+
//
|
|
5
|
+
// 1. An empty room and an unreachable server must never read the same. The
|
|
6
|
+
// first is an answer; the second is the absence of one, and saying
|
|
7
|
+
// "nobody is working on anything" when we simply could not ask is the
|
|
8
|
+
// single worst thing these tools could do.
|
|
9
|
+
// 2. The agent is the reader. Prose beats a row dump — it says who, what
|
|
10
|
+
// they are doing, on which branch, and what to do about it.
|
|
11
|
+
|
|
12
|
+
import { normalizePath } from './coordination.mjs';
|
|
13
|
+
|
|
14
|
+
const MAX_SESSIONS = 12;
|
|
15
|
+
const MAX_FILES_PER_SESSION = 8;
|
|
16
|
+
const MAX_CONTESTED = 20;
|
|
17
|
+
/** /activity caps its event list; past this we can't promise the window is complete. */
|
|
18
|
+
const EVENT_PAGE_SIZE = 150;
|
|
19
|
+
|
|
20
|
+
export function ago(iso) {
|
|
21
|
+
const then = Date.parse(iso);
|
|
22
|
+
if (Number.isNaN(then)) return 'recently';
|
|
23
|
+
const minutes = Math.max(0, Math.round((Date.now() - then) / 60_000));
|
|
24
|
+
if (minutes < 1) return 'just now';
|
|
25
|
+
if (minutes < 60) return `${minutes} min ago`;
|
|
26
|
+
const hours = Math.round(minutes / 60);
|
|
27
|
+
return hours < 24 ? `${hours} h ago` : `${Math.round(hours / 24)} d ago`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function windowText(minutes) {
|
|
31
|
+
return minutes < 120 ? `${minutes} min` : `${Math.round(minutes / 60)} hours`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function roomName(data, coordination) {
|
|
35
|
+
return data?.label ? `"${data.label}"` : `room ${coordination.room}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function truncationNote(data) {
|
|
39
|
+
return Array.isArray(data?.events) && data.events.length >= EVENT_PAGE_SIZE
|
|
40
|
+
? '\nThis room is busy enough that the server truncated its event list, so the picture may be partial.'
|
|
41
|
+
: '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What a tool says when it could not get an answer. Every branch states the
|
|
46
|
+
* failure, then states explicitly that the correct reading is "unknown".
|
|
47
|
+
*/
|
|
48
|
+
export function renderUnavailable(coordination, answer, subject) {
|
|
49
|
+
const unknown =
|
|
50
|
+
`Treat ${subject} as UNKNOWN. This is not the same as "nobody is working on anything" — ` +
|
|
51
|
+
'AgentMash could not ask. Carry on as you would without it, and be careful with files that look shared.';
|
|
52
|
+
|
|
53
|
+
switch (answer.reason) {
|
|
54
|
+
case 'disabled':
|
|
55
|
+
return (
|
|
56
|
+
'AgentMash is switched off in this environment (AGENTMASH_DISABLE=1), so no coordination data is ' +
|
|
57
|
+
`available.\n\n${unknown}`
|
|
58
|
+
);
|
|
59
|
+
case 'not_configured':
|
|
60
|
+
return (
|
|
61
|
+
'This repo is not connected to an AgentMash room: no room id in .claude/agentmash/config.json and no ' +
|
|
62
|
+
`AGENTMASH_ROOM in the environment.\n\n${unknown}\n\n` +
|
|
63
|
+
'Someone on the team can connect it with `npx agentmash init` (or `npx agentmash join <id>`), commit ' +
|
|
64
|
+
'.claude/, and everyone else picks it up on the next pull.'
|
|
65
|
+
);
|
|
66
|
+
case 'timeout':
|
|
67
|
+
return (
|
|
68
|
+
`The AgentMash coordination server at ${coordination.url} did not answer within ` +
|
|
69
|
+
`${coordination.timeoutMs} ms.\n\n${unknown}`
|
|
70
|
+
);
|
|
71
|
+
case 'unauthorized':
|
|
72
|
+
return (
|
|
73
|
+
`The AgentMash coordination server at ${coordination.url} does not recognise room ` +
|
|
74
|
+
`${coordination.room}. The room may have been reset, or this repo's config may point at the wrong ` +
|
|
75
|
+
`server.\n\n${unknown}\n\n\`npx agentmash doctor\` will say which.`
|
|
76
|
+
);
|
|
77
|
+
default:
|
|
78
|
+
return (
|
|
79
|
+
`The AgentMash coordination server at ${coordination.url} is unreachable ` +
|
|
80
|
+
`(${answer.detail}).\n\n${unknown}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function describeSession(session, coordination) {
|
|
86
|
+
const you = session.developer === coordination.developer ? ' (you)' : '';
|
|
87
|
+
const branch = session.git_branch ? ` · branch ${session.git_branch}` : '';
|
|
88
|
+
const ended = session.ended ? ' · session has ended' : '';
|
|
89
|
+
const lines = [`${session.developer}${you} — last edit ${ago(session.last_touched)}${branch}${ended}`];
|
|
90
|
+
if (session.task_hint) lines.push(` working on: "${session.task_hint}"`);
|
|
91
|
+
|
|
92
|
+
const files = Array.isArray(session.files) ? session.files : [];
|
|
93
|
+
if (files.length) {
|
|
94
|
+
const shown = files.slice(0, MAX_FILES_PER_SESSION).map((f) => {
|
|
95
|
+
return f.change_summary ? `${f.file_path} (${f.change_summary})` : f.file_path;
|
|
96
|
+
});
|
|
97
|
+
const more = files.length - shown.length;
|
|
98
|
+
lines.push(` touched: ${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}`);
|
|
99
|
+
}
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function renderWhoIsWorking(data, coordination, { minutes, includeSelf }) {
|
|
104
|
+
const all = Array.isArray(data?.sessions) ? data.sessions : [];
|
|
105
|
+
const sessions = includeSelf ? all : all.filter((s) => s.developer !== coordination.developer);
|
|
106
|
+
const room = roomName(data, coordination);
|
|
107
|
+
|
|
108
|
+
if (sessions.length === 0) {
|
|
109
|
+
return (
|
|
110
|
+
`Nobody${includeSelf ? '' : ' else'} has been active in ${room} in the last ${windowText(minutes)}. ` +
|
|
111
|
+
'The coordination server answered normally, so this is a real "no one", not a failure to reach it.\n\n' +
|
|
112
|
+
`You are ${coordination.developer}. Teammates who are idle, or who were working before this window, ` +
|
|
113
|
+
'will not appear — call this again with a larger `minutes` to look further back.'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const shown = sessions.slice(0, MAX_SESSIONS);
|
|
118
|
+
const header =
|
|
119
|
+
`${sessions.length} ${sessions.length === 1 ? 'person has' : 'people have'} been active in ${room} in the ` +
|
|
120
|
+
`last ${windowText(minutes)}` +
|
|
121
|
+
(includeSelf ? '.' : `, not counting you (${coordination.developer}).`);
|
|
122
|
+
|
|
123
|
+
const parts = [header, shown.map((s) => describeSession(s, coordination)).join('\n\n')];
|
|
124
|
+
if (sessions.length > shown.length) parts.push(`…and ${sessions.length - shown.length} more.`);
|
|
125
|
+
|
|
126
|
+
// Stated intent sits beside observed activity: a teammate who has claimed a
|
|
127
|
+
// module but not written to it yet is exactly who this answer exists to name.
|
|
128
|
+
const claims = (Array.isArray(data?.claims) ? data.claims : []).filter(
|
|
129
|
+
(c) => includeSelf || c.developer !== coordination.developer
|
|
130
|
+
);
|
|
131
|
+
if (claims.length) {
|
|
132
|
+
parts.push(
|
|
133
|
+
'Claimed right now (stated intent, not yet necessarily written):\n' +
|
|
134
|
+
claims
|
|
135
|
+
.slice(0, 8)
|
|
136
|
+
.map((c) => `- ${c.developer}: ${(c.paths || []).slice(0, 3).join(', ')} — "${c.task}"`)
|
|
137
|
+
.join('\n')
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const hot = Array.isArray(data?.hot_files) ? data.hot_files : [];
|
|
142
|
+
if (hot.length) {
|
|
143
|
+
const names = hot.slice(0, 5).map((h) => `${h.file_path} (${h.developers.join(', ')})`);
|
|
144
|
+
parts.push(`Already touched by more than one person: ${names.join('; ')}.`);
|
|
145
|
+
}
|
|
146
|
+
parts.push(
|
|
147
|
+
'Before you edit anything on this list, read what changed there first, and prefer work that does not ' +
|
|
148
|
+
'reshape the same interfaces.'
|
|
149
|
+
);
|
|
150
|
+
return parts.join('\n\n') + truncationNote(data);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Matches an exact file, or anything beneath a directory. */
|
|
154
|
+
function underPath(filePath, target) {
|
|
155
|
+
const file = normalizePath(filePath);
|
|
156
|
+
return file === target || file.startsWith(`${target}/`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function renderPathActivity(data, coordination, { target, minutes, includeSelf }) {
|
|
160
|
+
const all = Array.isArray(data?.sessions) ? data.sessions : [];
|
|
161
|
+
const room = roomName(data, coordination);
|
|
162
|
+
const hits = [];
|
|
163
|
+
for (const session of all) {
|
|
164
|
+
if (!includeSelf && session.developer === coordination.developer) continue;
|
|
165
|
+
const files = (Array.isArray(session.files) ? session.files : []).filter((f) =>
|
|
166
|
+
underPath(f.file_path, target)
|
|
167
|
+
);
|
|
168
|
+
if (files.length) hits.push({ session, files });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (hits.length === 0) {
|
|
172
|
+
return (
|
|
173
|
+
`Nobody${includeSelf ? '' : ' else'} has touched \`${target}\` in the last ${windowText(minutes)} ` +
|
|
174
|
+
`(${room}). The coordination server answered normally, so this is a real "no one".\n\n` +
|
|
175
|
+
'It is safe to plan work here on that basis, bearing in mind that only edits made through a ' +
|
|
176
|
+
"teammate's Claude Code agent are reported — hand edits and other tools are invisible to AgentMash."
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const people = hits.length === 1 ? '1 person has' : `${hits.length} people have`;
|
|
181
|
+
const parts = [`${people} touched \`${target}\` in the last ${windowText(minutes)} (${room}).`];
|
|
182
|
+
for (const { session, files } of hits) {
|
|
183
|
+
const you = session.developer === coordination.developer ? ' (you)' : '';
|
|
184
|
+
const branch = session.git_branch ? ` · branch ${session.git_branch}` : '';
|
|
185
|
+
const task = session.task_hint ? `\n working on: "${session.task_hint}"` : '';
|
|
186
|
+
const lines = files
|
|
187
|
+
.slice(0, MAX_FILES_PER_SESSION)
|
|
188
|
+
.map((f) => ` ${f.file_path} — ${ago(new Date(f.ts).toISOString())}${f.change_summary ? ` — ${f.change_summary}` : ''}`);
|
|
189
|
+
parts.push(`${session.developer}${you}${branch}${task}\n${lines.join('\n')}`);
|
|
190
|
+
}
|
|
191
|
+
parts.push(
|
|
192
|
+
'Read their changes before you edit these files, and say so in your plan if your work would collide ' +
|
|
193
|
+
'with what they are doing.'
|
|
194
|
+
);
|
|
195
|
+
return parts.join('\n\n') + truncationNote(data);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── claims ─────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
function minutesUntil(iso) {
|
|
201
|
+
const then = Date.parse(iso);
|
|
202
|
+
if (Number.isNaN(then)) return null;
|
|
203
|
+
return Math.max(0, Math.round((then - Date.now()) / 60_000));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function scopeText(paths, max = 4) {
|
|
207
|
+
const list = Array.isArray(paths) ? paths : [];
|
|
208
|
+
const shown = list.slice(0, max).join(', ');
|
|
209
|
+
return list.length > max ? `${shown} and ${list.length - max} more` : shown || 'this scope';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function claimLine(c, coordination) {
|
|
213
|
+
const you = c.developer === coordination.developer ? ' (you)' : '';
|
|
214
|
+
const symbols = c.symbols?.length ? ` · symbols ${c.symbols.slice(0, 4).join(', ')}` : '';
|
|
215
|
+
const left = minutesUntil(c.expires_at);
|
|
216
|
+
return (
|
|
217
|
+
`${c.developer}${you} — ${scopeText(c.paths)}${symbols}\n` +
|
|
218
|
+
` for: "${c.task}"\n` +
|
|
219
|
+
` claimed ${ago(c.created_at)}${left !== null ? `, ${left} min left` : ''}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** What claim_work says back. The overlap branch is the whole point of the tool. */
|
|
224
|
+
export function renderClaimResult(data, coordination, { paths, task }) {
|
|
225
|
+
const claim = data?.claim;
|
|
226
|
+
const overlaps = Array.isArray(data?.overlaps) ? data.overlaps : [];
|
|
227
|
+
const left = minutesUntil(claim?.expires_at);
|
|
228
|
+
const head =
|
|
229
|
+
`Claimed ${scopeText(paths)} for "${task}" as ${coordination.developer}` +
|
|
230
|
+
(claim?.id ? ` (id ${claim.id}${left !== null ? `, ${left} min` : ''}).` : '.');
|
|
231
|
+
|
|
232
|
+
if (overlaps.length === 0) {
|
|
233
|
+
return (
|
|
234
|
+
head +
|
|
235
|
+
'\n\nNo one else has claimed anything in this scope. Go ahead. Call release_claim when you finish — ' +
|
|
236
|
+
'otherwise it lapses on its own, and ends with your session.'
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const lines = overlaps.slice(0, 5).map((o) => {
|
|
241
|
+
const on = o.overlap?.paths?.length
|
|
242
|
+
? o.overlap.paths.join(', ')
|
|
243
|
+
: o.overlap?.symbols?.length
|
|
244
|
+
? `the symbols ${o.overlap.symbols.join(', ')}`
|
|
245
|
+
: 'this scope';
|
|
246
|
+
return `- ${o.developer} has ${on} for "${o.task}" (claimed ${ago(o.created_at)})`;
|
|
247
|
+
});
|
|
248
|
+
return (
|
|
249
|
+
head +
|
|
250
|
+
`\n\nOVERLAP — ${overlaps.length === 1 ? 'someone else has' : `${overlaps.length} people have`} already claimed part of this:\n` +
|
|
251
|
+
lines.join('\n') +
|
|
252
|
+
'\n\nYour claim stands, but split the work before you write anything: take the files and symbols they ' +
|
|
253
|
+
'have not claimed, or build on top of what they land rather than changing it. If your task genuinely needs ' +
|
|
254
|
+
'their scope, say so in your reply so the overlap is visible to both of you, and keep your edits out of the ' +
|
|
255
|
+
'parts they named.'
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function renderClaims(data, coordination, { includeSelf }) {
|
|
260
|
+
const all = Array.isArray(data?.claims) ? data.claims : [];
|
|
261
|
+
const claims = includeSelf ? all : all.filter((c) => c.developer !== coordination.developer);
|
|
262
|
+
if (claims.length === 0) {
|
|
263
|
+
return (
|
|
264
|
+
`Nothing is claimed right now${includeSelf ? '' : ' by anyone else'} in ${roomName(data, coordination)}. ` +
|
|
265
|
+
'The coordination server answered normally, so this is a real "nothing", not a failure to reach it.'
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return (
|
|
269
|
+
`${claims.length} live claim${claims.length === 1 ? '' : 's'}${includeSelf ? '' : ' by other people'}:\n\n` +
|
|
270
|
+
claims.slice(0, 20).map((c) => claimLine(c, coordination)).join('\n\n') +
|
|
271
|
+
'\n\nA claim is stated intent, not a lock: work outside these scopes where you can, and build on top of ' +
|
|
272
|
+
'them where you cannot.'
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── collisions ─────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* get_collisions: the same brief the Stop hook gives, phrased as an answer
|
|
280
|
+
* rather than an interruption. `brief` is the hooks' buildReconciliationBrief,
|
|
281
|
+
* passed in so this file stays free of hook imports.
|
|
282
|
+
*/
|
|
283
|
+
export function renderCollisions(data, coordination, { minutes, brief }) {
|
|
284
|
+
const collisions = Array.isArray(data?.collisions) ? data.collisions : [];
|
|
285
|
+
const mode = data?.reconcile_mode ?? 'approve';
|
|
286
|
+
if (collisions.length === 0) {
|
|
287
|
+
return (
|
|
288
|
+
`No unresolved collisions involving you (${coordination.developer}) in ${roomName(data, coordination)} in the ` +
|
|
289
|
+
`last ${windowText(minutes)}. The coordination server answered normally, so this is a real "none", not a ` +
|
|
290
|
+
'failure to reach it.'
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (mode === 'off') {
|
|
294
|
+
return (
|
|
295
|
+
`${collisions.length} file${collisions.length === 1 ? '' : 's'} you changed ${
|
|
296
|
+
collisions.length === 1 ? 'was' : 'were'
|
|
297
|
+
} also changed by a teammate: ${collisions.map((c) => c.file_path).join(', ')}. This room has reconciliation ` +
|
|
298
|
+
'switched off, so nothing will prompt you about it — but a merge is still coming, and reading their version ' +
|
|
299
|
+
'first is still the cheap option.'
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const heading =
|
|
303
|
+
`${collisions.length} unresolved collision${collisions.length === 1 ? '' : 's'} involving you in ` +
|
|
304
|
+
`${roomName(data, coordination)}:`;
|
|
305
|
+
return brief({ collisions, mode, showHints: true, heading }) ?? heading;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function renderResolved(data, { filePath, outcome, url }) {
|
|
309
|
+
const r = data?.resolution;
|
|
310
|
+
const how =
|
|
311
|
+
outcome === 'pr'
|
|
312
|
+
? `as a pull request${url ? ` (${url})` : ''}, waiting for a human to approve it`
|
|
313
|
+
: outcome === 'merged'
|
|
314
|
+
? 'as a direct merge'
|
|
315
|
+
: outcome === 'no_conflict'
|
|
316
|
+
? 'as not actually conflicting'
|
|
317
|
+
: 'as dismissed';
|
|
318
|
+
const who = Array.isArray(r?.developers) && r.developers.length ? ` between ${r.developers.join(' and ')}` : '';
|
|
319
|
+
return `Recorded: the collision on ${filePath}${who} is resolved ${how}. The room will not prompt about it again unless someone edits the file after this.`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function renderRelease(data, { claimId }) {
|
|
323
|
+
const n = Number(data?.released ?? 0);
|
|
324
|
+
if (claimId) {
|
|
325
|
+
return n > 0 ? `Released ${claimId}.` : `${claimId} was already released or has expired — nothing to do.`;
|
|
326
|
+
}
|
|
327
|
+
return n > 0
|
|
328
|
+
? `Released ${n} claim${n === 1 ? '' : 's'} held by this session.`
|
|
329
|
+
: 'This session holds no live claims — nothing to release.';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function renderContestedFiles(data, coordination, { minutes }) {
|
|
333
|
+
const hot = Array.isArray(data?.hot_files) ? data.hot_files : [];
|
|
334
|
+
const room = roomName(data, coordination);
|
|
335
|
+
|
|
336
|
+
if (hot.length === 0) {
|
|
337
|
+
return (
|
|
338
|
+
`No file in ${room} has been edited by more than one person in the last ${windowText(minutes)}. ` +
|
|
339
|
+
'The coordination server answered normally, so this is a real "none".\n\n' +
|
|
340
|
+
'That does not mean the room is idle — use who_is_working_on_what to see who is here.'
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const rows = hot.slice(0, MAX_CONTESTED).map((h) => {
|
|
345
|
+
const who = h.developers.map((d) => (d === coordination.developer ? `${d} (you)` : d));
|
|
346
|
+
return ` ${h.file_path} — ${who.join(', ')} — last edit ${ago(h.last_touched)}`;
|
|
347
|
+
});
|
|
348
|
+
const more = hot.length - rows.length;
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
`${hot.length} file${hot.length === 1 ? '' : 's'} in ${room} ${hot.length === 1 ? 'has' : 'have'} been ` +
|
|
352
|
+
`edited by more than one person in the last ${windowText(minutes)}:\n\n${rows.join('\n')}` +
|
|
353
|
+
(more > 0 ? `\n …and ${more} more.` : '') +
|
|
354
|
+
`\n\n${hot.length === 1 ? 'This is where' : 'These are where'} a collision is most likely. Read the ` +
|
|
355
|
+
'recent changes before editing them, and consider whether your task can avoid them entirely.' +
|
|
356
|
+
truncationNote(data)
|
|
357
|
+
);
|
|
358
|
+
}
|