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/hooks/lib.mjs
ADDED
|
@@ -0,0 +1,1957 @@
|
|
|
1
|
+
// Shared helpers for AgentMash hook scripts.
|
|
2
|
+
//
|
|
3
|
+
// Reliability contract (do not weaken):
|
|
4
|
+
// - every network call is capped by an AbortSignal, at the budget for its
|
|
5
|
+
// path (see ADVISORY_TIMEOUT_MS and REPORT_TIMEOUT_MS below)
|
|
6
|
+
// - no helper ever throws to the caller
|
|
7
|
+
// - nothing is written to stdout/stderr on the happy path
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
// Two budgets, because the hooks have two very different jobs.
|
|
16
|
+
//
|
|
17
|
+
// Both numbers are derived, and `npm run bench:budget` is where they come from.
|
|
18
|
+
// Do not raise or lower either one without re-running it.
|
|
19
|
+
//
|
|
20
|
+
// The advisory check is the only network call Claude Code waits on, so it is
|
|
21
|
+
// the only one a team feels. What it is waiting for is *not* the server: the
|
|
22
|
+
// bench puts findHolders at 0.1 ms on a 20k-event room and the whole response
|
|
23
|
+
// at ~500 bytes. It is waiting for a cold process to reach the server at all.
|
|
24
|
+
// Every hook is a fresh process, so each check pays three round trips before an
|
|
25
|
+
// answer can come back — TCP, then the TLS handshake, then the request — which
|
|
26
|
+
// the bench measures as roughly 110 ms + 3 x round trip. Hence:
|
|
27
|
+
//
|
|
28
|
+
// 500 ms delivers every advisory up to ~130 ms of round trip, and none at
|
|
29
|
+
// all past it: a team one ocean away would silently get nothing
|
|
30
|
+
// 1500 ms delivers every advisory up to ~460 ms of round trip
|
|
31
|
+
//
|
|
32
|
+
// 1500 ms is the default because it covers every network a team is likely to
|
|
33
|
+
// be on without anyone configuring anything. Past that the budget is sized to
|
|
34
|
+
// the network actually in front of it: `agentmash init` measures the round trip
|
|
35
|
+
// and writes `timeout_ms`. See budgetForRoundTrip, which is that derivation.
|
|
36
|
+
//
|
|
37
|
+
// Reporting hooks are registered with `async: true`, so Claude Code does not
|
|
38
|
+
// wait for them at all — their budget only bounds a stuck background process,
|
|
39
|
+
// and 2500 ms leaves room under Claude Code's own hook timeout.
|
|
40
|
+
//
|
|
41
|
+
// Note what actually protects you when the server is *down*: a refused
|
|
42
|
+
// connection fails in milliseconds, not at the timeout. These budgets only
|
|
43
|
+
// bite when a server is reachable but slow.
|
|
44
|
+
export const ADVISORY_TIMEOUT_MS = 1500;
|
|
45
|
+
export const REPORT_TIMEOUT_MS = 2500;
|
|
46
|
+
/** @deprecated kept so older vendored copies of the hooks keep working */
|
|
47
|
+
export const NETWORK_TIMEOUT_MS = ADVISORY_TIMEOUT_MS;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What one `/check` costs a cold hook against a server this far away. Three
|
|
51
|
+
* round trips, because a fresh process pays TCP, then TLS, then the request,
|
|
52
|
+
* plus the client-side setup `npm run bench:budget` measures at about 110 ms.
|
|
53
|
+
*
|
|
54
|
+
* This is the number to compare a budget against. A warm round trip — what
|
|
55
|
+
* `curl` or `agentmash doctor` sees — is about a third of it, and comparing a
|
|
56
|
+
* budget against *that* is how a config that drops every advisory passes a
|
|
57
|
+
* health check.
|
|
58
|
+
*/
|
|
59
|
+
export function coldCheckCostMs(roundTripMs) {
|
|
60
|
+
return 3 * roundTripMs + 110;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The largest advisory budget this hook can actually honour. The installed
|
|
65
|
+
* PreToolUse entry carries Claude Code's own `timeout: 5` (seconds) and the exit
|
|
66
|
+
* guard sits 250 ms past the budget, so anything larger is a promise the host
|
|
67
|
+
* cancels first. Past ~1.25 s of round trip there is no honest budget left —
|
|
68
|
+
* that is the point to move the server closer or turn the advisory off, not to
|
|
69
|
+
* write a bigger number.
|
|
70
|
+
*/
|
|
71
|
+
export const MAX_ADVISORY_TIMEOUT_MS = 4000;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The advisory budget a server this far away needs, rounded up to a readable
|
|
75
|
+
* 500 ms with a little slack for a jittery link. Never below the default, never
|
|
76
|
+
* above what the host will wait for.
|
|
77
|
+
*/
|
|
78
|
+
export function budgetForRoundTrip(roundTripMs) {
|
|
79
|
+
if (!Number.isFinite(roundTripMs) || roundTripMs <= 0) return ADVISORY_TIMEOUT_MS;
|
|
80
|
+
const needed = coldCheckCostMs(roundTripMs) + 140;
|
|
81
|
+
return Math.min(MAX_ADVISORY_TIMEOUT_MS, Math.max(ADVISORY_TIMEOUT_MS, Math.ceil(needed / 500) * 500));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read all of stdin; give up quietly after 2 s so a missing pipe can't hang the hook. */
|
|
85
|
+
export function readStdin() {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
let data = '';
|
|
88
|
+
const timer = setTimeout(() => resolve(data), 2000);
|
|
89
|
+
process.stdin.setEncoding('utf8');
|
|
90
|
+
process.stdin.on('data', (chunk) => {
|
|
91
|
+
data += chunk;
|
|
92
|
+
});
|
|
93
|
+
process.stdin.on('end', () => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
resolve(data);
|
|
96
|
+
});
|
|
97
|
+
process.stdin.on('error', () => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
resolve(data);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function parseJson(text) {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(text);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseDotEnv(file, into) {
|
|
113
|
+
let text;
|
|
114
|
+
try {
|
|
115
|
+
text = fs.readFileSync(file, 'utf8');
|
|
116
|
+
} catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
for (const line of text.split(/\r?\n/)) {
|
|
120
|
+
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
121
|
+
if (!m) continue;
|
|
122
|
+
const value = m[2].replace(/^(['"])(.*)\1$/, '$2');
|
|
123
|
+
if (into[m[1]] === undefined) into[m[1]] = value;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Config precedence, highest first: real environment variables,
|
|
129
|
+
* <repo>/.env, <repo>/.claude/agentmash/.env, then the committed
|
|
130
|
+
* <repo>/.claude/agentmash/config.json that `agentmash init` writes.
|
|
131
|
+
*
|
|
132
|
+
* The committed config is what makes teammate setup a `git pull` — the room
|
|
133
|
+
* travels with the repo, so nobody hand-writes a .env.
|
|
134
|
+
*/
|
|
135
|
+
export function loadConfig(projectDir) {
|
|
136
|
+
const vars = { ...process.env };
|
|
137
|
+
let file = {};
|
|
138
|
+
if (projectDir) {
|
|
139
|
+
parseDotEnv(path.join(projectDir, '.env'), vars);
|
|
140
|
+
parseDotEnv(path.join(projectDir, '.claude', 'agentmash', '.env'), vars);
|
|
141
|
+
try {
|
|
142
|
+
const raw = fs.readFileSync(path.join(projectDir, '.claude', 'agentmash', 'config.json'), 'utf8');
|
|
143
|
+
const parsed = JSON.parse(raw);
|
|
144
|
+
if (parsed && typeof parsed === 'object') file = parsed;
|
|
145
|
+
} catch {
|
|
146
|
+
// no committed config — env/.env may still supply everything
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const override = Number(vars.AGENTMASH_TIMEOUT_MS || file.timeout_ms);
|
|
150
|
+
const valid = Number.isFinite(override) && override > 0;
|
|
151
|
+
const url = (vars.AGENTMASH_URL || file.url || 'http://127.0.0.1:8787').replace(/\/+$/, '');
|
|
152
|
+
const roomRef = vars.AGENTMASH_ROOM_REF || file.room_ref || '';
|
|
153
|
+
return {
|
|
154
|
+
url,
|
|
155
|
+
room: vars.AGENTMASH_ROOM || file.room || '',
|
|
156
|
+
roomRef,
|
|
157
|
+
// Optional and absent by default: rooms that ask for it get a token from
|
|
158
|
+
// `agentmash login`, which stores it per machine, outside the repo.
|
|
159
|
+
token: vars.AGENTMASH_TOKEN || readDeveloperToken(url, roomRef),
|
|
160
|
+
dev: vars.AGENTMASH_DEV || '',
|
|
161
|
+
strict: vars.AGENTMASH_STRICT === '1' || (file.strict === true && vars.AGENTMASH_STRICT !== '0'),
|
|
162
|
+
disabled: vars.AGENTMASH_DISABLE === '1',
|
|
163
|
+
// Task hints are verbatim prompts, and every hook that renders one puts it
|
|
164
|
+
// in somebody else's agent's context. Off means this developer's prompts
|
|
165
|
+
// never leave the machine — not that they are hidden downstream.
|
|
166
|
+
shareHints: vars.AGENTMASH_HINTS !== '0' && !(file.share_hints === false && vars.AGENTMASH_HINTS !== '1'),
|
|
167
|
+
// Whether this developer will let their edits carry the changed text to
|
|
168
|
+
// the live view. Two switches must both be on: this one (per developer,
|
|
169
|
+
// default on) and the room's (per room, default off). Either side can say no.
|
|
170
|
+
streamDiffs: vars.AGENTMASH_DIFFS !== '0' && !(file.stream_diffs === false && vars.AGENTMASH_DIFFS !== '1'),
|
|
171
|
+
advisoryTimeoutMs: valid ? override : ADVISORY_TIMEOUT_MS,
|
|
172
|
+
reportTimeoutMs: valid ? Math.max(override, REPORT_TIMEOUT_MS) : REPORT_TIMEOUT_MS,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Per-machine credentials live outside the repo and are never committed. */
|
|
177
|
+
export function credentialsPath() {
|
|
178
|
+
const home = process.env.AGENTMASH_HOME || os.homedir();
|
|
179
|
+
return path.join(home, '.agentmash', 'credentials.json');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The developer token for this server+room, if this machine has one. Keyed by
|
|
184
|
+
* the room's non-secret ref, so rotating the room key does not orphan it.
|
|
185
|
+
*/
|
|
186
|
+
export function readDeveloperToken(url, roomRef) {
|
|
187
|
+
if (!roomRef) return '';
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'));
|
|
190
|
+
const entry = parsed?.tokens?.[`${url}|${roomRef}`];
|
|
191
|
+
return typeof entry?.token === 'string' ? entry.token : '';
|
|
192
|
+
} catch {
|
|
193
|
+
// no credentials file, unreadable, or malformed — the room may not need one
|
|
194
|
+
return '';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Identity. AGENTMASH_DEV wins over git config so two terminals on one
|
|
200
|
+
* machine (which share git config) can present as different developers.
|
|
201
|
+
*/
|
|
202
|
+
export function getDeveloper(config, cwd) {
|
|
203
|
+
if (config.dev) return config.dev;
|
|
204
|
+
try {
|
|
205
|
+
const email = execFileSync('git', ['config', 'user.email'], {
|
|
206
|
+
cwd: cwd || process.cwd(),
|
|
207
|
+
timeout: 400,
|
|
208
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
209
|
+
})
|
|
210
|
+
.toString()
|
|
211
|
+
.trim();
|
|
212
|
+
if (email) return email;
|
|
213
|
+
} catch {
|
|
214
|
+
// no git / no config — fall through
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
return os.userInfo().username;
|
|
218
|
+
} catch {
|
|
219
|
+
return 'unknown';
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Current branch by reading .git/HEAD directly — no subprocess, ~1 ms. */
|
|
224
|
+
export function getGitBranch(startDir) {
|
|
225
|
+
try {
|
|
226
|
+
let dir = startDir || process.cwd();
|
|
227
|
+
for (let i = 0; i < 40; i++) {
|
|
228
|
+
const dotGit = path.join(dir, '.git');
|
|
229
|
+
let gitDir = null;
|
|
230
|
+
const stat = fs.statSync(dotGit, { throwIfNoEntry: false });
|
|
231
|
+
if (stat?.isDirectory()) {
|
|
232
|
+
gitDir = dotGit;
|
|
233
|
+
} else if (stat?.isFile()) {
|
|
234
|
+
// worktree / submodule: .git is a file pointing at the real git dir
|
|
235
|
+
const m = fs.readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
|
|
236
|
+
if (m) gitDir = path.resolve(dir, m[1]);
|
|
237
|
+
}
|
|
238
|
+
if (gitDir) {
|
|
239
|
+
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
|
|
240
|
+
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
|
|
241
|
+
return ref ? ref[1] : head.slice(0, 7);
|
|
242
|
+
}
|
|
243
|
+
const parent = path.dirname(dir);
|
|
244
|
+
if (parent === dir) break;
|
|
245
|
+
dir = parent;
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// not a repo, unreadable HEAD, etc.
|
|
249
|
+
}
|
|
250
|
+
return '';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Repo-relative path with forward slashes, so the same file matches across
|
|
255
|
+
* machines with different checkouts. Files outside the project keep their
|
|
256
|
+
* absolute path.
|
|
257
|
+
*/
|
|
258
|
+
export function toRepoRelative(projectDir, filePath) {
|
|
259
|
+
if (!filePath) return null;
|
|
260
|
+
let result = filePath;
|
|
261
|
+
if (projectDir) {
|
|
262
|
+
const rel = path.relative(projectDir, filePath);
|
|
263
|
+
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) result = rel;
|
|
264
|
+
}
|
|
265
|
+
return result.replace(/\\/g, '/');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Per-session scratch lives in one temp directory, so nothing leaks into the repo. */
|
|
269
|
+
function scratchFile(name) {
|
|
270
|
+
return path.join(os.tmpdir(), 'agentmash', name);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function cacheFile(sessionId) {
|
|
274
|
+
const safe = String(sessionId || 'unknown').replace(/[^A-Za-z0-9._-]/g, '_');
|
|
275
|
+
return scratchFile(`${safe}.json`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Per-session scratch state: cached task hint + advisory cooldowns. */
|
|
279
|
+
export function readSessionCache(sessionId) {
|
|
280
|
+
try {
|
|
281
|
+
return JSON.parse(fs.readFileSync(cacheFile(sessionId), 'utf8')) || {};
|
|
282
|
+
} catch {
|
|
283
|
+
return {};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The directory the ack log lives in — the block message tells the agent to create it. */
|
|
288
|
+
export function scratchDir() {
|
|
289
|
+
return scratchFile('');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function writeSessionCache(sessionId, cache) {
|
|
293
|
+
try {
|
|
294
|
+
const file = cacheFile(sessionId);
|
|
295
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
296
|
+
fs.writeFileSync(file, JSON.stringify(cache));
|
|
297
|
+
} catch {
|
|
298
|
+
// a broken temp dir must not break the hook
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The session is over; its scratch file is dead weight in the temp dir. */
|
|
303
|
+
export function clearSessionCache(sessionId) {
|
|
304
|
+
try {
|
|
305
|
+
fs.rmSync(cacheFile(sessionId), { force: true });
|
|
306
|
+
} catch {
|
|
307
|
+
// best effort — a leftover file is harmless
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function truncate(text, max = 200) {
|
|
312
|
+
const clean = String(text).replace(/\s+/g, ' ').trim();
|
|
313
|
+
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Declaration keywords across the languages a mixed team is likely to share.
|
|
317
|
+
const DECLARATION =
|
|
318
|
+
/\b(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:public\s+|private\s+|protected\s+|static\s+)*(?:function|class|interface|type|enum|struct|trait|impl|def|fn|const|let|var)\s+([A-Za-z_$][\w$]*)/g;
|
|
319
|
+
|
|
320
|
+
function declaredNames(text) {
|
|
321
|
+
const names = new Set();
|
|
322
|
+
for (const match of String(text || '').matchAll(DECLARATION)) names.add(match[1]);
|
|
323
|
+
return names;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function countLines(text) {
|
|
327
|
+
if (!text) return 0;
|
|
328
|
+
return String(text).split('\n').length;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Per side, the most text a live diff carries. The server clips at the same bound. */
|
|
332
|
+
export const DIFF_CHARS = 4000;
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The changed text itself, for a room that streams diffs to its live view.
|
|
336
|
+
* Edit: what was replaced and what replaced it. MultiEdit: the first edit,
|
|
337
|
+
* plus how many there were. Write: the new content, no before. Clipped so
|
|
338
|
+
* a generated file does not become a 2 MB event; `truncated` says when.
|
|
339
|
+
* Null when the tool input has nothing textual to show.
|
|
340
|
+
*/
|
|
341
|
+
export function diffForEdit(toolName, toolInput, { maxChars = DIFF_CHARS } = {}) {
|
|
342
|
+
if (!toolInput || typeof toolInput !== 'object') return null;
|
|
343
|
+
let truncated = false;
|
|
344
|
+
const clip = (v) => {
|
|
345
|
+
if (typeof v !== 'string') return null;
|
|
346
|
+
if (v.length <= maxChars) return v;
|
|
347
|
+
truncated = true;
|
|
348
|
+
return v.slice(0, maxChars);
|
|
349
|
+
};
|
|
350
|
+
try {
|
|
351
|
+
if (toolName === 'Write') {
|
|
352
|
+
const after = clip(toolInput.content);
|
|
353
|
+
return after === null ? null : { before: null, after, truncated, edits: 1 };
|
|
354
|
+
}
|
|
355
|
+
const edits = Array.isArray(toolInput.edits) && toolInput.edits.length ? toolInput.edits : [toolInput];
|
|
356
|
+
const first = edits[0] || {};
|
|
357
|
+
const before = clip(first.old_string);
|
|
358
|
+
const after = clip(first.new_string);
|
|
359
|
+
if (before === null && after === null) return null;
|
|
360
|
+
return { before, after, truncated: truncated || edits.length > 1, edits: edits.length };
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* A compact, language-agnostic description of what an edit did — line counts
|
|
368
|
+
* plus any declarations the edit introduced. This is what turns an advisory
|
|
369
|
+
* from "someone touched this file" into "someone changed this interface".
|
|
370
|
+
*/
|
|
371
|
+
export function summarizeChange(toolName, toolInput) {
|
|
372
|
+
if (!toolInput) return null;
|
|
373
|
+
try {
|
|
374
|
+
if (toolName === 'Write') {
|
|
375
|
+
const lines = countLines(toolInput.content);
|
|
376
|
+
const names = [...declaredNames(toolInput.content)].slice(0, 3);
|
|
377
|
+
return `wrote ${lines} line${lines === 1 ? '' : 's'}${names.length ? `, defining ${names.join(', ')}` : ''}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const edits = Array.isArray(toolInput.edits)
|
|
381
|
+
? toolInput.edits
|
|
382
|
+
: [{ old_string: toolInput.old_string, new_string: toolInput.new_string }];
|
|
383
|
+
|
|
384
|
+
let added = 0;
|
|
385
|
+
let removed = 0;
|
|
386
|
+
const introduced = new Set();
|
|
387
|
+
for (const edit of edits) {
|
|
388
|
+
added += countLines(edit?.new_string);
|
|
389
|
+
removed += countLines(edit?.old_string);
|
|
390
|
+
const before = declaredNames(edit?.old_string);
|
|
391
|
+
for (const name of declaredNames(edit?.new_string)) {
|
|
392
|
+
if (!before.has(name)) introduced.add(name);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const scale = edits.length > 1 ? `${edits.length} edits, ` : '';
|
|
397
|
+
const names = [...introduced].slice(0, 3);
|
|
398
|
+
return `${scale}+${added}/-${removed} lines${names.length ? `, touching ${names.join(', ')}` : ''}`;
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Node races the addresses a hostname resolves to and gives each attempt 250 ms
|
|
406
|
+
* by default. A TCP connect to a server a continent away takes longer than that,
|
|
407
|
+
* so every attempt is abandoned, fetch rejects with ETIMEDOUT in ~300 ms, and the
|
|
408
|
+
* caller sees a dead server that curl reaches fine. Give each attempt half the
|
|
409
|
+
* request's own budget instead: a slow-but-working first address gets to finish,
|
|
410
|
+
* and a blackholed one still leaves the other half for the next family.
|
|
411
|
+
*/
|
|
412
|
+
export function widenConnectAttempts(timeoutMs) {
|
|
413
|
+
try {
|
|
414
|
+
net.setDefaultAutoSelectFamilyAttemptTimeout(Math.max(250, Math.floor(timeoutMs / 2)));
|
|
415
|
+
} catch {
|
|
416
|
+
// Node older than 18.18 has no such knob, and no such 250 ms default to undo
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* GET/POST JSON under the caller's budget — `timeoutMs`, or the advisory budget
|
|
422
|
+
* if none is given. Any failure — timeout, refused, non-2xx, bad JSON — returns
|
|
423
|
+
* null, and never throws.
|
|
424
|
+
*/
|
|
425
|
+
export async function httpJson(method, url, { room, token, body, timeoutMs } = {}) {
|
|
426
|
+
try {
|
|
427
|
+
widenConnectAttempts(timeoutMs || ADVISORY_TIMEOUT_MS);
|
|
428
|
+
const res = await fetch(url, {
|
|
429
|
+
method,
|
|
430
|
+
// `connection: close` keeps undici from parking a keep-alive socket that
|
|
431
|
+
// would otherwise hold this short-lived process open past its work.
|
|
432
|
+
// The developer token, when there is one, rides along on the request the
|
|
433
|
+
// hook was already making — authentication costs no extra round trip.
|
|
434
|
+
headers: {
|
|
435
|
+
'content-type': 'application/json',
|
|
436
|
+
'x-agentmash-room': room || '',
|
|
437
|
+
...(token ? { 'x-agentmash-token': token } : {}),
|
|
438
|
+
connection: 'close',
|
|
439
|
+
},
|
|
440
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
441
|
+
signal: AbortSignal.timeout(timeoutMs || ADVISORY_TIMEOUT_MS),
|
|
442
|
+
});
|
|
443
|
+
if (!res.ok) return null;
|
|
444
|
+
return await res.json();
|
|
445
|
+
} catch {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Safety net for a hook that hangs. Unref'd on purpose: it never keeps the
|
|
452
|
+
* process alive by itself, but it still fires if something else (a stuck
|
|
453
|
+
* socket) is holding the event loop open.
|
|
454
|
+
*/
|
|
455
|
+
export function armExitGuard(ms = 4000) {
|
|
456
|
+
const timer = setTimeout(() => process.exit(0), ms);
|
|
457
|
+
timer.unref?.();
|
|
458
|
+
return timer;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* End the hook successfully.
|
|
463
|
+
*
|
|
464
|
+
* Deliberately does NOT call process.exit(): tearing the process down while a
|
|
465
|
+
* TLS socket is still closing crashes Node on Windows (0xC0000409), and a
|
|
466
|
+
* crashed hook is a non-zero exit, which Claude Code surfaces as an error
|
|
467
|
+
* notice in the agent's transcript — exactly the noise these hooks promise
|
|
468
|
+
* never to make. Requests are sent with `connection: close`, so the loop
|
|
469
|
+
* drains on its own; armExitGuard covers the case where it doesn't.
|
|
470
|
+
*/
|
|
471
|
+
export function finish() {
|
|
472
|
+
process.exitCode = 0;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Print a hook JSON response and finish once stdout has flushed. */
|
|
476
|
+
export function emitAndExit(payload) {
|
|
477
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
478
|
+
finish();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── collision advice ────────────────────────────────────────────────────────
|
|
482
|
+
//
|
|
483
|
+
// Everything below is pure string work over the /check response the caller has
|
|
484
|
+
// already fetched: no network, no repo reads, no dependencies. It runs between
|
|
485
|
+
// an agent asking to edit a file and the edit happening, so it stays that cheap.
|
|
486
|
+
//
|
|
487
|
+
// The point of this code is not to announce that a file was touched. It is to
|
|
488
|
+
// change what the agent does next, which means every message has to answer
|
|
489
|
+
// three questions the old one-line advisory left open: what exactly changed,
|
|
490
|
+
// whether that change is in this agent's working tree, and what a good
|
|
491
|
+
// adaptation looks like without leaving the task.
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* A write this recent means my copy of the file is probably behind theirs.
|
|
495
|
+
*
|
|
496
|
+
* It used to double as "and they are still inside the file", which is a guess
|
|
497
|
+
* this hook has no business making on a timestamp alone. Since presence arrived
|
|
498
|
+
* that second question has its own answer and its own axis (see classify); this
|
|
499
|
+
* constant now only bounds the first one.
|
|
500
|
+
*/
|
|
501
|
+
const ACTIVE_MINUTES = 5;
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Weakest first — the index in this array *is* the severity, and it is also the
|
|
505
|
+
* order buildCollisionAdvice uses to pick which of several teammates gets the
|
|
506
|
+
* full paragraph.
|
|
507
|
+
*
|
|
508
|
+
* `stale` sits above `parallel` on purpose. A stale read fails inside this very
|
|
509
|
+
* edit and nothing catches it: the `old_string` either misses, or it matches
|
|
510
|
+
* and silently reverts a teammate's work into a diff nobody is reviewing yet. A
|
|
511
|
+
* parallel edit fails later, at a merge, where git and a human are both looking
|
|
512
|
+
* at it. Between "wrong now, unseen" and "wrong later, seen", the first one
|
|
513
|
+
* deserves the reader's attention first.
|
|
514
|
+
*/
|
|
515
|
+
export const ADVICE_LEVELS = ['nearby', 'recent', 'parallel', 'stale', 'active'];
|
|
516
|
+
|
|
517
|
+
/** How long one acknowledgement keeps covering edits to the same file. */
|
|
518
|
+
export const ACK_TTL_MS = 10 * 60_000;
|
|
519
|
+
|
|
520
|
+
/** Shorter than this is a reflex, not a judgement. */
|
|
521
|
+
export const ACK_MIN_REASON = 25;
|
|
522
|
+
|
|
523
|
+
/** Deliberately unmistakable, so "did you actually replace it" needs no heuristics. */
|
|
524
|
+
export const ACK_PLACEHOLDER = 'WHY-THIS-EDIT-STILL-NEEDS-TO-HAPPEN';
|
|
525
|
+
|
|
526
|
+
/** Stands in for a holder the server could not name. Grounds nothing — see ackGrounding. */
|
|
527
|
+
const ANON_DEVELOPER = 'a teammate';
|
|
528
|
+
|
|
529
|
+
/** Collapse whitespace, cap length, and turn anything non-string into ''. */
|
|
530
|
+
function clean(value, max) {
|
|
531
|
+
if (typeof value !== 'string') return '';
|
|
532
|
+
const text = value.replace(/\s+/g, ' ').trim();
|
|
533
|
+
if (!text) return '';
|
|
534
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Every name that reaches a sentence is a declaration name, so it looks like one. */
|
|
538
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Symbol names, from anywhere: the server's lists and our own re-parse of
|
|
542
|
+
* change_summary both land here.
|
|
543
|
+
*
|
|
544
|
+
* The identifier check is the load-bearing part. These names are interpolated
|
|
545
|
+
* into sentences that tell an agent what not to rename, so a value that is not
|
|
546
|
+
* a declaration name is not a weaker name — it is a wrong sentence, and the
|
|
547
|
+
* only safe thing to do with it is drop it.
|
|
548
|
+
*/
|
|
549
|
+
function nameList(values, max = 3) {
|
|
550
|
+
const names = [];
|
|
551
|
+
for (const value of Array.isArray(values) ? values : []) {
|
|
552
|
+
const name = clean(value, 60);
|
|
553
|
+
if (name && IDENTIFIER.test(name) && !names.includes(name)) names.push(name);
|
|
554
|
+
if (names.length >= max) break;
|
|
555
|
+
}
|
|
556
|
+
return names;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* "…, touching deletedAt, User" / "…, defining greet" — the names
|
|
561
|
+
* summarizeChange left behind, when the server has nothing better.
|
|
562
|
+
*
|
|
563
|
+
* change_summary is not a wire format we control: the reporting hook appends an
|
|
564
|
+
* override note to it (see composeSummary), and a future writer may append
|
|
565
|
+
* something else. So the tail is cut at the first bracketed note before it is
|
|
566
|
+
* read as a comma list, and nameList drops whatever still isn't an identifier.
|
|
567
|
+
*/
|
|
568
|
+
function namesFromSummary(summary) {
|
|
569
|
+
const match = /\b(?:touching|defining)\s+(.+)$/.exec(summary);
|
|
570
|
+
if (!match) return [];
|
|
571
|
+
return nameList(match[1].split(' [')[0].split(','));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function joinNames(names) {
|
|
575
|
+
if (names.length <= 1) return names[0] || '';
|
|
576
|
+
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function whenPhrase(minutes) {
|
|
580
|
+
if (minutes === null) return 'recently';
|
|
581
|
+
if (minutes <= 0) return 'seconds ago';
|
|
582
|
+
return `${minutes} min ago`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Flatten one Holder into the facts the message shapes read, with every field
|
|
587
|
+
* defaulted. A field the server never sent must produce a shorter sentence,
|
|
588
|
+
* never the word "undefined".
|
|
589
|
+
*
|
|
590
|
+
* `overlapping_symbols` / `symbols` are the seam for richer detection: when the
|
|
591
|
+
* server starts reporting which declarations actually collided, they arrive
|
|
592
|
+
* here and both the wording and the severity pick them up with no other change.
|
|
593
|
+
* Until then the names summarizeChange already writes into change_summary stand
|
|
594
|
+
* in — same slot, weaker evidence, so they inform wording but never severity.
|
|
595
|
+
*/
|
|
596
|
+
export function holderFacts(holder, file, myBranch) {
|
|
597
|
+
const h = holder && typeof holder === 'object' ? holder : {};
|
|
598
|
+
const developer = clean(h.developer, 80) || ANON_DEVELOPER;
|
|
599
|
+
const theirFile = clean(h.file_path, 300) || file;
|
|
600
|
+
const branch = clean(h.branch, 120);
|
|
601
|
+
const mine = clean(myBranch, 120);
|
|
602
|
+
const change = clean(h.change_summary, 160);
|
|
603
|
+
const minutes =
|
|
604
|
+
typeof h.minutes_ago === 'number' && Number.isFinite(h.minutes_ago) && h.minutes_ago >= 0
|
|
605
|
+
? Math.round(h.minutes_ago)
|
|
606
|
+
: null;
|
|
607
|
+
|
|
608
|
+
// SEAM (feat/semantic-conflicts × this branch): detection now does report
|
|
609
|
+
// which declarations collided. `Holder.symbols` is the *overlap* — the names
|
|
610
|
+
// both sides touched — and the server says so out loud with match_kind
|
|
611
|
+
// 'symbol' / confidence 'high'. That is the confirmed evidence
|
|
612
|
+
// `overlapping_symbols` was reserved for, so it feeds severity too. Without
|
|
613
|
+
// that signal `symbols` stays what this file always assumed: a weaker claim
|
|
614
|
+
// that shapes the wording and never the level.
|
|
615
|
+
const declared = nameList(h.overlapping_symbols);
|
|
616
|
+
const matchKind = clean(h.match_kind, 20);
|
|
617
|
+
const confirmed = matchKind === 'symbol' || h.confidence === 'high' ? nameList(h.symbols) : [];
|
|
618
|
+
const overlapping = declared.length ? declared : confirmed;
|
|
619
|
+
const reported = overlapping.length ? overlapping : nameList(h.symbols);
|
|
620
|
+
const touched = reported.length ? reported : namesFromSummary(change);
|
|
621
|
+
|
|
622
|
+
// An unknown branch is treated as "same tree" on purpose. That picks the
|
|
623
|
+
// re-read-the-file advice, which is cheap and harmless when wrong; the
|
|
624
|
+
// cross-branch advice tells the agent that re-reading is pointless, which is
|
|
625
|
+
// not harmless when wrong.
|
|
626
|
+
const sameFile = theirFile === file;
|
|
627
|
+
const sameTree = !branch || !mine || branch === mine;
|
|
628
|
+
|
|
629
|
+
const facts = {
|
|
630
|
+
developer,
|
|
631
|
+
who: `${developer}'s agent`,
|
|
632
|
+
file,
|
|
633
|
+
theirFile,
|
|
634
|
+
sameFile,
|
|
635
|
+
branch,
|
|
636
|
+
mine,
|
|
637
|
+
sameTree,
|
|
638
|
+
minutes,
|
|
639
|
+
when: whenPhrase(minutes),
|
|
640
|
+
hint: clean(h.task_hint, 200),
|
|
641
|
+
change,
|
|
642
|
+
touched,
|
|
643
|
+
overlapping,
|
|
644
|
+
// How the server matched this holder to our file: 'symbol' | 'file' | 'dir'.
|
|
645
|
+
matchKind,
|
|
646
|
+
// Whether this teammate is still around: 'active' | 'idle' | 'stale' |
|
|
647
|
+
// 'ended', or '' from a server that predates presence — in which case the
|
|
648
|
+
// timestamp is all we have and every decision below falls back to it.
|
|
649
|
+
//
|
|
650
|
+
// This used to be decoration: a trailing sentence on a message whose level
|
|
651
|
+
// had already been decided without it. It now decides whether the loudest
|
|
652
|
+
// level's headline claim can be made at all. See classify.
|
|
653
|
+
presence: clean(h.presence, 20),
|
|
654
|
+
presenceLabel: clean(h.presence_label, 200),
|
|
655
|
+
// Bound to this teammate's *specific* write, so an acknowledgement of it
|
|
656
|
+
// cannot double as standing permission. Falls back to a stable string when
|
|
657
|
+
// an older server sends no timestamp — see ackToken.
|
|
658
|
+
stamp: clean(h.last_touched, 40) || 'unknown',
|
|
659
|
+
};
|
|
660
|
+
// Confirmed at the declaration rather than inferred from the path: either the
|
|
661
|
+
// server named the overlapping symbols, or it told us it matched by symbol at
|
|
662
|
+
// all. Hoisted onto the facts because two separate decisions now turn on the
|
|
663
|
+
// same evidence — the promotion in classify, and classify's refusal to let
|
|
664
|
+
// presence take that promotion back again.
|
|
665
|
+
facts.symbolConfirmed = facts.overlapping.length > 0 || facts.matchKind === 'symbol';
|
|
666
|
+
facts.level = classify(facts);
|
|
667
|
+
return facts;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* "Presumed gone" is deliberately narrower than "not active".
|
|
672
|
+
*
|
|
673
|
+
* `idle` is excluded. The server's default active window is two minutes, which
|
|
674
|
+
* is shorter than a single agent turn — a teammate reading the diff their agent
|
|
675
|
+
* just produced is `idle`. Demoting on that would demote most real collisions
|
|
676
|
+
* and leave the loud level for almost nothing; and it would be wrong on the
|
|
677
|
+
* merits, because `idle` means the session is open and the person is at the
|
|
678
|
+
* desk. They will be surprised by a rewrite under them, which is exactly what
|
|
679
|
+
* ACTIVE COLLISION exists to say.
|
|
680
|
+
*
|
|
681
|
+
* `stale` and `ended` are the two states that mean the desk is empty. They are
|
|
682
|
+
* not equally well known — one is a report and the other is a timeout, and
|
|
683
|
+
* absenceClause keeps them apart in the text — but neither of them supports the
|
|
684
|
+
* claim that somebody is in this file right now, and that claim is the only
|
|
685
|
+
* thing being withdrawn.
|
|
686
|
+
*/
|
|
687
|
+
function teammateGone(f) {
|
|
688
|
+
return f.presence === 'stale' || f.presence === 'ended';
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* The whole severity model, in one function, so a reviewer can argue with the
|
|
693
|
+
* design rather than reconstruct it from branches.
|
|
694
|
+
*
|
|
695
|
+
* There are two axes, and they are genuinely independent.
|
|
696
|
+
*
|
|
697
|
+
* Axis 1 — *my working tree*. Is their change already in the bytes I am about
|
|
698
|
+
* to edit? Same branch means yes, and the failure is a stale read: my
|
|
699
|
+
* `old_string` no longer matches, or worse it does match and quietly reverts
|
|
700
|
+
* them. A different branch means no, re-reading cannot help, and the failure is
|
|
701
|
+
* a merge conflict instead. Confirmed symbol overlap sharpens this axis rather
|
|
702
|
+
* than replacing it: it says the collision is at a declaration, so which file
|
|
703
|
+
* that declaration lives in stops being the interesting question.
|
|
704
|
+
*
|
|
705
|
+
* Axis 2 — *the other agent*. Are they still there? That is a fact about them,
|
|
706
|
+
* not about my tree. A teammate can have gone home an hour ago and their change
|
|
707
|
+
* still be the exact thing that breaks this edit.
|
|
708
|
+
*
|
|
709
|
+
* evidence \ them | here, or unknown | presumed gone
|
|
710
|
+
* -----------------------+------------------+---------------
|
|
711
|
+
* confirmed symbol, | active | active
|
|
712
|
+
* same tree | | (never demoted)
|
|
713
|
+
* confirmed symbol, | parallel | parallel
|
|
714
|
+
* other branch | |
|
|
715
|
+
* same file, same tree, | active | stale
|
|
716
|
+
* write <= 5 min | |
|
|
717
|
+
* same file, | parallel | parallel
|
|
718
|
+
* other branch | |
|
|
719
|
+
* same file, same tree, | recent | recent
|
|
720
|
+
* write > 5 min | |
|
|
721
|
+
* neighbouring file | nearby | nearby
|
|
722
|
+
*
|
|
723
|
+
* Only one row moves under axis 2, and that is the model rather than an
|
|
724
|
+
* omission. ACTIVE COLLISION is the only level whose headline is a claim about
|
|
725
|
+
* a *person*: someone is in this file right now and will be surprised by what
|
|
726
|
+
* you do. Once they have closed the laptop that sentence is simply false, and
|
|
727
|
+
* a reader who catches the loudest label asserting something false learns to
|
|
728
|
+
* discount the label — which costs us the cases where it is true, and those are
|
|
729
|
+
* the ones that have to land. The other three levels claim nothing about anyone
|
|
730
|
+
* being present: PARALLEL EDIT is a statement about two branches, RECENT EDIT
|
|
731
|
+
* already says out loud that they have likely moved on, NEARBY EDIT asks for
|
|
732
|
+
* nothing at all. Presence has nothing to withdraw from any of them, so it does
|
|
733
|
+
* not touch them.
|
|
734
|
+
*
|
|
735
|
+
* One asymmetry is load-bearing and easy to miss. Presence is computed per
|
|
736
|
+
* *developer* — the server folds all of one person's sessions together — so
|
|
737
|
+
* "alice is active" does not mean alice is active *in this file*; she may be
|
|
738
|
+
* three packages away. Absence generalises downwards and presence does not:
|
|
739
|
+
* silence everywhere really does imply silence here, while activity somewhere
|
|
740
|
+
* implies nothing about here. So presence may only ever take a level away. It
|
|
741
|
+
* must never add one, and nothing below tries to: a cold 25-minute-old write
|
|
742
|
+
* from someone who is busy elsewhere stays RECENT EDIT.
|
|
743
|
+
*/
|
|
744
|
+
export function classify(facts) {
|
|
745
|
+
// A symbol match is the server telling us it found the collision by the
|
|
746
|
+
// declaration, not by the path. It outranks the directory heuristic below
|
|
747
|
+
// even if every name was dropped on the way in — the match kind is the
|
|
748
|
+
// evidence, the names are only how we word it.
|
|
749
|
+
if (facts.symbolConfirmed) {
|
|
750
|
+
// Deliberately NOT gated on presence, unlike the same-file case below, and
|
|
751
|
+
// this is the one place the two axes are allowed to disagree.
|
|
752
|
+
//
|
|
753
|
+
// The loud level here was never earned by anybody being at a keyboard. It
|
|
754
|
+
// was earned by proof that the exact declaration this edit is about to
|
|
755
|
+
// write against was changed by someone else, and that proof does not lapse
|
|
756
|
+
// because its author went to lunch. Demoting it would invite precisely the
|
|
757
|
+
// wrong inference — "they are gone, so the signature I read is the current
|
|
758
|
+
// one" — which is false and expensive. SHAPES.active says as much in the
|
|
759
|
+
// text whenever the two signals point different ways.
|
|
760
|
+
return facts.sameTree ? 'active' : 'parallel';
|
|
761
|
+
}
|
|
762
|
+
if (!facts.sameFile) return 'nearby';
|
|
763
|
+
if (!facts.sameTree) return 'parallel';
|
|
764
|
+
if (facts.minutes === null || facts.minutes > ACTIVE_MINUTES) return 'recent';
|
|
765
|
+
|
|
766
|
+
// WAS: 'active', unconditionally, with presence appended afterwards as a
|
|
767
|
+
// trailing sentence that changed nothing. NOW: 'active' only while there is
|
|
768
|
+
// somebody there to collide with.
|
|
769
|
+
//
|
|
770
|
+
// Note what is *not* demoted with it. Their write is in this working tree
|
|
771
|
+
// either way, so STALE READ carries the identical re-read imperative; that is
|
|
772
|
+
// why the demotion target is a new level rather than 'recent', whose "low
|
|
773
|
+
// urgency… they have likely moved on" would have thrown away the true half of
|
|
774
|
+
// the message along with the false half. Only the social claim is dropped.
|
|
775
|
+
//
|
|
776
|
+
// Under the old behaviour, an agent shown ACTIVE COLLISION for a teammate who
|
|
777
|
+
// shut their laptop twenty minutes ago would conclude that another agent was
|
|
778
|
+
// writing into this file concurrently — and would spend real effort avoiding
|
|
779
|
+
// a live conflict that cannot happen, while quietly learning that the loudest
|
|
780
|
+
// label is issued for absent people.
|
|
781
|
+
return teammateGone(facts) ? 'stale' : 'active';
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function branchPhrase(f) {
|
|
785
|
+
if (f.branch && f.mine && f.branch === f.mine) return ` on your branch (${f.branch})`;
|
|
786
|
+
if (f.branch && f.mine) return ` on branch ${f.branch} (you are on ${f.mine})`;
|
|
787
|
+
if (f.branch) return ` on branch ${f.branch}`;
|
|
788
|
+
return '';
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function taskPhrase(f, showHints) {
|
|
792
|
+
return showHints && f.hint ? `, while working on '${f.hint}'` : '';
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function changePhrase(f) {
|
|
796
|
+
return f.change ? ` (${f.change})` : '';
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function overlapPhrase(f) {
|
|
800
|
+
return f.overlapping.length ? ` You are both changing ${joinNames(f.overlapping)}.` : '';
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** The path, plus a note when it isn't the file this agent is about to edit. */
|
|
804
|
+
function where(f) {
|
|
805
|
+
if (f.sameFile) return f.file;
|
|
806
|
+
// SEAM (feat/semantic-conflicts): a holder in another file used to mean only
|
|
807
|
+
// one thing — a directory neighbour. It can now also be a file this one
|
|
808
|
+
// depends on, which is a different sentence and a different reason to care.
|
|
809
|
+
if (f.matchKind === 'symbol') {
|
|
810
|
+
const names = joinNames(f.overlapping) || joinNames(f.touched);
|
|
811
|
+
return names ? `${f.theirFile}, which ${f.file} depends on for ${names}` : f.theirFile;
|
|
812
|
+
}
|
|
813
|
+
return `${f.theirFile} (a different file in the same directory)`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** What the timestamp alone lets us say about whether they are still in there. */
|
|
817
|
+
function inhabits(f) {
|
|
818
|
+
if (f.minutes === null || f.minutes > ACTIVE_MINUTES) return 'has open work in';
|
|
819
|
+
return f.minutes <= 1 ? 'is inside' : 'is working in';
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* The verb. "alice is inside this file" is a lie once alice has gone home, and
|
|
824
|
+
* it is costlier here than on the dashboard because the agent acts on it. When
|
|
825
|
+
* the server actually knows whether she is still there, its answer outranks the
|
|
826
|
+
* timestamp guess `inhabits` makes on its own.
|
|
827
|
+
*
|
|
828
|
+
* This softens for `idle` too, which teammateGone deliberately does not demote.
|
|
829
|
+
* That is not an inconsistency: four minutes of silence is not enough to move
|
|
830
|
+
* ACTIVE COLLISION off a file, and it is easily enough to make "is inside"
|
|
831
|
+
* an overstatement. Wording is cheap to be careful with; the level is not.
|
|
832
|
+
*/
|
|
833
|
+
function presence(f) {
|
|
834
|
+
if (f.presence && f.presence !== 'active') return 'has open work in';
|
|
835
|
+
return inhabits(f);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Its own sentence, appended once, for a teammate the server knows is no longer
|
|
840
|
+
* active. An active one needs none — "last write 2 min ago" already says they
|
|
841
|
+
* are there.
|
|
842
|
+
*
|
|
843
|
+
* STALE READ is excluded because it puts the very same fact where it now
|
|
844
|
+
* belongs. Presence stopped being a footnote to that message and became the
|
|
845
|
+
* reason for its level, so absenceClause states it up front, next to the
|
|
846
|
+
* headline it is qualifying, instead of trailing behind the whole paragraph.
|
|
847
|
+
*/
|
|
848
|
+
function presenceSentence(f) {
|
|
849
|
+
if (f.level === 'stale') return '';
|
|
850
|
+
if (!f.presenceLabel || !f.presence || f.presence === 'active') return '';
|
|
851
|
+
return ` ${f.developer} ${f.presenceLabel}.`;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Why this is a stale file rather than a live collision — and, just as
|
|
856
|
+
* important, how well we actually know that.
|
|
857
|
+
*
|
|
858
|
+
* `ended` and `stale` both empty the desk, but one is a report from the client
|
|
859
|
+
* and the other is this server giving up waiting, and an advisory that renders
|
|
860
|
+
* them identically is dressing a guess up as a fact. The difference changes
|
|
861
|
+
* what a reader should do: an agent that believes alice is definitely gone will
|
|
862
|
+
* happily restructure the file, whereas one told only that the board lost sight
|
|
863
|
+
* of her should keep the edit small and say what it did.
|
|
864
|
+
*/
|
|
865
|
+
function absenceClause(f) {
|
|
866
|
+
if (f.presence === 'ended') {
|
|
867
|
+
return (
|
|
868
|
+
` ${f.developer} ${f.presenceLabel || 'ended their session'} — reported by their own client, not inferred,` +
|
|
869
|
+
` so there is no second agent in this file to surprise; what is left is your copy of it.`
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
return (
|
|
873
|
+
` ${f.developer} ${f.presenceLabel || 'has not been heard from in a while'}. That is a timeout on the board's` +
|
|
874
|
+
` clock rather than a goodbye — nobody reported leaving — so treat the file as stale, not the desk as certainly` +
|
|
875
|
+
` empty: keep this edit small enough to survive them coming back.`
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* The one case where the two axes contradict each other, said out loud instead
|
|
881
|
+
* of resolved silently.
|
|
882
|
+
*
|
|
883
|
+
* Presence says nobody is there; the server says you are both changing the same
|
|
884
|
+
* declaration. classify obeys the second and keeps the loud level, so the text
|
|
885
|
+
* owes the reader a reason — otherwise ACTIVE COLLISION over an absent teammate
|
|
886
|
+
* looks exactly like the bug this branch was written to remove, and the next
|
|
887
|
+
* person to notice will "fix" it.
|
|
888
|
+
*/
|
|
889
|
+
function confirmedDespiteAbsenceSentence(f) {
|
|
890
|
+
if (!f.symbolConfirmed || !teammateGone(f)) return '';
|
|
891
|
+
const names = joinNames(f.overlapping) || joinNames(f.touched);
|
|
892
|
+
return (
|
|
893
|
+
` ${f.developer} is not at the keyboard, and this is still a collision rather than a stale file:` +
|
|
894
|
+
` the overlap is confirmed at ${names ? `${names},` : 'the declaration,'} and a declaration someone else` +
|
|
895
|
+
` has already changed does not become safe to write against because its author stepped away.`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* What goes wrong when their change *is* in your tree, and what fixes it. The
|
|
901
|
+
* cross-file variant only arises once detection reports symbol overlap: then
|
|
902
|
+
* the risk isn't a stale edit, it's writing a call against a shape that moved.
|
|
903
|
+
*/
|
|
904
|
+
function staleSentence(f) {
|
|
905
|
+
if (f.sameFile) {
|
|
906
|
+
return (
|
|
907
|
+
` Their write is already in your working tree, so whatever you read from this file earlier is out of date.` +
|
|
908
|
+
` Re-read ${f.file} now and rebuild this edit against what is actually there:` +
|
|
909
|
+
` a stale old_string either fails to match or quietly reverts their change.`
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
return (
|
|
913
|
+
` Their write is already in your working tree. Read ${f.theirFile} before you finish this edit —` +
|
|
914
|
+
` you are about to write against ${joinNames(f.overlapping) || 'code'} as it used to be, and it has moved.`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** What goes wrong when their change is on a branch you cannot see. */
|
|
919
|
+
function mergeSentence(f) {
|
|
920
|
+
if (f.sameFile) {
|
|
921
|
+
return (
|
|
922
|
+
` Their version isn't in your working tree, so re-reading the file won't show it —` +
|
|
923
|
+
` this is a merge conflict forming, not a stale read.` +
|
|
924
|
+
` Keep this edit additive: preserve the names and signatures that are already exported,` +
|
|
925
|
+
` don't reformat or reorder the file, and don't rewrite regions your task doesn't require.`
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
return (
|
|
929
|
+
` Their version isn't in your working tree, so ${f.theirFile} still shows you the old` +
|
|
930
|
+
` ${joinNames(f.overlapping) || 'definitions'} here. Write against the shape you can actually see,` +
|
|
931
|
+
` and keep the change additive so both sides survive the merge.`
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* One shape per level. They differ in what they claim, what they ask for, and
|
|
937
|
+
* how long they are: a weak signal that spends a paragraph of context reads as
|
|
938
|
+
* urgent whatever the label says, so the weakest shape is one sentence.
|
|
939
|
+
*
|
|
940
|
+
* STALE READ is the exception to "louder is longer", and knowingly so. It asks
|
|
941
|
+
* for exactly what ACTIVE COLLISION asks for about the working tree and less
|
|
942
|
+
* than it asks for about the teammate, but it spends extra words saying what is
|
|
943
|
+
* *not* known about that teammate. Hedging is the opposite of a demand, so it
|
|
944
|
+
* does not read as escalation; the label carries the volume.
|
|
945
|
+
*/
|
|
946
|
+
const SHAPES = {
|
|
947
|
+
active: (f, showHints) =>
|
|
948
|
+
`[AgentMash: ACTIVE COLLISION] ${f.who} ${presence(f)} ${where(f)} — last write ${f.when}` +
|
|
949
|
+
`${branchPhrase(f)}${taskPhrase(f, showHints)}${changePhrase(f)}.${overlapPhrase(f)}` +
|
|
950
|
+
confirmedDespiteAbsenceSentence(f) +
|
|
951
|
+
staleSentence(f) +
|
|
952
|
+
(f.touched.length
|
|
953
|
+
? ` Leave ${joinNames(f.touched)} named and shaped as ${f.developer} left ${f.touched.length > 1 ? 'them' : 'it'} — that is what they just changed.`
|
|
954
|
+
: '') +
|
|
955
|
+
` If your task genuinely needs those same lines, change them and say so in your reply, so the overlap is visible.`,
|
|
956
|
+
|
|
957
|
+
// Everything ACTIVE COLLISION says about your working tree, and nothing it
|
|
958
|
+
// says about a person being in the room with you. The headline verb is past
|
|
959
|
+
// tense and impersonal for that reason: "left this file changed" is a fact
|
|
960
|
+
// about the file, where "is working in" was a claim about someone who is not.
|
|
961
|
+
//
|
|
962
|
+
// WAS: STALE FILE. Renamed, and only the label moved — the level, the demotion
|
|
963
|
+
// rule and every sentence below are unchanged. Two things were wrong with the
|
|
964
|
+
// old word, and both are about how the *pair* of labels reads:
|
|
965
|
+
//
|
|
966
|
+
// Against RECENT EDIT it inverted the clock. Both fire on the same file in the
|
|
967
|
+
// same tree with the teammate absent; this one covers the write from a minute
|
|
968
|
+
// ago and RECENT EDIT covers the one from twenty-five. Read side by side, the
|
|
969
|
+
// label containing "RECENT" was the older and quieter case, so a reader
|
|
970
|
+
// ranking two adjectives about time ranked them backwards. "Read" is not a
|
|
971
|
+
// word about time at all: it names *whose* state is out of date, and the
|
|
972
|
+
// ordering then follows from the subject — a defect in what you know outranks
|
|
973
|
+
// a fact about when somebody typed — instead of from which adjective sounds
|
|
974
|
+
// fresher.
|
|
975
|
+
//
|
|
976
|
+
// And it named the wrong noun. The file on disk is not stale; it is the newest
|
|
977
|
+
// version there is. What is stale is the copy of it this agent read into
|
|
978
|
+
// context, which is also the only thing the message asks it to fix. Naming the
|
|
979
|
+
// file invites "the file is old, so I can overwrite it"; naming the read
|
|
980
|
+
// invites "re-read it", which is the whole ask.
|
|
981
|
+
//
|
|
982
|
+
// Considered and rejected: renaming RECENT EDIT instead (it is accurate, and
|
|
983
|
+
// it is the level nobody misreads on its own), and folding this level back
|
|
984
|
+
// into RECENT EDIT (it would drop the re-read imperative, which is still true
|
|
985
|
+
// — their write is in your tree either way).
|
|
986
|
+
stale: (f, showHints) =>
|
|
987
|
+
`[AgentMash: STALE READ] ${f.who} left ${where(f)} changed — last write ${f.when}` +
|
|
988
|
+
`${branchPhrase(f)}${taskPhrase(f, showHints)}${changePhrase(f)}.${overlapPhrase(f)}` +
|
|
989
|
+
absenceClause(f) +
|
|
990
|
+
staleSentence(f) +
|
|
991
|
+
(f.touched.length
|
|
992
|
+
? ` Leave ${joinNames(f.touched)} named and shaped as ${f.developer} left ${f.touched.length > 1 ? 'them' : 'it'} — that is what they changed.`
|
|
993
|
+
: '') +
|
|
994
|
+
` If you change those lines anyway, say so in your reply: ${f.developer} is not here to watch it happen,` +
|
|
995
|
+
` so the reply is the only record they will get.`,
|
|
996
|
+
|
|
997
|
+
parallel: (f, showHints) =>
|
|
998
|
+
`[AgentMash: PARALLEL EDIT] ${f.who} modified ${where(f)} ${f.when}` +
|
|
999
|
+
`${branchPhrase(f)}${taskPhrase(f, showHints)}${changePhrase(f)}.${overlapPhrase(f)}` +
|
|
1000
|
+
mergeSentence(f) +
|
|
1001
|
+
(f.touched.length ? ` Assume ${joinNames(f.touched)} moved under you.` : '') +
|
|
1002
|
+
` If you have to change something they touched, say so in your reply so the divergence is visible before the merge.`,
|
|
1003
|
+
|
|
1004
|
+
recent: (f, showHints) =>
|
|
1005
|
+
`[AgentMash: RECENT EDIT] ${f.who} modified ${where(f)} ${f.when}` +
|
|
1006
|
+
`${branchPhrase(f)}${taskPhrase(f, showHints)}${changePhrase(f)}.` +
|
|
1007
|
+
` Low urgency: their write is already in your working tree and they have likely moved on.` +
|
|
1008
|
+
` Re-read ${f.file} first only if this edit depends on lines they may have moved.` +
|
|
1009
|
+
(f.touched.length ? ` Otherwise carry on, and just don't rename or delete ${joinNames(f.touched)}.` : ' Otherwise carry on.'),
|
|
1010
|
+
|
|
1011
|
+
nearby: (f, showHints) =>
|
|
1012
|
+
`[AgentMash: NEARBY EDIT] ${f.who} modified ${f.theirFile} ${f.when}` +
|
|
1013
|
+
`${branchPhrase(f)}${taskPhrase(f, showHints)}${changePhrase(f)} —` +
|
|
1014
|
+
` a different file in the same directory as ${f.file}, so nothing of yours is stale.` +
|
|
1015
|
+
` No action needed unless your edit changes something this directory shares.`,
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* A short id for one specific collision: this teammate, this file, this write.
|
|
1020
|
+
*
|
|
1021
|
+
* FNV-1a rather than node:crypto, because this is not a secret — anyone who can
|
|
1022
|
+
* append to the ack log can also just unset AGENTMASH_STRICT. Its only job is
|
|
1023
|
+
* specificity: acknowledging alice's 14:32 write to user.ts must not be
|
|
1024
|
+
* expressible as a standing permission to ignore everyone.
|
|
1025
|
+
*/
|
|
1026
|
+
export function ackToken(facts) {
|
|
1027
|
+
const seed = `${facts.developer}|${facts.theirFile}|${facts.stamp}|${facts.branch}`;
|
|
1028
|
+
let a = 0x811c9dc5;
|
|
1029
|
+
let b = 0x9e3779b9;
|
|
1030
|
+
for (let i = 0; i < seed.length; i++) {
|
|
1031
|
+
const c = seed.charCodeAt(i);
|
|
1032
|
+
a = Math.imul(a ^ c, 0x01000193) >>> 0;
|
|
1033
|
+
b = Math.imul(b + c, 0x85ebca6b) >>> 0;
|
|
1034
|
+
}
|
|
1035
|
+
return `ack_${a.toString(36)}${b.toString(36)}`;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Pick the shape, render it, and hand back everything the caller needs to
|
|
1040
|
+
* decide what to do with it. Holders beyond the first get one clause each:
|
|
1041
|
+
* three full paragraphs about one file is how a useful signal becomes noise.
|
|
1042
|
+
*/
|
|
1043
|
+
export function buildCollisionAdvice({ file, holders, branch, showHints = true }) {
|
|
1044
|
+
const list = (Array.isArray(holders) ? holders : [])
|
|
1045
|
+
.filter((h) => h && typeof h === 'object')
|
|
1046
|
+
.map((h) => holderFacts(h, file, branch));
|
|
1047
|
+
if (list.length === 0) return null;
|
|
1048
|
+
|
|
1049
|
+
list.sort(
|
|
1050
|
+
(a, b) =>
|
|
1051
|
+
ADVICE_LEVELS.indexOf(b.level) - ADVICE_LEVELS.indexOf(a.level) ||
|
|
1052
|
+
(a.minutes ?? Number.MAX_SAFE_INTEGER) - (b.minutes ?? Number.MAX_SAFE_INTEGER)
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
const top = list[0];
|
|
1056
|
+
const others = list.slice(1, 3);
|
|
1057
|
+
const tail = others.length
|
|
1058
|
+
? ` Also recently in this area: ${others.map((o) => `${o.developer} (${o.when})`).join(', ')}.`
|
|
1059
|
+
: '';
|
|
1060
|
+
|
|
1061
|
+
return {
|
|
1062
|
+
level: top.level,
|
|
1063
|
+
file,
|
|
1064
|
+
developer: top.developer,
|
|
1065
|
+
token: ackToken(top),
|
|
1066
|
+
grounding: ackGrounding(top),
|
|
1067
|
+
text: SHAPES[top.level](top, showHints) + presenceSentence(top) + tail,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const REJECTIONS = {
|
|
1072
|
+
placeholder: 'the placeholder was still in it, so it was not your own words',
|
|
1073
|
+
thin: `it was under ${ACK_MIN_REASON} characters, which is not a judgement`,
|
|
1074
|
+
ungrounded:
|
|
1075
|
+
'it named nothing from this collision — not the teammate, not what they changed —' +
|
|
1076
|
+
' so it was a sentence that would have fitted any collision at all',
|
|
1077
|
+
restated:
|
|
1078
|
+
'it added nothing to what you were already told: it only echoed this collision and the task you are on,' +
|
|
1079
|
+
' and the question is not what you are doing but why theirs and yours can both stand',
|
|
1080
|
+
recycled: 'you had already given that same reason for a different collision',
|
|
1081
|
+
expired: `it was more than ${Math.round(ACK_TTL_MS / 60_000)} minutes old, and acknowledgements do not stay open`,
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
/** The particulars the block asks for by name, so the agent isn't guessing. */
|
|
1085
|
+
function requiredPhrase(advice) {
|
|
1086
|
+
const named = (advice.grounding?.particulars || []).slice(0, 3);
|
|
1087
|
+
if (!named.length) return `name ${advice.developer} and what they changed`;
|
|
1088
|
+
return `name ${joinNames(named)} in it`;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* The other half of strict mode: how an agent, working alone, gets past a block
|
|
1093
|
+
* it has genuinely thought about — without the getting-past becoming automatic.
|
|
1094
|
+
*
|
|
1095
|
+
* Five things keep it from being a rubber stamp, and the text says all five out
|
|
1096
|
+
* loud, because an agent that knows the override is recorded behaves
|
|
1097
|
+
* differently from one that thinks it is free:
|
|
1098
|
+
* 1. the token is bound to one teammate's one write to one file;
|
|
1099
|
+
* 2. it dies when they write again, and expires after ACK_TTL_MS;
|
|
1100
|
+
* 3. the reason is checked against the collision's own particulars — the
|
|
1101
|
+
* teammate, the declarations they changed, their file, their branch — so a
|
|
1102
|
+
* sentence that would have fitted any collision fits none;
|
|
1103
|
+
* 4. it must add something to those particulars and to this agent's own task,
|
|
1104
|
+
* and a reason reused across collisions is rejected as the reflex it is;
|
|
1105
|
+
* 5. the reason rides out on the next reported edit, so the teammate who was
|
|
1106
|
+
* overridden — and the dashboard — see that it happened and why.
|
|
1107
|
+
*
|
|
1108
|
+
* Guard 3 is not a lock and this file should not pretend otherwise: a model
|
|
1109
|
+
* determined to proceed can read the advisory and write a plausible sentence
|
|
1110
|
+
* about it. What it buys is that the sentence has to be *about this collision*,
|
|
1111
|
+
* which cannot be produced without reading it, cannot be reused, and is wrong
|
|
1112
|
+
* in a way the overridden teammate can see when it is not true. Guard 5 is what
|
|
1113
|
+
* makes that cost real; guard 3 only makes it unavoidable.
|
|
1114
|
+
*/
|
|
1115
|
+
/**
|
|
1116
|
+
* A claim is stated intent — a teammate's agent has said it is taking this
|
|
1117
|
+
* scope — so it deserves its own shape rather than being dressed up as an edit
|
|
1118
|
+
* that has not happened. Nothing in this agent's tree will show it yet; the
|
|
1119
|
+
* whole point is that the message arrives before there is anything to see.
|
|
1120
|
+
*
|
|
1121
|
+
* Claims never block, even in strict mode: blocking on what someone *intends*
|
|
1122
|
+
* to do is how a coordination layer becomes the thing people route around.
|
|
1123
|
+
*/
|
|
1124
|
+
export function buildClaimAdvice({ file, claims, showHints = true }) {
|
|
1125
|
+
const list = (Array.isArray(claims) ? claims : []).filter(
|
|
1126
|
+
(c) => c && typeof c === 'object' && typeof c.developer === 'string'
|
|
1127
|
+
);
|
|
1128
|
+
if (list.length === 0) return null;
|
|
1129
|
+
|
|
1130
|
+
const now = Date.now();
|
|
1131
|
+
const minutesAgo = (ts) => (Number.isFinite(ts) ? Math.max(0, Math.round((now - ts) / 60_000)) : null);
|
|
1132
|
+
const minutesLeft = (ts) => (Number.isFinite(ts) ? Math.max(0, Math.round((ts - now) / 60_000)) : null);
|
|
1133
|
+
|
|
1134
|
+
const scopeOf = (c) => {
|
|
1135
|
+
const covering = (c.overlap?.paths?.length ? c.overlap.paths : c.paths || []).find(Boolean);
|
|
1136
|
+
if (!covering || covering === file) return 'this file';
|
|
1137
|
+
if (file.startsWith(`${covering}/`)) return `${covering}/ — which includes ${file}`;
|
|
1138
|
+
const shared = c.overlap?.symbols?.length ? ` (you share ${c.overlap.symbols.slice(0, 3).join(', ')})` : '';
|
|
1139
|
+
return `${covering}${shared}`;
|
|
1140
|
+
};
|
|
1141
|
+
const taskOf = (c) => (showHints && c.task ? ` for '${c.task}'` : '');
|
|
1142
|
+
|
|
1143
|
+
const top = list[0];
|
|
1144
|
+
const ago = minutesAgo(top.created_ts);
|
|
1145
|
+
const left = minutesLeft(top.expires_ts);
|
|
1146
|
+
const when =
|
|
1147
|
+
ago === null ? '' : ` — claimed ${ago === 0 ? 'just now' : `${ago} min ago`}${left !== null ? `, ${left} min left` : ''}`;
|
|
1148
|
+
|
|
1149
|
+
const others = list.slice(1, 3);
|
|
1150
|
+
const tail = others.length
|
|
1151
|
+
? ` Also claimed by ${others.map((o) => `${o.developer}${taskOf(o)}`).join(' and ')}.`
|
|
1152
|
+
: '';
|
|
1153
|
+
|
|
1154
|
+
const text =
|
|
1155
|
+
`[AgentMash: CLAIMED] ${top.developer}'s agent has claimed ${scopeOf(top)}${taskOf(top)}${when}.` +
|
|
1156
|
+
` They may not have written to it yet, so nothing in your working tree shows this.` +
|
|
1157
|
+
` If your change is part of their task, leave it to them or build on what they land;` +
|
|
1158
|
+
` if it is unrelated, keep out of the lines their task will touch,` +
|
|
1159
|
+
` and say in your reply that ${top.developer} has this claimed so the overlap is visible.` +
|
|
1160
|
+
tail;
|
|
1161
|
+
|
|
1162
|
+
return { level: 'claimed', file, developer: top.developer, text };
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* What an agent is handed when it is about to finish having collided with a
|
|
1167
|
+
* teammate: what they changed and why, how to see their version, and what to
|
|
1168
|
+
* do with the reconciled result — which depends on the room's reconcile mode.
|
|
1169
|
+
*
|
|
1170
|
+
* The server never sees code, so this cannot merge anything. It briefs the
|
|
1171
|
+
* one process that has the code and an agent attached to it, and tells it not
|
|
1172
|
+
* to stop yet. Used by the Stop hook (as a block reason) and by the
|
|
1173
|
+
* get_collisions MCP tool (as an answer).
|
|
1174
|
+
*/
|
|
1175
|
+
export function buildReconciliationBrief({ collisions, mode = 'approve', showHints = true, heading } = {}) {
|
|
1176
|
+
const list = (Array.isArray(collisions) ? collisions : []).filter(
|
|
1177
|
+
(c) => c && typeof c.file_path === 'string' && Array.isArray(c.theirs) && c.theirs.length > 0
|
|
1178
|
+
);
|
|
1179
|
+
if (list.length === 0 || mode === 'off') return null;
|
|
1180
|
+
|
|
1181
|
+
const lines = [];
|
|
1182
|
+
lines.push(
|
|
1183
|
+
heading ??
|
|
1184
|
+
`[AgentMash: RECONCILE BEFORE YOU FINISH] ${
|
|
1185
|
+
list.length === 1
|
|
1186
|
+
? 'A file you changed in this session was also changed by a teammate.'
|
|
1187
|
+
: `${list.length} files you changed in this session were also changed by teammates.`
|
|
1188
|
+
} Do not end here — reconcile first.`
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
let exampleRef = null;
|
|
1192
|
+
for (const c of list.slice(0, 4)) {
|
|
1193
|
+
const mine = c.mine || {};
|
|
1194
|
+
lines.push('');
|
|
1195
|
+
lines.push(`• ${c.file_path} — also changed by ${c.theirs.map((t) => t.developer).join(' and ')}.`);
|
|
1196
|
+
for (const t of c.theirs.slice(0, 2)) {
|
|
1197
|
+
const when = typeof t.minutes_ago === 'number' ? (t.minutes_ago === 0 ? 'just now' : `${t.minutes_ago} min ago`) : 'recently';
|
|
1198
|
+
let branch = '';
|
|
1199
|
+
if (t.branch && mine.branch && t.branch === mine.branch) branch = ` on your branch (${t.branch})`;
|
|
1200
|
+
else if (t.branch) branch = ` on branch ${t.branch}${mine.branch ? ` — you are on ${mine.branch}` : ''}`;
|
|
1201
|
+
if (t.branch && (!mine.branch || t.branch !== mine.branch) && !exampleRef) exampleRef = t.branch;
|
|
1202
|
+
const task = showHints && t.task_hint ? ` while working on '${t.task_hint}'` : '';
|
|
1203
|
+
const change = t.change_summary ? ` (${t.change_summary})` : '';
|
|
1204
|
+
lines.push(` ${t.developer}: last write ${when}${branch}${task}${change}.`);
|
|
1205
|
+
}
|
|
1206
|
+
if (Array.isArray(c.shared_symbols) && c.shared_symbols.length) {
|
|
1207
|
+
lines.push(
|
|
1208
|
+
` You both touched ${c.shared_symbols.slice(0, 5).join(', ')} — that is where a merge is most likely to break.`
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
if (showHints && mine.task_hint) {
|
|
1212
|
+
lines.push(` Your side: '${mine.task_hint}'${mine.change_summary ? ` (${mine.change_summary})` : ''}.`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
if (list.length > 4) lines.push(`…and ${list.length - 4} more.`);
|
|
1216
|
+
|
|
1217
|
+
const ref = exampleRef ? `origin/${exampleRef}` : 'origin/<their-branch>';
|
|
1218
|
+
lines.push('');
|
|
1219
|
+
lines.push('How to reconcile:');
|
|
1220
|
+
lines.push(
|
|
1221
|
+
`1. See their version: \`git fetch origin\`, then \`git log --oneline ${ref} -- <file>\` and ` +
|
|
1222
|
+
`\`git diff HEAD...${ref} -- <file>\`. If you are on the same branch, \`git pull --rebase\` and resolve ` +
|
|
1223
|
+
'the conflicts git reports.'
|
|
1224
|
+
);
|
|
1225
|
+
lines.push(
|
|
1226
|
+
'2. Merge so that BOTH intents survive: keep the names and shapes they introduced, keep what your task ' +
|
|
1227
|
+
'needed, and run the tests.'
|
|
1228
|
+
);
|
|
1229
|
+
if (mode === 'auto') {
|
|
1230
|
+
lines.push(
|
|
1231
|
+
'3. This room reconciles automatically: commit the reconciled result the way this repository normally ' +
|
|
1232
|
+
'commits, and push if that is its flow.'
|
|
1233
|
+
);
|
|
1234
|
+
lines.push(
|
|
1235
|
+
'4. Call the AgentMash tool `mark_resolved` with the file and outcome "merged" — or, if the tool is not ' +
|
|
1236
|
+
'available, say clearly in your reply what you merged.'
|
|
1237
|
+
);
|
|
1238
|
+
} else {
|
|
1239
|
+
lines.push(
|
|
1240
|
+
'3. This room needs a human to approve reconciliations: commit on a branch named reconcile/<short-file-name> ' +
|
|
1241
|
+
'and open a pull request (for example `gh pr create`) titled "Reconcile <file>: <you> + <them>", ' +
|
|
1242
|
+
'describing both changes and what you kept. Do NOT push the reconciled result to the shared branch directly.'
|
|
1243
|
+
);
|
|
1244
|
+
lines.push(
|
|
1245
|
+
'4. Call the AgentMash tool `mark_resolved` with the file, outcome "pr" and the PR URL — or, if the tool ' +
|
|
1246
|
+
'is not available, put the URL in your reply.'
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
lines.push(
|
|
1250
|
+
'5. If, after looking, the two changes do not actually conflict, say so in your reply and call ' +
|
|
1251
|
+
'`mark_resolved` with outcome "no_conflict".'
|
|
1252
|
+
);
|
|
1253
|
+
lines.push('Then finish. You will not be asked about these files again in this session.');
|
|
1254
|
+
return lines.join('\n');
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
export function buildStrictBlock(advice, { ackDir, ackPath, rejected } = {}) {
|
|
1258
|
+
const minutes = Math.round(ACK_TTL_MS / 60_000);
|
|
1259
|
+
const parts = [
|
|
1260
|
+
advice.text,
|
|
1261
|
+
'',
|
|
1262
|
+
'AgentMash strict mode blocked this edit. The block is a checkpoint, not a veto:' +
|
|
1263
|
+
' if you have taken the above into account and this edit still has to happen,' +
|
|
1264
|
+
' acknowledge this specific collision and make the same edit again.',
|
|
1265
|
+
];
|
|
1266
|
+
if (rejected && REJECTIONS[rejected]) {
|
|
1267
|
+
parts.push(
|
|
1268
|
+
'',
|
|
1269
|
+
`Your previous acknowledgement was not accepted: ${REJECTIONS[rejected]}.`
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
parts.push(
|
|
1273
|
+
'',
|
|
1274
|
+
'Run exactly this, with the placeholder replaced by your own one-sentence reason:',
|
|
1275
|
+
'',
|
|
1276
|
+
` mkdir -p "${ackDir}" && echo "${advice.token} ${ACK_PLACEHOLDER}" >> "${ackPath}"`,
|
|
1277
|
+
'',
|
|
1278
|
+
`That token covers only ${advice.file} and only ${advice.developer}'s current write:` +
|
|
1279
|
+
` if they write again it changes and you are stopped again, and it expires ${minutes} minutes after you first use it.` +
|
|
1280
|
+
` Your reason is attached to your next reported edit, so ${advice.developer} and the room dashboard will see that you overrode this, and why.`,
|
|
1281
|
+
`Do not acknowledge on reflex. The reason is checked against this collision, not counted: ${requiredPhrase(advice)}, and say why` +
|
|
1282
|
+
` ${advice.developer}'s change and yours can both stand.` +
|
|
1283
|
+
` A sentence that only restates your task ("I need to edit this file to complete my assigned task") names none of that and will be refused,` +
|
|
1284
|
+
' and so will one that repeats those names back with nothing of your own added.' +
|
|
1285
|
+
` If you cannot say something that specific, stop and ask the user instead.`
|
|
1286
|
+
);
|
|
1287
|
+
return parts.join('\n');
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// ── acknowledgement log ─────────────────────────────────────────────────────
|
|
1291
|
+
//
|
|
1292
|
+
// A plain append-only text file the agent writes with one shell command we hand
|
|
1293
|
+
// it verbatim. Read only in strict mode, and only after /check has already
|
|
1294
|
+
// reported a conflict — the ordinary edit path never opens it.
|
|
1295
|
+
|
|
1296
|
+
export function ackFile(sessionId) {
|
|
1297
|
+
return scratchFile(`ack-${String(sessionId || 'unknown').replace(/[^A-Za-z0-9._-]/g, '_')}.log`);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/** Lines are "<token> <reason>"; anything else in the file is ignored. */
|
|
1301
|
+
export function readAcks(sessionId) {
|
|
1302
|
+
let raw;
|
|
1303
|
+
try {
|
|
1304
|
+
raw = fs.readFileSync(ackFile(sessionId), 'utf8');
|
|
1305
|
+
} catch {
|
|
1306
|
+
return [];
|
|
1307
|
+
}
|
|
1308
|
+
const entries = [];
|
|
1309
|
+
for (const line of raw.split(/\r?\n/).slice(-200)) {
|
|
1310
|
+
const match = /^\s*(ack_[a-z0-9]+)\s+(.*\S)\s*$/i.exec(line);
|
|
1311
|
+
if (match) entries.push({ token: match[1], reason: match[2].trim() });
|
|
1312
|
+
}
|
|
1313
|
+
return entries;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// Words that carry no claim. Only words of three characters or more are counted
|
|
1317
|
+
// at all, so this list only has to cover the longer connectives.
|
|
1318
|
+
const REASON_STOPWORDS = new Set(
|
|
1319
|
+
('the and but for not are was were this that with from have has had you your they their them its' +
|
|
1320
|
+
' been being will would can could should because into onto out off only also just than then there' +
|
|
1321
|
+
' here what which who whom while when where all any some each both same other about after before' +
|
|
1322
|
+
' over under again more most very still now does did doing don won yet already need needs').split(/\s+/)
|
|
1323
|
+
);
|
|
1324
|
+
|
|
1325
|
+
/** Words a claim is made of: three characters or more, and not a connective. */
|
|
1326
|
+
function contentWords(text) {
|
|
1327
|
+
const words = new Set();
|
|
1328
|
+
for (const word of String(text || '').toLowerCase().match(/[a-z0-9_$]+/g) || []) {
|
|
1329
|
+
if (word.length >= 3 && !REASON_STOPWORDS.has(word)) words.add(word);
|
|
1330
|
+
}
|
|
1331
|
+
return words;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** Domains and the like: parts of an identity that identify nobody. */
|
|
1335
|
+
const DEVELOPER_NOISE = new Set(['com', 'org', 'net', 'www', 'agent']);
|
|
1336
|
+
/** Branch names shared by every repo on earth ground nothing. */
|
|
1337
|
+
const GENERIC_BRANCHES = new Set(['main', 'master', 'trunk', 'dev', 'develop', 'default']);
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* What a reason for *this* collision can be checked against.
|
|
1341
|
+
*
|
|
1342
|
+
* `terms` are the things an agent can only put in a sentence by having read the
|
|
1343
|
+
* advisory: the teammate's name, the declarations the server says they changed,
|
|
1344
|
+
* the file of theirs that is not the one being edited, the branch it is on.
|
|
1345
|
+
* `particulars` are the same things in their original spelling, for the block
|
|
1346
|
+
* message to ask for by name — the check is a forcing function for reading the
|
|
1347
|
+
* collision, not a password to be guessed.
|
|
1348
|
+
*
|
|
1349
|
+
* A collision can offer nothing: an anonymous holder, same file, same branch, no
|
|
1350
|
+
* symbols. Then there is nothing to check a reason against, and the honest thing
|
|
1351
|
+
* is to check nothing rather than to demand a word that does not exist.
|
|
1352
|
+
*/
|
|
1353
|
+
export function ackGrounding(facts) {
|
|
1354
|
+
const particulars = [];
|
|
1355
|
+
const terms = new Set();
|
|
1356
|
+
// A particular is only worth asking for if something about it is checkable:
|
|
1357
|
+
// two-character fragments match every sentence, so they ground nothing.
|
|
1358
|
+
const add = (display, matches) => {
|
|
1359
|
+
const usable = matches.filter((m) => m && m.length >= 3);
|
|
1360
|
+
if (!display || !usable.length || particulars.includes(display)) return;
|
|
1361
|
+
particulars.push(display);
|
|
1362
|
+
for (const m of usable) terms.add(m.toLowerCase());
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
for (const name of [...(facts.overlapping || []), ...(facts.touched || [])]) {
|
|
1366
|
+
add(name, [name]);
|
|
1367
|
+
}
|
|
1368
|
+
if (facts.developer && facts.developer !== ANON_DEVELOPER) {
|
|
1369
|
+
const lower = facts.developer.toLowerCase();
|
|
1370
|
+
const parts = lower.split('@')[0].split(/[^a-z0-9]+/).filter((p) => !DEVELOPER_NOISE.has(p));
|
|
1371
|
+
add(facts.developer, [lower, ...parts]);
|
|
1372
|
+
}
|
|
1373
|
+
if (!facts.sameFile && facts.theirFile) {
|
|
1374
|
+
const base = facts.theirFile.split('/').pop();
|
|
1375
|
+
add(base, [base, base.replace(/\.[^.]+$/, '')]);
|
|
1376
|
+
}
|
|
1377
|
+
if (facts.branch && facts.branch !== facts.mine && !GENERIC_BRANCHES.has(facts.branch.toLowerCase())) {
|
|
1378
|
+
add(facts.branch, [facts.branch, facts.branch.split('/').pop()]);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
return { terms: [...terms], particulars };
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function isGrounded(reason, grounding) {
|
|
1385
|
+
const terms = grounding?.terms || [];
|
|
1386
|
+
if (!terms.length) return true; // nothing to check against — see ackGrounding
|
|
1387
|
+
const lower = reason.toLowerCase();
|
|
1388
|
+
return terms.some((term) => lower.includes(term));
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* True when the reason contributes nothing of its own — every word in it is
|
|
1393
|
+
* either a particular of the collision it was just handed or a word from the
|
|
1394
|
+
* agent's own task. "I must rename createUser, that is my task" is not an
|
|
1395
|
+
* argument that the collision is acceptable; it is the premise restated.
|
|
1396
|
+
*/
|
|
1397
|
+
function isRestatement(reason, grounding, taskHint) {
|
|
1398
|
+
const words = contentWords(reason);
|
|
1399
|
+
if (words.size === 0) return true;
|
|
1400
|
+
const known = contentWords(`${(grounding?.terms || []).join(' ')} ${taskHint || ''}`);
|
|
1401
|
+
for (const word of words) {
|
|
1402
|
+
if (!known.has(word)) return false;
|
|
1403
|
+
}
|
|
1404
|
+
return true;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* A reason reduced to the claim it makes: content words, sorted. Deleting a
|
|
1409
|
+
* full stop or reordering a clause produces the same signature, which is what
|
|
1410
|
+
* the previous exact-string comparison could not survive.
|
|
1411
|
+
*/
|
|
1412
|
+
function reasonSignature(reason) {
|
|
1413
|
+
return [...contentWords(reason)].sort().join(' ');
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Does the log contain a usable acknowledgement of `token`? Returns the reason
|
|
1418
|
+
* if so, and otherwise why the closest attempt was refused, so the next block
|
|
1419
|
+
* message can say what to fix instead of repeating itself.
|
|
1420
|
+
*
|
|
1421
|
+
* `grounding` and `taskHint` are what makes this more than a length check: the
|
|
1422
|
+
* reason is read against the collision the agent was just shown and against
|
|
1423
|
+
* what the agent itself is doing. Both are optional, and absent means the
|
|
1424
|
+
* corresponding test is skipped rather than failed — a check that cannot be
|
|
1425
|
+
* satisfied would only teach the agent to disable strict mode.
|
|
1426
|
+
*/
|
|
1427
|
+
export function resolveAck(entries, token, { grounding, taskHint } = {}) {
|
|
1428
|
+
const mine = entries.filter((e) => e.token === token);
|
|
1429
|
+
if (mine.length === 0) return { ok: false, rejected: null };
|
|
1430
|
+
const elsewhere = new Set(
|
|
1431
|
+
entries.filter((e) => e.token !== token).map((e) => reasonSignature(e.reason))
|
|
1432
|
+
);
|
|
1433
|
+
let rejected = null;
|
|
1434
|
+
for (const entry of mine) {
|
|
1435
|
+
if (entry.reason.includes(ACK_PLACEHOLDER)) {
|
|
1436
|
+
rejected = 'placeholder';
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
if (entry.reason.length < ACK_MIN_REASON) {
|
|
1440
|
+
rejected = 'thin';
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
if (grounding && !isGrounded(entry.reason, grounding)) {
|
|
1444
|
+
rejected = 'ungrounded';
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
if (grounding && isRestatement(entry.reason, grounding, taskHint)) {
|
|
1448
|
+
rejected = 'restated';
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
// The same sentence filed under a second token is the signature of a habit,
|
|
1452
|
+
// not of a decision taken twice.
|
|
1453
|
+
if (elsewhere.has(reasonSignature(entry.reason))) {
|
|
1454
|
+
rejected = 'recycled';
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
return { ok: true, reason: entry.reason, rejected: null };
|
|
1458
|
+
}
|
|
1459
|
+
return { ok: false, rejected };
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** The note rides in change_summary, which the server bounds at 300 characters. */
|
|
1463
|
+
const SUMMARY_MAX = 300;
|
|
1464
|
+
const OVERRIDE_NOTE_MAX = 140;
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* Take the override note owed for `file`, if one is still owed. Called by the
|
|
1468
|
+
* reporting hook, so the teammate who was overridden finds out through the
|
|
1469
|
+
* channel they already watch instead of a new one.
|
|
1470
|
+
*/
|
|
1471
|
+
export function takeOverrideNote(cache, file) {
|
|
1472
|
+
const entry = cache?.overrides?.[file];
|
|
1473
|
+
if (!entry) return null;
|
|
1474
|
+
delete cache.overrides[file];
|
|
1475
|
+
if (!(Date.now() - (Number(entry.at) || 0) < ACK_TTL_MS)) return null;
|
|
1476
|
+
return truncate(`[overrode ${entry.developer}: ${entry.reason}]`, OVERRIDE_NOTE_MAX);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* Put the override note on the end of the edit summary without letting the pair
|
|
1481
|
+
* exceed what the server will accept — a rejected event is a lost edit report,
|
|
1482
|
+
* which costs the room more than a shortened sentence. The note is kept whole
|
|
1483
|
+
* and the summary gets what is left, because the summary can always be
|
|
1484
|
+
* recomputed from the next edit and the note cannot.
|
|
1485
|
+
*/
|
|
1486
|
+
export function composeSummary(summary, note) {
|
|
1487
|
+
const base = summary ? String(summary) : '';
|
|
1488
|
+
if (!note) return truncate(base, SUMMARY_MAX) || null;
|
|
1489
|
+
const room = SUMMARY_MAX - note.length - 1;
|
|
1490
|
+
const head = room > 0 ? truncate(base, room) : '';
|
|
1491
|
+
return head ? `${head} ${note}` : note;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// ── symbol extraction ───────────────────────────────────────────────────────
|
|
1495
|
+
//
|
|
1496
|
+
// Why regexes and not a parser: these hooks are dependency-free files that
|
|
1497
|
+
// travel by `git pull`, and that is what makes teammate setup a single command.
|
|
1498
|
+
// A real TypeScript parse would make setup an install step, so it is out.
|
|
1499
|
+
//
|
|
1500
|
+
// The trade that buys back accuracy is precision over recall. `summarizeChange`
|
|
1501
|
+
// above matches *any* declaration keyword and so names locals — `i`, `res`,
|
|
1502
|
+
// `text` — which is fine for a sentence a human reads and useless for matching.
|
|
1503
|
+
// These extractors only claim a name when the line starts with `export`, which
|
|
1504
|
+
// on this repo's own source is 100% precise. Anything they cannot see becomes a
|
|
1505
|
+
// miss, never a false alarm, and a miss degrades to today's path matching.
|
|
1506
|
+
|
|
1507
|
+
const SYMBOL_EXTENSIONS = new Set([
|
|
1508
|
+
'.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs',
|
|
1509
|
+
]);
|
|
1510
|
+
|
|
1511
|
+
// Caps, so a huge file can't bloat a URL or a POST body. The reference side
|
|
1512
|
+
// is the wider one: it is a whole file's imports plus exports, and it rides in
|
|
1513
|
+
// a query string, which is cheap. A single edit that changes 24 exported
|
|
1514
|
+
// symbols is already a rewrite, so the definition side stays tight.
|
|
1515
|
+
const MAX_REF_SYMBOLS = 60;
|
|
1516
|
+
const MAX_DEF_SYMBOLS = 24;
|
|
1517
|
+
/** Files past this size are skipped rather than read on the edit path. */
|
|
1518
|
+
const MAX_SOURCE_BYTES = 512 * 1024;
|
|
1519
|
+
/** Two-character names ("db", "id") match everything; the noise isn't worth it. */
|
|
1520
|
+
const MIN_SYMBOL_LENGTH = 3;
|
|
1521
|
+
/** Matches the server's bound, so nothing we send can be rejected for length. */
|
|
1522
|
+
const MAX_SYMBOL_LENGTH = 120;
|
|
1523
|
+
/** Past this many identical occurrences, placing an edit is guesswork. */
|
|
1524
|
+
const MAX_ATTRIBUTION_MATCHES = 8;
|
|
1525
|
+
|
|
1526
|
+
/** True when this path is a language the extractors understand. */
|
|
1527
|
+
export function supportsSymbols(filePath) {
|
|
1528
|
+
const m = /(\.[A-Za-z0-9]+)$/.exec(String(filePath || ''));
|
|
1529
|
+
return Boolean(m) && SYMBOL_EXTENSIONS.has(m[1].toLowerCase());
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// A scanner that reads raw text claims whatever *looks* like an export, and a
|
|
1533
|
+
// commented-out `export function` looks exactly like a live one. That phantom is
|
|
1534
|
+
// stored on the definition side and matches at `high` confidence — the loudest
|
|
1535
|
+
// thing this system says — so it is worth one linear pass to remove the whole
|
|
1536
|
+
// class: comment, string, regex and template-literal bodies are replaced by
|
|
1537
|
+
// spaces before any regex runs. Spaces and not deletion, because every offset
|
|
1538
|
+
// `regionEnd` and `enclosingDeclaration` work with is an offset into the
|
|
1539
|
+
// original text.
|
|
1540
|
+
|
|
1541
|
+
/** Characters after which a `/` opens a regex literal rather than dividing. */
|
|
1542
|
+
const REGEX_MAY_FOLLOW = /[({[,;:=!&|?+\-*%~^<>]/;
|
|
1543
|
+
const REGEX_MAY_FOLLOW_KEYWORD = /\b(?:return|typeof|instanceof|in|of|new|delete|void|case|do|else|yield|await)$/;
|
|
1544
|
+
|
|
1545
|
+
function blanked(span) {
|
|
1546
|
+
return span.replace(/[^\n]/g, ' ');
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/** Where a quoted string ends, or `start + 1` if it never closes on its line. */
|
|
1550
|
+
function quoteEnd(source, start) {
|
|
1551
|
+
const quote = source[start];
|
|
1552
|
+
for (let i = start + 1; i < source.length; i++) {
|
|
1553
|
+
const c = source[i];
|
|
1554
|
+
if (c === '\\') i++;
|
|
1555
|
+
else if (c === quote) return i + 1;
|
|
1556
|
+
else if (c === '\n') break;
|
|
1557
|
+
}
|
|
1558
|
+
return start + 1;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Where a regex literal ends, or -1 when this `/` is division. A regex cannot
|
|
1563
|
+
* span a line, so an unterminated one is division and we leave the text alone —
|
|
1564
|
+
* which bounds the damage of guessing wrong to that line.
|
|
1565
|
+
*/
|
|
1566
|
+
function regexEnd(source, start) {
|
|
1567
|
+
let before = start - 1;
|
|
1568
|
+
while (before >= 0 && /\s/.test(source[before])) before--;
|
|
1569
|
+
const prev = before < 0 ? '' : source[before];
|
|
1570
|
+
const isRegex =
|
|
1571
|
+
before < 0 ||
|
|
1572
|
+
REGEX_MAY_FOLLOW.test(prev) ||
|
|
1573
|
+
REGEX_MAY_FOLLOW_KEYWORD.test(source.slice(Math.max(0, before - 11), before + 1));
|
|
1574
|
+
if (!isRegex) return -1;
|
|
1575
|
+
let inClass = false;
|
|
1576
|
+
for (let i = start + 1; i < source.length; i++) {
|
|
1577
|
+
const c = source[i];
|
|
1578
|
+
if (c === '\\') i++;
|
|
1579
|
+
else if (c === '\n') return -1;
|
|
1580
|
+
else if (c === '[') inClass = true;
|
|
1581
|
+
else if (c === ']') inClass = false;
|
|
1582
|
+
else if (c === '/' && !inClass) return i + 1;
|
|
1583
|
+
}
|
|
1584
|
+
return -1;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* How many times an unterminated backtick may be demoted to punctuation before
|
|
1589
|
+
* we stop trying. Each demotion re-reads the tail, so this is what keeps a file
|
|
1590
|
+
* full of stray backticks linear-ish rather than quadratic.
|
|
1591
|
+
*/
|
|
1592
|
+
const MAX_TEMPLATE_RESTARTS = 8;
|
|
1593
|
+
|
|
1594
|
+
// Nothing between these characters can change the lexer's state, so the scan
|
|
1595
|
+
// jumps from one to the next rather than walking every character: a `{` only
|
|
1596
|
+
// matters inside a `${…}` substitution, which is why the top level uses the
|
|
1597
|
+
// narrower set.
|
|
1598
|
+
const NEXT_IN_CODE = /[/"'`]/g;
|
|
1599
|
+
const NEXT_IN_SUBSTITUTION = /[/"'`{}]/g;
|
|
1600
|
+
const NEXT_IN_TEMPLATE = /[\\`$]/g;
|
|
1601
|
+
|
|
1602
|
+
/** The index of the next character that matters, or -1. */
|
|
1603
|
+
function nextStop(pattern, source, from) {
|
|
1604
|
+
pattern.lastIndex = from;
|
|
1605
|
+
const m = pattern.exec(source);
|
|
1606
|
+
return m ? m.index : -1;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* The same text with comments, string bodies, regex bodies and template-literal
|
|
1611
|
+
* bodies blanked out, character for character the same length. The length is the
|
|
1612
|
+
* contract: `regionEnd` and `enclosingDeclaration` compare offsets in this copy
|
|
1613
|
+
* against offsets in the original, and everything breaks quietly if they drift.
|
|
1614
|
+
*
|
|
1615
|
+
* This is a lexer, and the state it carries is what a hand-written one usually
|
|
1616
|
+
* gets wrong: a template body may contain `${…}` substitutions holding arbitrary
|
|
1617
|
+
* code, that code may contain strings, comments, regexes and further templates,
|
|
1618
|
+
* and any of those may contain a `}` or a backtick that is text rather than
|
|
1619
|
+
* punctuation. So the open spans are kept on a stack and a template body is
|
|
1620
|
+
* blanked in one piece when its closing backtick turns up — a `}` inside a
|
|
1621
|
+
* substitution's own string then only has to be *tracked*, never separately
|
|
1622
|
+
* reasoned about.
|
|
1623
|
+
*/
|
|
1624
|
+
export function scrubForScan(input) {
|
|
1625
|
+
const source = String(input || '');
|
|
1626
|
+
const n = source.length;
|
|
1627
|
+
const out = [];
|
|
1628
|
+
let copied = 0;
|
|
1629
|
+
// Every blank goes through here, so "the copy is the same length as the
|
|
1630
|
+
// input" is a property of the construction rather than something each branch
|
|
1631
|
+
// has to remember to preserve.
|
|
1632
|
+
const blank = (from, to) => {
|
|
1633
|
+
if (to <= from || from < copied) return;
|
|
1634
|
+
out.push(source.slice(copied, from), blanked(source.slice(from, to)));
|
|
1635
|
+
copied = to;
|
|
1636
|
+
};
|
|
1637
|
+
|
|
1638
|
+
// What we are inside, innermost last: `{ text: true }` for a template's
|
|
1639
|
+
// literal text, `{ text: false, depth }` for the code of one of its `${…}`
|
|
1640
|
+
// substitutions, where `depth` counts the plain braces open inside it so the
|
|
1641
|
+
// `}` that ends the substitution is told apart from one closing an object
|
|
1642
|
+
// literal or a function body written in it.
|
|
1643
|
+
let stack = [];
|
|
1644
|
+
// Backticks demoted to punctuation because the template they opened never
|
|
1645
|
+
// closed; see the restart below.
|
|
1646
|
+
const notATemplate = new Set();
|
|
1647
|
+
let restarts = 0;
|
|
1648
|
+
let i = 0;
|
|
1649
|
+
|
|
1650
|
+
for (;;) {
|
|
1651
|
+
while (i < n) {
|
|
1652
|
+
const top = stack.length ? stack[stack.length - 1] : null;
|
|
1653
|
+
const inLiteral = top !== null;
|
|
1654
|
+
|
|
1655
|
+
if (top && top.text) {
|
|
1656
|
+
// Template text: only an escape, `${` and the closing backtick matter,
|
|
1657
|
+
// and the body is blanked as a whole when that backtick arrives.
|
|
1658
|
+
const at = nextStop(NEXT_IN_TEMPLATE, source, i);
|
|
1659
|
+
if (at === -1) break;
|
|
1660
|
+
const c = source[at];
|
|
1661
|
+
if (c === '\\') i = at + 2;
|
|
1662
|
+
else if (c === '`') {
|
|
1663
|
+
stack.pop();
|
|
1664
|
+
if (stack.length === 0) blank(top.at + 1, at);
|
|
1665
|
+
i = at + 1;
|
|
1666
|
+
} else if (source[at + 1] === '{') {
|
|
1667
|
+
stack.push({ text: false, depth: 0 });
|
|
1668
|
+
i = at + 2;
|
|
1669
|
+
} else i = at + 1;
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Code: the top level, or a substitution's expression.
|
|
1674
|
+
const at = nextStop(inLiteral ? NEXT_IN_SUBSTITUTION : NEXT_IN_CODE, source, i);
|
|
1675
|
+
if (at === -1) break;
|
|
1676
|
+
const c = source[at];
|
|
1677
|
+
|
|
1678
|
+
if (c === '/' && source[at + 1] === '/') {
|
|
1679
|
+
const eol = source.indexOf('\n', at);
|
|
1680
|
+
const end = eol === -1 ? n : eol;
|
|
1681
|
+
if (!inLiteral) blank(at, end);
|
|
1682
|
+
i = end;
|
|
1683
|
+
} else if (c === '/' && source[at + 1] === '*') {
|
|
1684
|
+
const close = source.indexOf('*/', at + 2);
|
|
1685
|
+
const end = close === -1 ? n : close + 2;
|
|
1686
|
+
if (!inLiteral) blank(at, end);
|
|
1687
|
+
i = end;
|
|
1688
|
+
} else if (c === '/') {
|
|
1689
|
+
const end = regexEnd(source, at);
|
|
1690
|
+
if (end === -1) i = at + 1;
|
|
1691
|
+
else {
|
|
1692
|
+
// Blanked like a string, because a regex is data too: `/const \{ a \}
|
|
1693
|
+
// = require\(/` reads as an import to a scanner that only sees text.
|
|
1694
|
+
if (!inLiteral) blank(at + 1, end - 1);
|
|
1695
|
+
i = end;
|
|
1696
|
+
}
|
|
1697
|
+
} else if (c === '"' || c === "'") {
|
|
1698
|
+
const end = quoteEnd(source, at);
|
|
1699
|
+
if (end <= at + 1) i = at + 1;
|
|
1700
|
+
else {
|
|
1701
|
+
// Keep the quotes, blank what is between them: a code generator that
|
|
1702
|
+
// builds `const X = require(...)` as a string is not declaring X here.
|
|
1703
|
+
if (!inLiteral) blank(at + 1, end - 1);
|
|
1704
|
+
i = end;
|
|
1705
|
+
}
|
|
1706
|
+
} else if (c === '`') {
|
|
1707
|
+
if (!notATemplate.has(at)) stack.push({ text: true, at });
|
|
1708
|
+
i = at + 1;
|
|
1709
|
+
} else if (c === '{') {
|
|
1710
|
+
if (top) top.depth++;
|
|
1711
|
+
i = at + 1;
|
|
1712
|
+
} else {
|
|
1713
|
+
if (top) {
|
|
1714
|
+
if (top.depth === 0) stack.pop();
|
|
1715
|
+
else top.depth--;
|
|
1716
|
+
}
|
|
1717
|
+
i = at + 1;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
if (stack.length === 0) break;
|
|
1722
|
+
// A backtick with no partner means we are wrong about something — and the
|
|
1723
|
+
// input is as often a fragment of an edit, where half a template literal is
|
|
1724
|
+
// normal, as it is a whole file. Running "inside a literal" to the end of
|
|
1725
|
+
// the input on that guess is what this pass used to do, and it silently cost
|
|
1726
|
+
// every declaration below the guess. So the backtick is demoted to ordinary
|
|
1727
|
+
// punctuation and the rest is re-read as code: being wrong now costs at most
|
|
1728
|
+
// the span itself, never the file.
|
|
1729
|
+
const opened = stack[0].at;
|
|
1730
|
+
stack = [];
|
|
1731
|
+
if (++restarts > MAX_TEMPLATE_RESTARTS) break;
|
|
1732
|
+
notATemplate.add(opened);
|
|
1733
|
+
i = opened + 1;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
out.push(source.slice(copied));
|
|
1737
|
+
return out.join('');
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
const EXPORT_DECL =
|
|
1741
|
+
/^[ \t]*export[ \t]+(?:default[ \t]+)?(?:declare[ \t]+)?(?:abstract[ \t]+)?(?:async[ \t]+)?(?:function[ \t]*\*?|class|interface|type|enum|const[ \t]+enum|const|let|var)[ \t]+([A-Za-z_$][\w$]*)/gm;
|
|
1742
|
+
const EXPORT_LIST = /^[ \t]*export[ \t]*(?:type[ \t]*)?\{([^}]*)\}/gm;
|
|
1743
|
+
const CJS_EXPORT = /^[ \t]*(?:module\.)?exports\.([A-Za-z_$][\w$]*)[ \t]*=/gm;
|
|
1744
|
+
const IMPORT_CLAUSE = /^[ \t]*import[ \t]+(?:type[ \t]+)?([^;'"]*?)[ \t]+from[ \t]*['"]/gm;
|
|
1745
|
+
const REQUIRE_LIST = /(?:const|let|var)[ \t]*\{([^}]*)\}[ \t]*=[ \t]*require[ \t]*\(/g;
|
|
1746
|
+
const REQUIRE_BINDING = /(?:const|let|var)[ \t]+([A-Za-z_$][\w$]*)[ \t]*=[ \t]*require[ \t]*\(/g;
|
|
1747
|
+
|
|
1748
|
+
function usableSymbol(name) {
|
|
1749
|
+
return (
|
|
1750
|
+
typeof name === 'string' &&
|
|
1751
|
+
name.length >= MIN_SYMBOL_LENGTH &&
|
|
1752
|
+
name.length <= MAX_SYMBOL_LENGTH &&
|
|
1753
|
+
name !== 'default'
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/** Split `{ a, b as c, type D }` into the names it brings into scope. */
|
|
1758
|
+
function parseNameList(inner) {
|
|
1759
|
+
const out = [];
|
|
1760
|
+
for (const raw of String(inner).split(',')) {
|
|
1761
|
+
const part = raw.trim().replace(/^type[ \t]+/, '');
|
|
1762
|
+
if (!part) continue;
|
|
1763
|
+
const pieces = part.split(/\s+as\s+/);
|
|
1764
|
+
const name = pieces[pieces.length - 1].trim();
|
|
1765
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) out.push(name);
|
|
1766
|
+
}
|
|
1767
|
+
return out;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Exported declarations with the offset they start at, in source order.
|
|
1772
|
+
* `text` must already have been through `scrubForScan`.
|
|
1773
|
+
*/
|
|
1774
|
+
function exportedDeclarations(text) {
|
|
1775
|
+
const source = String(text || '');
|
|
1776
|
+
const found = [];
|
|
1777
|
+
const add = (name, index) => {
|
|
1778
|
+
if (usableSymbol(name)) found.push({ name, index });
|
|
1779
|
+
};
|
|
1780
|
+
for (const m of source.matchAll(EXPORT_DECL)) add(m[1], m.index);
|
|
1781
|
+
for (const m of source.matchAll(EXPORT_LIST)) {
|
|
1782
|
+
for (const name of parseNameList(m[1])) add(name, m.index);
|
|
1783
|
+
}
|
|
1784
|
+
for (const m of source.matchAll(CJS_EXPORT)) add(m[1], m.index);
|
|
1785
|
+
found.sort((a, b) => a.index - b.index);
|
|
1786
|
+
return found;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Names this file pulls in from elsewhere — its side of the dependency edge.
|
|
1791
|
+
* `text` must already have been through `scrubForScan`.
|
|
1792
|
+
*/
|
|
1793
|
+
function importedNames(text) {
|
|
1794
|
+
const source = String(text || '');
|
|
1795
|
+
const names = [];
|
|
1796
|
+
for (const m of source.matchAll(IMPORT_CLAUSE)) {
|
|
1797
|
+
for (const piece of m[1].split(/,(?![^{]*\})/)) {
|
|
1798
|
+
const clause = piece.trim();
|
|
1799
|
+
if (!clause) continue;
|
|
1800
|
+
if (clause.startsWith('{')) {
|
|
1801
|
+
for (const name of parseNameList(clause.replace(/^\{|\}$/g, ''))) {
|
|
1802
|
+
if (usableSymbol(name)) names.push(name);
|
|
1803
|
+
}
|
|
1804
|
+
} else {
|
|
1805
|
+
const bare = clause.replace(/^\*[ \t]*as[ \t]+/, '');
|
|
1806
|
+
if (/^[A-Za-z_$][\w$]*$/.test(bare) && usableSymbol(bare)) names.push(bare);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
for (const m of source.matchAll(REQUIRE_LIST)) {
|
|
1811
|
+
for (const name of parseNameList(m[1])) if (usableSymbol(name)) names.push(name);
|
|
1812
|
+
}
|
|
1813
|
+
for (const m of source.matchAll(REQUIRE_BINDING)) {
|
|
1814
|
+
if (usableSymbol(m[1])) names.push(m[1]);
|
|
1815
|
+
}
|
|
1816
|
+
return names;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
function readSource(filePath) {
|
|
1820
|
+
try {
|
|
1821
|
+
const stat = fs.statSync(filePath, { throwIfNoEntry: false });
|
|
1822
|
+
if (!stat || !stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null;
|
|
1823
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
1824
|
+
} catch {
|
|
1825
|
+
return null;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* How far a declaration's region reaches: to the next exported declaration, or
|
|
1831
|
+
* to the first line that closes a top-level block, whichever comes first. The
|
|
1832
|
+
* second half is what stops an edit in a trailing helper from being credited to
|
|
1833
|
+
* the last exported function in the file.
|
|
1834
|
+
*/
|
|
1835
|
+
function regionEnd(text, start, decls) {
|
|
1836
|
+
const next = decls.find((d) => d.index > start);
|
|
1837
|
+
const limit = next ? next.index : text.length;
|
|
1838
|
+
const close = text.indexOf('\n}', start);
|
|
1839
|
+
if (close !== -1 && close < limit) {
|
|
1840
|
+
const eol = text.indexOf('\n', close + 1);
|
|
1841
|
+
return eol === -1 ? limit : eol;
|
|
1842
|
+
}
|
|
1843
|
+
return limit;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/** The declaration whose region contains `at`, or null if none does. */
|
|
1847
|
+
function declarationAt(scanned, decls, at) {
|
|
1848
|
+
let found = null;
|
|
1849
|
+
for (const decl of decls) {
|
|
1850
|
+
if (decl.index > at) break;
|
|
1851
|
+
found = decl;
|
|
1852
|
+
}
|
|
1853
|
+
if (!found) return null;
|
|
1854
|
+
return at <= regionEnd(scanned, found.index, decls) ? found.name : null;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* An edit inside a function body never repeats the `export function` line, so
|
|
1859
|
+
* the only way to know which symbol it changed is to find the new text in the
|
|
1860
|
+
* post-edit file and walk back to the declaration whose region contains it.
|
|
1861
|
+
*
|
|
1862
|
+
* Which occurrence, though: a body line duplicated across two functions used to
|
|
1863
|
+
* be credited to whichever came first, which is a confident wrong answer — the
|
|
1864
|
+
* one failure mode this design exists to avoid. So every occurrence is placed,
|
|
1865
|
+
* and unless they all land in the same declaration the edit names nobody. The
|
|
1866
|
+
* search runs over the original text, not the scrubbed copy, because an edit's
|
|
1867
|
+
* new text routinely is a comment; the offsets are the same either way.
|
|
1868
|
+
*/
|
|
1869
|
+
function enclosingDeclaration(text, scanned, decls, added) {
|
|
1870
|
+
const needle = String(added || '').trim();
|
|
1871
|
+
if (!needle) return null;
|
|
1872
|
+
const probe = needle.length > 400 ? needle.slice(0, 400) : needle;
|
|
1873
|
+
let name = null;
|
|
1874
|
+
let at = text.indexOf(probe);
|
|
1875
|
+
for (let seen = 0; at !== -1; seen++) {
|
|
1876
|
+
if (seen >= MAX_ATTRIBUTION_MATCHES) return null;
|
|
1877
|
+
const here = declarationAt(scanned, decls, at);
|
|
1878
|
+
if (here === null || (name !== null && here !== name)) return null;
|
|
1879
|
+
name = here;
|
|
1880
|
+
at = text.indexOf(probe, at + 1);
|
|
1881
|
+
}
|
|
1882
|
+
return name;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function editSegments(toolName, toolInput) {
|
|
1886
|
+
if (toolName === 'Write') return [{ added: toolInput.content, removed: '' }];
|
|
1887
|
+
const edits = Array.isArray(toolInput.edits)
|
|
1888
|
+
? toolInput.edits
|
|
1889
|
+
: [{ old_string: toolInput.old_string, new_string: toolInput.new_string }];
|
|
1890
|
+
return edits.map((e) => ({ added: e?.new_string || '', removed: e?.old_string || '' }));
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/**
|
|
1894
|
+
* The exported symbols an edit changed — the *definition* side.
|
|
1895
|
+
*
|
|
1896
|
+
* Returns null, never throws, whenever it cannot say anything useful: an
|
|
1897
|
+
* unsupported language, an unreadable file, an edit that touches no exported
|
|
1898
|
+
* API. Null means "fall back to path matching".
|
|
1899
|
+
*/
|
|
1900
|
+
export function symbolsForEdit(filePath, toolName, toolInput) {
|
|
1901
|
+
try {
|
|
1902
|
+
if (!supportsSymbols(filePath) || !toolInput) return null;
|
|
1903
|
+
const segments = editSegments(toolName, toolInput);
|
|
1904
|
+
const touched = new Set();
|
|
1905
|
+
|
|
1906
|
+
// Declarations the edit spells out itself; works with no file access at all.
|
|
1907
|
+
for (const segment of segments) {
|
|
1908
|
+
for (const decl of exportedDeclarations(scrubForScan(segment.added))) touched.add(decl.name);
|
|
1909
|
+
for (const decl of exportedDeclarations(scrubForScan(segment.removed))) touched.add(decl.name);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
const text = readSource(filePath);
|
|
1913
|
+
if (text !== null) {
|
|
1914
|
+
const scanned = scrubForScan(text);
|
|
1915
|
+
const decls = exportedDeclarations(scanned);
|
|
1916
|
+
if (decls.length) {
|
|
1917
|
+
if (toolName === 'Write') {
|
|
1918
|
+
// A Write replaces the file, so every export in it is in play.
|
|
1919
|
+
for (const decl of decls) touched.add(decl.name);
|
|
1920
|
+
} else {
|
|
1921
|
+
for (const segment of segments) {
|
|
1922
|
+
const name = enclosingDeclaration(text, scanned, decls, segment.added);
|
|
1923
|
+
if (name) touched.add(name);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
const list = [...touched].slice(0, MAX_DEF_SYMBOLS);
|
|
1930
|
+
return list.length ? list : null;
|
|
1931
|
+
} catch {
|
|
1932
|
+
return null;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* The symbols a file is bound to — what it imports plus what it exports. This
|
|
1938
|
+
* is the *reference* side: the set an about-to-edit file is checked against, so
|
|
1939
|
+
* a teammate changing `createUser` three directories away still reaches you.
|
|
1940
|
+
*/
|
|
1941
|
+
export function symbolsForFile(filePath) {
|
|
1942
|
+
try {
|
|
1943
|
+
if (!supportsSymbols(filePath)) return null;
|
|
1944
|
+
const text = readSource(filePath);
|
|
1945
|
+
if (text === null) return null;
|
|
1946
|
+
// Exports first: they are fewer and carry more weight than imports, so
|
|
1947
|
+
// when MAX_REF_SYMBOLS truncates a big file it truncates the cheaper half.
|
|
1948
|
+
const scanned = scrubForScan(text);
|
|
1949
|
+
const names = new Set();
|
|
1950
|
+
for (const decl of exportedDeclarations(scanned)) names.add(decl.name);
|
|
1951
|
+
for (const name of importedNames(scanned)) names.add(name);
|
|
1952
|
+
const list = [...names].slice(0, MAX_REF_SYMBOLS);
|
|
1953
|
+
return list.length ? list : null;
|
|
1954
|
+
} catch {
|
|
1955
|
+
return null;
|
|
1956
|
+
}
|
|
1957
|
+
}
|