huddle-cli 0.1.0 → 0.1.3

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/QUICKSTART.md +135 -0
  3. package/README.md +26 -4
  4. package/dist/adapters/amp.js +1 -1
  5. package/dist/adapters/antigravity.js +2 -0
  6. package/dist/adapters/claude.js +19 -3
  7. package/dist/adapters/codex.js +9 -1
  8. package/dist/adapters/cursor.js +12 -5
  9. package/dist/adapters/custom.js +4 -2
  10. package/dist/adapters/droid.js +1 -0
  11. package/dist/adapters/goose.js +2 -0
  12. package/dist/adapters/index.js +44 -18
  13. package/dist/adapters/kiro.js +1 -1
  14. package/dist/adapters/opencode.js +1 -1
  15. package/dist/app/live.js +142 -0
  16. package/dist/app/page.js +701 -0
  17. package/dist/app/server.js +262 -0
  18. package/dist/args.js +18 -0
  19. package/dist/attach/connect.js +7 -4
  20. package/dist/attach/guard.js +21 -2
  21. package/dist/attach/live.js +5 -1
  22. package/dist/attach/plug.js +21 -3
  23. package/dist/attach/screen.js +150 -0
  24. package/dist/attach/session.js +7 -2
  25. package/dist/cli.js +134 -42
  26. package/dist/mcp/context-tools.js +79 -0
  27. package/dist/mcp/server.js +57 -11
  28. package/dist/orchestrator/ask.js +13 -10
  29. package/dist/orchestrator/brain.js +16 -4
  30. package/dist/orchestrator/budget.js +283 -0
  31. package/dist/orchestrator/busy.js +106 -0
  32. package/dist/orchestrator/council.js +434 -87
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +2 -2
  35. package/dist/orchestrator/launcher.js +31 -13
  36. package/dist/orchestrator/learn.js +101 -0
  37. package/dist/orchestrator/planner.js +54 -9
  38. package/dist/orchestrator/qa.js +71 -15
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +575 -50
  41. package/dist/orchestrator/takeover.js +155 -0
  42. package/dist/platform.js +377 -0
  43. package/dist/room/context.js +1285 -0
  44. package/dist/room/contract.js +132 -0
  45. package/dist/room/db.js +79 -16
  46. package/dist/room/followup.js +233 -0
  47. package/dist/room/identity.js +2 -2
  48. package/dist/room/lessons.js +275 -0
  49. package/dist/room/live.js +75 -20
  50. package/dist/room/mail.js +225 -0
  51. package/dist/room/progress.js +40 -1
  52. package/dist/room/server.js +220 -39
  53. package/dist/room/tunnel.js +6 -5
  54. package/dist/room/user.js +54 -0
  55. package/dist/sandbox/sync.js +4 -4
  56. package/dist/sandbox/worktree.js +145 -14
  57. package/dist/ui/app.js +963 -159
  58. package/dist/ui/brief.js +494 -0
  59. package/dist/ui/catalog.js +62 -24
  60. package/dist/ui/dashboard.js +269 -55
  61. package/dist/ui/doctor.js +15 -11
  62. package/dist/ui/export.js +1194 -0
  63. package/dist/ui/lessons.js +81 -0
  64. package/dist/ui/narrator.js +56 -0
  65. package/dist/ui/paste.js +515 -0
  66. package/dist/ui/remote.js +11 -5
  67. package/dist/ui/setup.js +152 -0
  68. package/dist/ui/story.js +716 -94
  69. package/dist/ui/talk.js +176 -0
  70. package/dist/ui/term.js +182 -16
  71. package/dist/ui/transcript.js +434 -0
  72. package/dist/ui/watch.js +149 -48
  73. package/package.json +6 -3
@@ -0,0 +1,719 @@
1
+ // Integration radar: catch "claude named it XYZ, codex named it ABC" while
2
+ // they work, not at merge time.
3
+ //
4
+ // Every tick it snapshots each in-flight worktree into a throwaway WIP commit
5
+ // (a temp index, so the agent's own index and files are never touched), then
6
+ // for every pair of agents:
7
+ // overlap — `git merge-tree` of the two WIPs: the files/lines the merge
8
+ // would conflict on, told to both while they can still agree;
9
+ // mismatch — B imports name N from module M, A is changing M and A's M
10
+ // doesn't define N: told to both, with A's closest export.
11
+ // Findings are deduped (re-alerted only after 3 unresolved ticks) and capped
12
+ // at 2 messages per agent per tick. Never runs tests; skips unchanged
13
+ // snapshots; anything that fails just means no finding this tick.
14
+ import { execFile } from "node:child_process";
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ /** Re-alert an unresolved finding after this many ticks. */
19
+ export const REALERT_TICKS = 3;
20
+ /** Messages one agent gets from the radar per tick, at most. */
21
+ export const MAX_MSGS_PER_TICK = 2;
22
+ const MAX_FILES = 150; // changed files parsed per agent per tick
23
+ const MAX_BLOB = 200_000; // bytes of one file we bother parsing
24
+ const MAX_TOTAL = 2_000_000; // bytes parsed per agent per tick
25
+ /**
26
+ * HUDDLE_RADAR_SEC: seconds between ticks; "off" or 0 = no radar (null).
27
+ * Default 20s: found live (capstone round 4), tasks took 40s-2m, so at 45s a
28
+ * short task could start and land between two ticks without being looked at.
29
+ */
30
+ export function radarIntervalFromEnv() {
31
+ const raw = (process.env.HUDDLE_RADAR_SEC ?? "").trim().toLowerCase();
32
+ if (raw === "off" || raw === "0")
33
+ return null;
34
+ const s = Number(raw);
35
+ return raw && Number.isFinite(s) && s > 0 ? s * 1000 : 20_000;
36
+ }
37
+ function run(cwd, args, env) {
38
+ return new Promise((resolve) => {
39
+ execFile("git", args,
40
+ // No optional locks: a status-style refresh must never take the agent's index lock.
41
+ { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", ...env } }, (err, stdout) => resolve({ code: err ? (typeof err.code === "number" ? err.code : 128) : 0, out: String(stdout ?? "") }));
42
+ });
43
+ }
44
+ async function git(cwd, args, env) {
45
+ const r = await run(cwd, args, env);
46
+ return r.code === 0 ? r.out.trim() : null;
47
+ }
48
+ /**
49
+ * `git cat-file --batch(-check)` over many `rev:path` specs in one process:
50
+ * sizes (check) or utf8 contents, null for a missing object; null if git failed.
51
+ */
52
+ function catBatch(cwd, specs, check) {
53
+ if (!specs.length)
54
+ return Promise.resolve([]);
55
+ return new Promise((resolve) => {
56
+ const child = execFile("git", ["cat-file", check ? "--batch-check=%(objecttype) %(objectsize)" : "--batch"], { cwd, encoding: "buffer", maxBuffer: 2 * MAX_TOTAL + 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } }, (err, stdout) => {
57
+ if (err)
58
+ return resolve(null);
59
+ const buf = stdout;
60
+ const out = [];
61
+ let at = 0;
62
+ for (let i = 0; i < specs.length; i++) {
63
+ const nl = buf.indexOf(10, at);
64
+ if (nl < 0)
65
+ return resolve(null);
66
+ const head = buf.subarray(at, nl).toString("utf8");
67
+ at = nl + 1;
68
+ const m = head.match(/(\S+) (\d+)$/);
69
+ if (!m || head.endsWith(" missing") || head.endsWith(" ambiguous")) {
70
+ out.push(null);
71
+ continue;
72
+ }
73
+ if (check) {
74
+ out.push(m[1] === "blob" ? m[2] : null);
75
+ continue;
76
+ }
77
+ const size = Number(m[2]);
78
+ out.push(m[1] === "blob" ? buf.subarray(at, at + size).toString("utf8") : null);
79
+ at += size + 1; // the content, then a newline
80
+ }
81
+ resolve(out);
82
+ });
83
+ child.stdin?.on("error", () => { }); // git gone early: the callback reports it
84
+ child.stdin?.end(specs.join("\n") + "\n");
85
+ });
86
+ }
87
+ const IDENT = {
88
+ GIT_AUTHOR_NAME: "huddle-radar",
89
+ GIT_AUTHOR_EMAIL: "radar@huddle.local",
90
+ GIT_COMMITTER_NAME: "huddle-radar",
91
+ GIT_COMMITTER_EMAIL: "radar@huddle.local",
92
+ };
93
+ /**
94
+ * The worktree's files as a WIP commit, via a separate index — git add -A
95
+ * respects .gitignore; the Plug wiring is left out like the merge commit does.
96
+ * `indexFile` is kept between ticks so unchanged files aren't re-hashed; it
97
+ * starts as a copy of the agent's index (its stat cache), never the original.
98
+ */
99
+ export async function snapshot(wt, mainSha, excludes = [], indexFile) {
100
+ if (!fs.existsSync(wt))
101
+ return null;
102
+ const head = await git(wt, ["rev-parse", "HEAD"]);
103
+ if (!head)
104
+ return null;
105
+ // Mid-merge/rebase (a resolver at work): the markers would be noise.
106
+ for (const p of ["MERGE_HEAD", "rebase-merge", "rebase-apply"]) {
107
+ const gp = await git(wt, ["rev-parse", "--path-format=absolute", "--git-path", p]);
108
+ if (gp && fs.existsSync(gp))
109
+ return null;
110
+ }
111
+ const dir = indexFile ? null : fs.mkdtempSync(path.join(os.tmpdir(), "huddle-radar-"));
112
+ const tmpIndex = indexFile ?? path.join(dir, "index");
113
+ try {
114
+ if (!fs.existsSync(tmpIndex)) {
115
+ const idx = await git(wt, ["rev-parse", "--path-format=absolute", "--git-path", "index"]);
116
+ if (idx && fs.existsSync(idx)) {
117
+ fs.copyFileSync(idx, tmpIndex);
118
+ // Keep the original's mtime: git trusts an entry's stat data only if the
119
+ // file is older than the index, so a fresh mtime on the copy would hide
120
+ // an edit made in the same second the agent's index was written.
121
+ const st = fs.statSync(idx);
122
+ fs.utimesSync(tmpIndex, st.atime, st.mtime);
123
+ }
124
+ }
125
+ const env = { GIT_INDEX_FILE: tmpIndex };
126
+ let live = excludes.filter((e) => e && !e.includes("..") && !path.isAbsolute(e));
127
+ // Excluding a path git already ignores makes `git add` fail outright
128
+ // (see prepareMerge): those are left out by .gitignore anyway.
129
+ if (live.length) {
130
+ const ignored = new Set(((await run(wt, ["check-ignore", "-z", "--", ...live])).out).split("\0").filter(Boolean));
131
+ live = live.filter((e) => !ignored.has(e));
132
+ }
133
+ // --ignore-errors: one unreadable file or a nested repo without commits
134
+ // (create-next-app before its first commit) mustn't blind the radar.
135
+ const add = await run(wt, ["add", "-A", "--ignore-errors", "--", ".", ...live.map((e) => `:!${e}`)], env);
136
+ if (add.code !== 0 && add.code !== 1)
137
+ return null;
138
+ const tree = await git(wt, ["write-tree"], env);
139
+ if (!tree)
140
+ return null;
141
+ // Deterministic dates: the same tree on the same head is the same commit sha.
142
+ const wip = await git(wt, ["commit-tree", tree, "-p", head, "-m", "huddle radar wip"], {
143
+ ...IDENT,
144
+ GIT_AUTHOR_DATE: "1700000000 +0000",
145
+ GIT_COMMITTER_DATE: "1700000000 +0000",
146
+ });
147
+ if (!wip)
148
+ return null;
149
+ const base = (await git(wt, ["merge-base", wip, mainSha])) ?? head;
150
+ return { head, tree, wip, base };
151
+ }
152
+ finally {
153
+ if (dir)
154
+ fs.rmSync(dir, { recursive: true, force: true });
155
+ }
156
+ }
157
+ // ---- parsing ---------------------------------------------------------------
158
+ const JS = /\.(m|c)?(j|t)sx?$/;
159
+ const PY = /\.py$/;
160
+ const codeFile = (f) => JS.test(f) || PY.test(f);
161
+ /** Names a file defines/exports; null = can't tell (export *, from x import *). */
162
+ export function definedNames(file, text) {
163
+ const out = new Set();
164
+ if (PY.test(file)) {
165
+ for (const m of text.matchAll(/^(?:async\s+)?(?:def|class)\s+([A-Za-z_]\w*)/gm))
166
+ out.add(m[1]);
167
+ for (const m of text.matchAll(/^([A-Za-z_]\w*)\s*(?::[^=\n]+)?=(?!=)/gm))
168
+ out.add(m[1]);
169
+ if (/^from\s+\S+\s+import\s+\*/m.test(text))
170
+ return null;
171
+ // Top-level imports are names in the module too (re-exports).
172
+ for (const m of text.matchAll(/^from\s+\S+\s+import\s+(\([^)]*\)|[^\n]+)/gm))
173
+ for (const n of splitNames(m[1]))
174
+ out.add(n.alias ?? n.name);
175
+ for (const m of text.matchAll(/^import\s+([^\n]+)/gm))
176
+ for (const part of m[1].split(",")) {
177
+ const mm = part.trim().match(/^([\w.]+)(?:\s+as\s+(\w+))?/);
178
+ if (mm)
179
+ out.add(mm[2] ?? mm[1].split(".")[0]);
180
+ }
181
+ return out;
182
+ }
183
+ if (/^\s*export\s+\*\s+from/m.test(text))
184
+ return null;
185
+ for (const m of text.matchAll(/^\s*export\s+(?:declare\s+)?(?:default\s+)?(?:async\s+)?(?:abstract\s+)?(?:function\*?|const|let|var|class|interface|type|enum|namespace)\s+([A-Za-z_$][\w$]*)/gm))
186
+ out.add(m[1]);
187
+ if (/^\s*export\s+default\b/m.test(text))
188
+ out.add("default");
189
+ for (const m of text.matchAll(/^\s*export\s+(?:type\s+)?\{([^}]*)\}/gm))
190
+ for (const n of splitNames(m[1]))
191
+ out.add(n.alias ?? n.name);
192
+ // CommonJS
193
+ for (const m of text.matchAll(/(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=/g))
194
+ out.add(m[1]);
195
+ for (const m of text.matchAll(/module\.exports\s*=\s*\{([^}]*)\}/g))
196
+ for (const part of m[1].split(",")) {
197
+ const k = part.trim().match(/^(?:async\s+)?([A-Za-z_$][\w$]*)/);
198
+ if (k)
199
+ out.add(k[1]);
200
+ }
201
+ if (/module\.exports\s*=(?!\s*\{)/.test(text))
202
+ return null; // module.exports = someObject: opaque
203
+ return out;
204
+ }
205
+ function splitNames(list) {
206
+ return list
207
+ .replace(/\/\*[\s\S]*?\*\//g, "")
208
+ .replace(/(\/\/|#)[^\n]*/g, "")
209
+ .replace(/[()]/g, "")
210
+ .split(",")
211
+ .map((s) => s.trim().replace(/^type\s+/, ""))
212
+ .filter(Boolean)
213
+ .map((s) => {
214
+ const m = s.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
215
+ return m ? { name: m[1], alias: m[2] } : null;
216
+ })
217
+ .filter((x) => !!x);
218
+ }
219
+ /** Named imports from local modules (relative JS specs, any Python from-import). */
220
+ export function importedNames(file, text) {
221
+ const out = [];
222
+ if (PY.test(file)) {
223
+ for (const m of text.matchAll(/^\s*from\s+([.\w]+)\s+import\s+(\([^)]*\)|[^\n]+)/gm)) {
224
+ const names = splitNames(m[2]).map((n) => n.name);
225
+ if (names.length)
226
+ out.push({ module: m[1], names });
227
+ }
228
+ return out;
229
+ }
230
+ for (const m of text.matchAll(/import\s+(?:type\s+)?(?:[A-Za-z_$][\w$]*\s*,\s*)?\{([^}]*)\}\s*from\s*["']([^"']+)["']/g))
231
+ if (m[2].startsWith("."))
232
+ out.push({ module: m[2], names: splitNames(m[1]).map((n) => n.name) });
233
+ for (const m of text.matchAll(/\{([^}]*)\}\s*=\s*require\(\s*["']([^"']+)["']\s*\)/g))
234
+ if (m[2].startsWith("."))
235
+ out.push({
236
+ module: m[2],
237
+ names: m[1]
238
+ .split(",")
239
+ .map((s) => s.trim().split(/\s*:\s*/)[0])
240
+ .filter((s) => /^[A-Za-z_$][\w$]*$/.test(s)),
241
+ });
242
+ return out.filter((r) => r.names.length);
243
+ }
244
+ /** Repo paths a module spec could mean, imported from `fromFile`. */
245
+ export function candidatesFor(fromFile, spec) {
246
+ const dir = path.posix.dirname(fromFile);
247
+ if (PY.test(fromFile)) {
248
+ const lead = spec.match(/^\.*/)[0].length;
249
+ const rest = spec.slice(lead).split(".").filter(Boolean).join("/");
250
+ const roots = lead ? [path.posix.join(dir, ...Array(Math.max(0, lead - 1)).fill(".."))] : [".", dir];
251
+ return roots.flatMap((r) => (rest ? [path.posix.join(r, rest + ".py"), path.posix.join(r, rest, "__init__.py")] : [path.posix.join(r, "__init__.py")]));
252
+ }
253
+ const p = path.posix.normalize(path.posix.join(dir, spec));
254
+ const stem = p.replace(/\.(m|c)?(j|t)sx?$/, "");
255
+ const exts = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"];
256
+ return [p, ...exts.map((e) => stem + e), ...exts.map((e) => `${p}/index${e}`)];
257
+ }
258
+ export function editDistance(a, b) {
259
+ const d = Array.from({ length: b.length + 1 }, (_, i) => i);
260
+ for (let i = 1; i <= a.length; i++) {
261
+ let prev = d[0];
262
+ d[0] = i;
263
+ for (let j = 1; j <= b.length; j++) {
264
+ const t = d[j];
265
+ d[j] = Math.min(d[j] + 1, d[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
266
+ prev = t;
267
+ }
268
+ }
269
+ return d[b.length];
270
+ }
271
+ /** The defined name closest to `n` (case-insensitive match first), if any is close-ish. */
272
+ export function nearest(n, names) {
273
+ let best = null;
274
+ let score = Infinity;
275
+ const lo = n.toLowerCase();
276
+ for (const c of names) {
277
+ if (c === "default")
278
+ continue;
279
+ const s = c.toLowerCase() === lo ? 0 : editDistance(lo, c.toLowerCase());
280
+ if (s < score)
281
+ [best, score] = [c, s];
282
+ }
283
+ return best !== null && score <= Math.max(3, Math.ceil(n.length / 2)) ? best : null;
284
+ }
285
+ /** The function/class a line sits in, looking upward. */
286
+ function symbolAt(lines, i) {
287
+ const decl = /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?\s+([A-Za-z_$][\w$]*)|class\s+([A-Za-z_$][\w$]*)|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=|def\s+([A-Za-z_]\w*)|(?:interface|type)\s+([A-Za-z_$][\w$]*))/;
288
+ // The conflict may start on the declaration itself (both rewrote the signature).
289
+ for (let j = i + 1; j < Math.min(lines.length, i + 4) && !/^(=======|\|\|\|\|\|\|\|)/.test(lines[j]); j++) {
290
+ const m = lines[j].match(decl);
291
+ if (m)
292
+ return m.slice(1).find(Boolean) ?? null;
293
+ }
294
+ for (let j = i - 1; j >= 0 && j > i - 200; j--) {
295
+ if (/^(<<<<<<<|=======|>>>>>>>)/.test(lines[j]))
296
+ continue;
297
+ const m = lines[j].match(decl);
298
+ if (m)
299
+ return m.slice(1).find(Boolean) ?? null;
300
+ }
301
+ return null;
302
+ }
303
+ /**
304
+ * Every identifier in a file outside comments. A name that's in there at all
305
+ * (`export const { a, b } = x`, a try/except import, `module.exports = obj`)
306
+ * is too unclear to call missing: the radar stays quiet rather than send an
307
+ * agent off renaming working code.
308
+ */
309
+ export function wordsIn(file, text) {
310
+ const bare = PY.test(file) ? text.replace(/#[^\n]*/g, "") : text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
311
+ return new Set(bare.match(/[A-Za-z_$][\w$]*/g) ?? []);
312
+ }
313
+ // Temp index dirs of radars not stopped yet: removed if the process exits first.
314
+ const liveDirs = new Set();
315
+ function rmLiveDirs() {
316
+ for (const d of liveDirs)
317
+ fs.rmSync(d, { recursive: true, force: true });
318
+ liveDirs.clear();
319
+ }
320
+ export function createRadar(o) {
321
+ const agents = new Map(); // agent id -> last analysis
322
+ const pairCache = new Map();
323
+ const seen = new Map(); // finding key -> when last sent
324
+ let tickNo = 0;
325
+ // What it did, for the record (radar.stats): found live, "0 findings" couldn't tell a quiet radar from one that never looked.
326
+ const stats = { ticks: 0, looks: 0, pairs: 0, findings: 0 };
327
+ let busy = false;
328
+ let stopped = false;
329
+ // One index per worktree, kept across ticks (stat cache); removed on stop.
330
+ let indexDir = null;
331
+ const indexFor = (wt) => {
332
+ if (!indexDir) {
333
+ indexDir = fs.mkdtempSync(path.join(os.tmpdir(), "huddle-radar-"));
334
+ if (!liveDirs.size)
335
+ process.once("exit", rmLiveDirs); // one listener however many radars
336
+ liveDirs.add(indexDir);
337
+ }
338
+ return path.join(indexDir, wt.replace(/[^\w.-]/g, "_").slice(-120) + ".index");
339
+ };
340
+ const cleanup = () => {
341
+ if (!indexDir)
342
+ return;
343
+ fs.rmSync(indexDir, { recursive: true, force: true });
344
+ liveDirs.delete(indexDir);
345
+ indexDir = null;
346
+ if (!liveDirs.size)
347
+ process.off("exit", rmLiveDirs);
348
+ };
349
+ const analyze = async (a, mainSha) => {
350
+ const snap = await snapshot(a.worktree, mainSha, a.excludes, indexFor(a.worktree));
351
+ if (!snap)
352
+ return null;
353
+ const prev = agents.get(a.agentId);
354
+ if (prev && prev.snap.wip === snap.wip && prev.snap.base === snap.base)
355
+ return prev; // unchanged: no re-parse
356
+ const names = (await run(a.worktree, ["diff", "--name-only", "--no-renames", "-z", snap.base, snap.wip])).out;
357
+ const changed = names.split("\0").filter((f) => f && codeFile(f)).slice(0, MAX_FILES);
358
+ const st = { snap, changed, defs: new Map(), words: new Map(), imports: new Map() };
359
+ // Two git processes for all files (sizes, then contents), not two per file.
360
+ const files = changed.filter((f) => !f.includes("\n"));
361
+ const sizes = await catBatch(a.worktree, files.map((f) => `${snap.wip}:${f}`), true);
362
+ if (!sizes || stopped)
363
+ return null;
364
+ const want = [];
365
+ let total = 0;
366
+ files.forEach((f, i) => {
367
+ const size = sizes[i] === null ? 0 : Number(sizes[i]);
368
+ if (!(size > 0))
369
+ st.defs.set(f, new Set()); // deleted in this WIP: defines nothing
370
+ else if (size > MAX_BLOB || total + size > MAX_TOTAL)
371
+ st.defs.set(f, null); // too big to read: unknown
372
+ else {
373
+ total += size;
374
+ want.push(f);
375
+ }
376
+ });
377
+ const texts = await catBatch(a.worktree, want.map((f) => `${snap.wip}:${f}`), false);
378
+ if (!texts)
379
+ return null;
380
+ want.forEach((f, i) => {
381
+ const text = texts[i];
382
+ if (text === null)
383
+ return void st.defs.set(f, null);
384
+ st.defs.set(f, definedNames(f, text));
385
+ st.words.set(f, wordsIn(f, text));
386
+ st.imports.set(f, importedNames(f, text));
387
+ });
388
+ return st;
389
+ };
390
+ const owner = (file, a, b, ca, cb) => {
391
+ try {
392
+ const claim = o.db
393
+ .listClaims()
394
+ .find((c) => (c.path === file || c.path.startsWith(file + "::")) && (c.held_by === a.agentId || c.held_by === b.agentId));
395
+ if (claim)
396
+ return claim.held_by === a.agentId ? a : b;
397
+ }
398
+ catch {
399
+ /* no claims: fall through */
400
+ }
401
+ return cb > ca ? b : a; // whoever changed more of it
402
+ };
403
+ const linesChanged = async (s, wt, file) => {
404
+ const r = await git(wt, ["diff", "--numstat", s.snap.base, s.snap.wip, "--", file]);
405
+ const m = r?.match(/^(\d+)\s+(\d+)/);
406
+ return m ? Number(m[1]) + Number(m[2]) : 0;
407
+ };
408
+ const overlaps = async (a, sa, b, sb) => {
409
+ // -z: paths with spaces or non-ASCII come back as-is, not C-quoted.
410
+ const r = await run(o.repoRoot, ["merge-tree", "--write-tree", "--name-only", "--no-messages", "-z", sa.snap.wip, sb.snap.wip]);
411
+ if (r.code !== 1)
412
+ return []; // 0 = clean; anything else = couldn't tell
413
+ const [tree, ...files] = r.out.split("\0").filter(Boolean);
414
+ const out = [];
415
+ for (const file of [...new Set(files)].slice(0, 5)) {
416
+ const merged = (await run(o.repoRoot, ["cat-file", "blob", `${tree}:${file}`])).out;
417
+ const lines = merged.split("\n");
418
+ const hunks = [];
419
+ let extra = 0; // marker + other-side lines before this point, so line numbers match the file
420
+ for (let i = 0, side = 0; i < lines.length; i++) {
421
+ if (lines[i].startsWith("<<<<<<<")) {
422
+ hunks.push({ line: i + 1 - extra, sym: symbolAt(lines, i) });
423
+ side = 1;
424
+ extra++;
425
+ }
426
+ else if (side && /^(\|\|\|\|\|\|\||=======)/.test(lines[i])) {
427
+ side = 2;
428
+ extra++;
429
+ }
430
+ else if (side && lines[i].startsWith(">>>>>>>")) {
431
+ side = 0;
432
+ extra++;
433
+ }
434
+ else if (side === 2)
435
+ extra++;
436
+ }
437
+ const syms = [...new Set(hunks.map((h) => h.sym).filter(Boolean))];
438
+ const where = hunks.length
439
+ ? `${file} ${syms.length ? syms.join(", ") + " " : ""}(line${hunks.length > 1 ? "s" : ""} ${hunks.map((h) => h.line).join(", ")})`
440
+ : file;
441
+ const own = owner(file, a, b, await linesChanged(sa, a.worktree, file), await linesChanged(sb, b.worktree, file));
442
+ const msg = (me, them) => `Huddle radar: ${them.agentId} (task ${them.taskId}) and you both changed ${where} — that would conflict at merge. ` +
443
+ `${own.agentId === me.agentId ? "You" : own.agentId} should own it; agree now via huddle_send(to="${them.agentId}") and the other side builds on it.`;
444
+ out.push({
445
+ kind: "overlap",
446
+ key: `overlap|${[a.agentId, b.agentId].sort().join("+")}|${file}|${syms.join(",")}`,
447
+ agent: a.agentId,
448
+ with: b.agentId,
449
+ task: a.taskId,
450
+ detail: `${where} (owner: ${own.agentId})`,
451
+ messages: { [a.agentId]: msg(a, b), [b.agentId]: msg(b, a) },
452
+ });
453
+ }
454
+ return out;
455
+ };
456
+ /** A Python package's submodule (`from . import store`) is not a name its __init__ must define. */
457
+ const isSubmodule = async (target, n, wips) => {
458
+ if (!/(^|\/)__init__\.py$/.test(target))
459
+ return false;
460
+ const dir = path.posix.dirname(target);
461
+ const paths = [`${n}.py`, `${n}/__init__.py`].map((p) => (dir === "." ? p : `${dir}/${p}`));
462
+ for (const w of wips)
463
+ for (const p of paths)
464
+ if ((await run(o.repoRoot, ["cat-file", "-e", `${w}:${p}`])).code === 0)
465
+ return true;
466
+ return false;
467
+ };
468
+ /** importer's imports against definer's changed modules. */
469
+ const mismatches = async (imp, si, def, sd) => {
470
+ const out = [];
471
+ const theirs = new Set(sd.changed);
472
+ for (const [file, refs] of si.imports)
473
+ for (const ref of refs) {
474
+ const target = candidatesFor(file, ref.module).find((c) => theirs.has(c));
475
+ if (!target)
476
+ continue;
477
+ const names = sd.defs.get(target);
478
+ if (!names || !names.size)
479
+ continue; // unknown/opaque, or they deleted it: not a naming question
480
+ // The importer also edited that module and defines the name there itself: the merge carries it.
481
+ const mine = si.defs.get(target);
482
+ const words = sd.words.get(target);
483
+ for (const n of ref.names) {
484
+ if (names.has(n) || mine?.has(n) || words?.has(n))
485
+ continue;
486
+ if (await isSubmodule(target, n, [sd.snap.wip, si.snap.wip]))
487
+ continue;
488
+ const near = nearest(n, names);
489
+ const has = near ? `exports ${near}` : `exports ${[...names].filter((x) => x !== "default").slice(0, 3).join(", ") || "no such name"}`;
490
+ const fix = near ? `one of you rename now (${n} or ${near}) and tell the other via huddle_send` : `agree on the name now via huddle_send`;
491
+ const msg = (them) => `Huddle radar: ${imp.agentId} imports ${n} from ${target}, but ${def.agentId}'s ${target} ${has} — ${fix}${them ? ` (to="${them}")` : ""}.`;
492
+ out.push({
493
+ kind: "mismatch",
494
+ key: `mismatch|${imp.agentId}|${def.agentId}|${target}|${n}`,
495
+ agent: imp.agentId,
496
+ with: def.agentId,
497
+ task: imp.taskId,
498
+ detail: `${imp.agentId} imports ${n} from ${target}; ${def.agentId}'s ${target} ${has}`,
499
+ messages: { [imp.agentId]: msg(def.agentId), [def.agentId]: msg(imp.agentId) },
500
+ });
501
+ }
502
+ }
503
+ return out;
504
+ };
505
+ /**
506
+ * A defined name that's a near-miss of a contract name (case, or a typo on
507
+ * a 5+ letter name) while the contract's spelling appears nowhere in the
508
+ * agent's work: told to that agent only. Short names need an exact
509
+ * case-fold match (id vs ID, not id vs ix). Worded as a question — it may
510
+ * be a separate helper — so nobody renames on the radar's say-so alone.
511
+ */
512
+ const contractDrift = (a, s, contract) => {
513
+ if (!contract.length)
514
+ return [];
515
+ const out = [];
516
+ const everywhere = new Set();
517
+ for (const w of s.words.values())
518
+ for (const x of w)
519
+ everywhere.add(x);
520
+ const close = (x, n) => {
521
+ const [lx, ln] = [x.toLowerCase(), n.toLowerCase()];
522
+ if (lx === ln)
523
+ return true;
524
+ const d = editDistance(lx, ln);
525
+ return (Math.min(x.length, n.length) >= 5 && d === 1) || (Math.min(x.length, n.length) >= 10 && d === 2);
526
+ };
527
+ for (const [file, names] of s.defs) {
528
+ if (!names)
529
+ continue;
530
+ for (const n of names) {
531
+ if (n === "default" || contract.includes(n))
532
+ continue;
533
+ const c = contract.find((x) => close(x, n));
534
+ if (!c || everywhere.has(c))
535
+ continue;
536
+ out.push({
537
+ kind: "mismatch",
538
+ key: `contract|${a.agentId}|${file}|${n}|${c}`,
539
+ agent: a.agentId,
540
+ with: "contract",
541
+ task: a.taskId,
542
+ detail: `${file} defines ${n}; the contract says ${c}`,
543
+ messages: { [a.agentId]: `Huddle radar: your ${file} defines ${n}, but the shared contract names it ${c} — if it's meant to be the contract's ${c}, rename it now (or amend the contract) so the others' code matches.` },
544
+ });
545
+ }
546
+ }
547
+ return out;
548
+ };
549
+ /** One pass; returns the findings sent (or re-sent) this tick. */
550
+ const tick = async () => {
551
+ if (busy || stopped)
552
+ return [];
553
+ busy = true;
554
+ try {
555
+ tickNo++;
556
+ let raw = [];
557
+ try {
558
+ raw = o.inFlight() ?? [];
559
+ }
560
+ catch {
561
+ /* the board's state mid-change: try next tick */
562
+ }
563
+ const fly = raw.filter((x, i, all) => x && x.agentId && x.worktree && all.findIndex((y) => y?.agentId === x.agentId) === i);
564
+ for (const id of agents.keys())
565
+ if (!fly.some((f) => f.agentId === id))
566
+ agents.delete(id);
567
+ for (const pk of pairCache.keys())
568
+ if (!pk.split("|").every((id) => fly.some((f) => f.agentId === id)))
569
+ pairCache.delete(pk);
570
+ stats.ticks++;
571
+ if (fly.length < 2)
572
+ return [];
573
+ const mainSha = await git(o.repoRoot, ["rev-parse", "HEAD"]);
574
+ if (!mainSha)
575
+ return [];
576
+ const live = [];
577
+ for (const f of fly) {
578
+ if (stopped)
579
+ return [];
580
+ const s = await analyze(f, mainSha).catch(() => null);
581
+ if (s) {
582
+ agents.set(f.agentId, s);
583
+ live.push({ f, s });
584
+ }
585
+ }
586
+ if (live.length >= 2) {
587
+ stats.looks++;
588
+ stats.pairs += (live.length * (live.length - 1)) / 2;
589
+ }
590
+ const found = [];
591
+ for (let i = 0; i < live.length; i++)
592
+ for (let j = i + 1; j < live.length; j++) {
593
+ if (stopped)
594
+ return [];
595
+ const [A, B] = [live[i], live[j]];
596
+ const pk = `${A.f.agentId}|${B.f.agentId}`;
597
+ const sig = `${A.s.snap.wip}:${A.s.snap.base}|${B.s.snap.wip}:${B.s.snap.base}`;
598
+ let c = pairCache.get(pk);
599
+ if (!c || c.sig !== sig) {
600
+ const fresh = [
601
+ ...(await overlaps(A.f, A.s, B.f, B.s).catch(() => [])),
602
+ ...(await mismatches(A.f, A.s, B.f, B.s).catch(() => [])),
603
+ ...(await mismatches(B.f, B.s, A.f, A.s).catch(() => [])),
604
+ ];
605
+ c = { sig, findings: fresh };
606
+ pairCache.set(pk, c);
607
+ }
608
+ found.push(...c.findings);
609
+ }
610
+ let contract = [];
611
+ try {
612
+ const names = o.contractNames?.();
613
+ contract = Array.isArray(names) ? names.filter((x) => typeof x === "string" && /^[A-Za-z_$][\w$]*$/.test(x)) : [];
614
+ }
615
+ catch {
616
+ /* no contract */
617
+ }
618
+ for (const { f, s } of live)
619
+ found.push(...contractDrift(f, s, contract));
620
+ // Resolved findings are forgotten, so a later reappearance is news again —
621
+ // but only when both sides were looked at: a snapshot that failed this
622
+ // tick (an agent mid-rebase) says nothing about whether it's fixed.
623
+ const now = new Set(found.map((x) => x.key));
624
+ const looked = new Set(["contract", ...live.map((l) => l.f.agentId)]);
625
+ const keyAgents = (k) => {
626
+ const [kind, x, y] = k.split("|");
627
+ return kind === "overlap" ? x.split("+") : kind === "contract" ? [x] : [x, y];
628
+ };
629
+ for (const k of seen.keys())
630
+ if (!now.has(k) && keyAgents(k).every((id) => looked.has(id))) {
631
+ seen.delete(k);
632
+ try {
633
+ o.resolved?.(k);
634
+ }
635
+ catch {
636
+ /* a bookkeeping error never stops the radar */
637
+ }
638
+ }
639
+ if (stopped)
640
+ return []; // the board finished mid-tick: say nothing
641
+ const quota = new Map();
642
+ const sent = [];
643
+ // New findings before reminders, overlaps before mismatches.
644
+ const rank = (x) => (seen.has(x.key) ? 2 : 0) + (x.kind === "overlap" ? 0 : 1);
645
+ const order = [...found].sort((x, y) => rank(x) - rank(y));
646
+ for (const fd of order) {
647
+ const s = seen.get(fd.key);
648
+ if (s && tickNo - s.sentTick < REALERT_TICKS)
649
+ continue;
650
+ const to = Object.keys(fd.messages);
651
+ if (to.some((r) => (quota.get(r) ?? 0) >= MAX_MSGS_PER_TICK))
652
+ continue; // next tick
653
+ for (const r of to) {
654
+ quota.set(r, (quota.get(r) ?? 0) + 1);
655
+ try {
656
+ o.notify(r, fd.messages[r], fd.key);
657
+ }
658
+ catch {
659
+ /* an unreachable inbox never breaks a run */
660
+ }
661
+ }
662
+ if (!s) {
663
+ stats.findings++;
664
+ const ev = { type: "radar", agent: fd.agent, with: fd.with, task: fd.task, kind: fd.kind, detail: fd.detail };
665
+ try {
666
+ o.db.publish("radar.finding", { agent_id: fd.agent, task_id: fd.task, payload: { ...ev, key: fd.key } });
667
+ }
668
+ catch {
669
+ /* closed db */
670
+ }
671
+ try {
672
+ o.onEvent(ev);
673
+ }
674
+ catch {
675
+ /* a renderer bug never stops the radar */
676
+ }
677
+ }
678
+ seen.set(fd.key, { sentTick: tickNo });
679
+ sent.push(fd);
680
+ }
681
+ return sent;
682
+ }
683
+ finally {
684
+ busy = false;
685
+ if (stopped)
686
+ cleanup();
687
+ }
688
+ };
689
+ return {
690
+ tick,
691
+ /** Ticks run, ticks that compared 2+ agents' work, pairs compared, findings sent. */
692
+ stats: () => ({ ...stats }),
693
+ stop: () => {
694
+ stopped = true;
695
+ if (!busy)
696
+ cleanup(); // else the running tick cleans up as it ends
697
+ },
698
+ };
699
+ }
700
+ /** Start the radar on an interval; returns stop. With <2 agents in flight a tick does nothing. */
701
+ export function startRadar(o) {
702
+ const ms = o.intervalMs === undefined ? radarIntervalFromEnv() : o.intervalMs;
703
+ if (ms === null || !Number.isFinite(ms) || ms <= 0)
704
+ return () => { };
705
+ const r = createRadar(o);
706
+ const t = setInterval(() => void r.tick().catch(() => { }), Math.max(50, ms));
707
+ t.unref();
708
+ return () => {
709
+ clearInterval(t);
710
+ r.stop();
711
+ try {
712
+ o.db.publish("radar.stats", { payload: { ...r.stats(), interval_ms: ms } });
713
+ }
714
+ catch {
715
+ /* closed db */
716
+ }
717
+ };
718
+ }
719
+ //# sourceMappingURL=radar.js.map