huddle-cli 0.1.0 → 0.1.1

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 (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/QUICKSTART.md +102 -0
  3. package/README.md +23 -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 +6 -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/attach/connect.js +4 -1
  16. package/dist/attach/guard.js +21 -2
  17. package/dist/attach/live.js +5 -1
  18. package/dist/attach/plug.js +14 -3
  19. package/dist/attach/screen.js +150 -0
  20. package/dist/attach/session.js +7 -2
  21. package/dist/cli.js +108 -40
  22. package/dist/mcp/context-tools.js +79 -0
  23. package/dist/mcp/server.js +38 -11
  24. package/dist/orchestrator/ask.js +13 -10
  25. package/dist/orchestrator/brain.js +13 -3
  26. package/dist/orchestrator/council.js +427 -86
  27. package/dist/orchestrator/goal.js +196 -0
  28. package/dist/orchestrator/handoff.js +2 -2
  29. package/dist/orchestrator/launcher.js +29 -13
  30. package/dist/orchestrator/learn.js +105 -0
  31. package/dist/orchestrator/planner.js +25 -7
  32. package/dist/orchestrator/qa.js +62 -14
  33. package/dist/orchestrator/radar.js +719 -0
  34. package/dist/orchestrator/scheduler.js +498 -39
  35. package/dist/orchestrator/takeover.js +155 -0
  36. package/dist/platform.js +273 -0
  37. package/dist/room/context.js +1285 -0
  38. package/dist/room/contract.js +132 -0
  39. package/dist/room/db.js +79 -16
  40. package/dist/room/followup.js +233 -0
  41. package/dist/room/identity.js +2 -2
  42. package/dist/room/lessons.js +275 -0
  43. package/dist/room/live.js +75 -20
  44. package/dist/room/mail.js +212 -0
  45. package/dist/room/progress.js +40 -1
  46. package/dist/room/server.js +169 -39
  47. package/dist/room/tunnel.js +6 -5
  48. package/dist/sandbox/sync.js +4 -4
  49. package/dist/sandbox/worktree.js +145 -14
  50. package/dist/ui/app.js +554 -133
  51. package/dist/ui/catalog.js +62 -24
  52. package/dist/ui/dashboard.js +194 -51
  53. package/dist/ui/doctor.js +15 -11
  54. package/dist/ui/lessons.js +81 -0
  55. package/dist/ui/paste.js +515 -0
  56. package/dist/ui/remote.js +11 -5
  57. package/dist/ui/setup.js +152 -0
  58. package/dist/ui/story.js +666 -69
  59. package/dist/ui/term.js +151 -16
  60. package/dist/ui/watch.js +145 -48
  61. package/package.json +6 -3
@@ -5,13 +5,18 @@
5
5
  // runs the test gate; in manual mode you approve the merge; landing it can
6
6
  // unblock more tasks, so we fill again. Nobody idles while there's work.
7
7
  import { launchAgent, stopChild } from "./launcher.js";
8
- import { prepareMerge, landMerge, removeWorktree, branchDiff, worktreeFor, createWorktree, beginConflictMerge, conflictBrief, finishConflictMerge, abortConflictMerge, } from "../sandbox/worktree.js";
8
+ import { prepareMerge, landMerge, removeWorktree, branchDiff, wipDiff, worktreeFor, createWorktree, beginConflictMerge, conflictBrief, finishConflictMerge, abortConflictMerge, } from "../sandbox/worktree.js";
9
9
  import { fetchBranch, deleteRemoteBranch } from "../sandbox/sync.js";
10
10
  import { UsageMeter } from "../adapters/usage.js";
11
11
  import { askAgent } from "./ask.js";
12
12
  import { handoffsFor, headSha, recordHandoff } from "./handoff.js";
13
- import { takeoverBrief, silenceMsFromEnv, helpAfterMsFromEnv } from "./takeover.js";
14
- import { helpOffer, alongsideBrief } from "../room/progress.js";
13
+ import { takeoverBrief, silenceMsFromEnv, helpAfterMsFromEnv, pairWaitMsFromEnv, buddyAfterMsFromEnv, isSpecialTask, buddyPrompt, parseBuddy, pairPrompt, parsePart, testsPart, pairNote, BUDDY_AT, pairEligible, } from "./takeover.js";
14
+ import { helpOffer, alongsideBrief, addSplitPart, taskViews } from "../room/progress.js";
15
+ import { contractBrief, contractNames, getContract } from "../room/contract.js";
16
+ import { notifyAgent } from "../room/mail.js";
17
+ import { closeNotes, openNotes, resolveNotes, trackNote } from "../room/followup.js";
18
+ import { onFinished } from "../platform.js";
19
+ import { startRadar } from "./radar.js";
15
20
  const prefersOf = (w) => w.adapter?.prefers ?? w.remote?.prefers ?? [];
16
21
  /** No help offers on a task whose reported progress is this far along. */
17
22
  export const OFFER_MAX_PCT = 60;
@@ -46,18 +51,20 @@ HOW:
46
51
  resolution and re-runs the tests before anything lands.
47
52
  4. huddle_remember(kind="decision", body=<how you combined them>, why=...), then huddle_done(task_id, summary).`;
48
53
  }
49
- function reviewPrompt(title, spec, diff) {
54
+ function reviewPrompt(title, spec, diff, contract = "") {
50
55
  return `You are reviewing a teammate's change before Huddle merges it. Another AI agent wrote it.
51
56
 
52
57
  TASK: ${title}
53
58
  ${spec}
54
-
59
+ ${contract ? `\n${cap(contract, 2500)}\n` : ""}
55
60
  DIFF (against main):
56
61
  ${diff || "(empty diff)"}
57
62
 
58
63
  You have NO tools — you can't run commands or open files, and you don't need to: the task and the full diff are above.
59
- Check the diff against the task: bugs, broken contracts, missing requirements, behavior that would fail.
64
+ Check, in order: (1) names and shapes match the ${contract ? "contract and the " : ""}task (functions, fields, routes, messages);
65
+ (2) each acceptance point in the task is actually done; (3) error paths (bad input, missing data) behave; (4) the tests actually exercise this change.
60
66
  Reply with the first line exactly APPROVE or CHANGES.
67
+ If APPROVE, add one line "Verified: <what you checked and found working>".
61
68
  If CHANGES, add up to 6 short lines, each one concrete blocking problem and where it is.
62
69
  Style preferences and nice-to-haves are NOT blocking: APPROVE if that's all you have. Do not edit anything.`;
63
70
  }
@@ -65,14 +72,19 @@ Style preferences and nice-to-haves are NOT blocking: APPROVE if that's all you
65
72
  * The first APPROVE/CHANGES line decides. Anything else is "skipped" — shown
66
73
  * honestly, and it never blocks a merge (a reviewer can't block by accident).
67
74
  */
68
- function parseVerdict(text) {
75
+ export function parseVerdict(text) {
69
76
  const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
70
- const i = lines.findIndex((l) => /^(APPROVE|CHANGES)\b/.test(l.replace(/[*`#_]/g, "").toUpperCase()));
77
+ // "APPROVED" is an approve too (it used to read as no verdict at all).
78
+ const i = lines.findIndex((l) => /^(APPROVED?|CHANGES)\b/.test(l.replace(/[*`#_]/g, "").toUpperCase()));
71
79
  if (i < 0)
72
80
  return { approve: true, skipped: true, notes: "no verdict" };
73
81
  if (lines[i].replace(/[*`#_]/g, "").toUpperCase().startsWith("CHANGES"))
74
82
  return { approve: false, notes: lines.slice(i + 1, i + 7).join("\n") || "(no details given)" };
75
- return { approve: true, notes: lines.slice(i + 1, i + 3).join(" ") };
83
+ // Found live: ~9s bare APPROVEs. An approve that says nothing about what was verified is shown as shallow.
84
+ const said = [lines[i].replace(/[*`#_]/g, "").replace(/^\s*APPROVE[D]?\b[\s:.,!—-]*/i, ""), ...lines.slice(i + 1, i + 3)].join(" ").trim();
85
+ // "Verified: ok" / "LGTM" say nothing either: what's left after the label has to name something.
86
+ const what = said.replace(/^(verified|checked)\s*:?\s*/i, "").replace(/^(lgtm|looks good|ok|fine)\b[\s.!,]*/i, "");
87
+ return what.length >= 10 ? { approve: true, notes: said } : { approve: true, shallow: true, notes: "approve (shallow)" };
76
88
  }
77
89
  export async function runBoard(opts) {
78
90
  const { db, repoRoot, roomUrl, workers, testCmd, signal } = opts;
@@ -90,6 +102,16 @@ export async function runBoard(opts) {
90
102
  }
91
103
  opts.onEvent(e);
92
104
  };
105
+ // The shared contract, when the Council wrote one ("" when there is none).
106
+ const contractOf = opts.contractBrief ?? contractBrief;
107
+ const contract = () => {
108
+ try {
109
+ return contractOf(db) || "";
110
+ }
111
+ catch {
112
+ return "";
113
+ }
114
+ };
93
115
  const running = new Map(); // agent id -> handle
94
116
  let merged = 0;
95
117
  let failed = 0;
@@ -112,11 +134,17 @@ export async function runBoard(opts) {
112
134
  return new Promise((resolve) => {
113
135
  let resolved = false;
114
136
  let stopWatch = () => { }; // the silence watchdog + help offers (set up below)
137
+ let stopRadar = () => { }; // the integration radar (set up below)
138
+ // Buddy checks and pairing asks end with the run (or with you): no ask outlives it.
139
+ const sideAsks = new AbortController();
140
+ signal?.addEventListener("abort", () => sideAsks.abort());
115
141
  const done = () => {
116
142
  if (resolved)
117
143
  return;
118
144
  resolved = true;
145
+ sideAsks.abort();
119
146
  stopWatch();
147
+ stopRadar();
120
148
  process.off("exit", killAll);
121
149
  resolve({ merged, failed, stopped: !!signal?.aborted });
122
150
  };
@@ -233,7 +261,8 @@ export async function runBoard(opts) {
233
261
  agentId: r.id,
234
262
  role: "worker",
235
263
  taskId: task.id,
236
- taskSpec: `Resolve merge conflict: ${task.title}\n\n${resolvePrompt({ title: task.title, spec: task.spec, brief, others, retry: st.last })}`,
264
+ // The contract decides a name clash: both sides code to it.
265
+ taskSpec: `Resolve merge conflict: ${task.title}\n\n${resolvePrompt({ title: task.title, spec: task.spec, brief, others, retry: st.last })}${contractBrief(db) ? `\n\n${contractBrief(db)}` : ""}`,
237
266
  roomUrl,
238
267
  roomSecret: opts.roomSecret,
239
268
  approvals: opts.approvals,
@@ -245,15 +274,8 @@ export async function runBoard(opts) {
245
274
  running.set(r.id, h2);
246
275
  heard(r.id, h2);
247
276
  onEvent({ type: "conflict", agent: r.id, author: c.author.id, task: task.id, title: task.title, files: cs.files, attempt: st.n, log: h2.logPath });
248
- let exited = false;
249
- const once = (code) => {
250
- if (exited)
251
- return;
252
- exited = true;
253
- void endResolve(r, c, h2, cs, code);
254
- };
255
- h2.child.on("exit", once);
256
- h2.child.on("error", () => once(-1));
277
+ // On 'close' (capped): its last stdout lines (token usage) can still be in the pipe at 'exit'.
278
+ onFinished(h2.child, (code) => void endResolve(r, c, h2, cs, code));
257
279
  };
258
280
  const endResolve = async (r, c, h2, cs, code) => {
259
281
  const ms = Date.now() - h2.startedAt;
@@ -380,18 +402,333 @@ export async function runBoard(opts) {
380
402
  continue;
381
403
  const span = quiet >= 60_000 ? `${Math.round(quiet / 60_000)} min` : `${Math.round(quiet / 1000)}s`;
382
404
  silenced.set(h.taskId, `went silent — no output and no huddle_* call for ${span}`);
383
- h.child.kill("SIGTERM");
384
- const child = h.child;
385
- setTimeout(() => child.exitCode === null && child.signalCode === null && child.kill("SIGKILL"), 5000).unref();
405
+ stopChild(h.child);
406
+ }
407
+ };
408
+ // --- teamwork mid-task --------------------------------------------------
409
+ // Found live (3 capstone runs): every help offer was declined, no split
410
+ // ever happened, and reviews came only after huddle_done. So Huddle acts:
411
+ // buddy checks put a second agent's eyes on work in progress, and pairing
412
+ // hands a part of the slowest task to a free agent unless its author says no.
413
+ const buddyOn = opts.buddyChecks ?? !/^(off|no|0|false)$/i.test(process.env.HUDDLE_BUDDY ?? "");
414
+ const pairWaitMs = opts.pairWaitMs ?? pairWaitMsFromEnv();
415
+ const buddyAfterMs = opts.buddyAfterMs !== undefined ? opts.buddyAfterMs : buddyAfterMsFromEnv();
416
+ const buddyBy = new Map(); // agent id -> buddy checks it gave
417
+ const buddyDone = new Map(); // task id -> its checks so far (and the first one's pct)
418
+ const buddyBusy = new Set(); // task ids with a check in flight
419
+ const buddyRuns = new Map(); // task id -> its check in flight, for finish() to wait on
420
+ const buddyWaitMs = opts.buddyWaitMs ?? 60_000;
421
+ const followedUp = new Set(); // task ids that had their one round for notes that came too late
422
+ const paired = new Set(); // task ids that had their one pairing (or attempt)
423
+ const pairing = new Set(); // helper ids mid-pairing (asking, or waiting for a no)
424
+ const holdFor = new Map(); // task id -> the partner task that must land first
425
+ const testsOf = new Set(); // tests-part task ids (they test their partner's code)
426
+ const held = []; // finished, waiting for their partner to land
427
+ const settled = (id) => {
428
+ const st = db.getTask(id)?.status;
429
+ return !st || st === "merged" || st === "failed" || st === "needs_you";
430
+ };
431
+ /** The other tasks in flight, with their work so far (capped): where a buddy spots XYZ vs ABC. */
432
+ const othersInFlight = (taskId) => [...running.values()]
433
+ .filter((x) => x.taskId !== taskId && x.child)
434
+ .slice(0, 3)
435
+ .map((x) => {
436
+ const t = db.getTask(x.taskId);
437
+ const wip = wipDiff(repoRoot, x.worktree, x.excludes, 2500);
438
+ return `- ${x.agentId}: “${t?.title ?? x.taskId}” (${x.taskId}) · files so far: ${wip.files.slice(0, 10).join(", ") || "(none yet)"}${wip.diff ? `\n${wip.diff}` : ""}`;
439
+ })
440
+ .join("\n");
441
+ /** Never the author; a free agent first, then the one that gave the fewest buddy checks. */
442
+ const pickBuddy = (authorId) => workers
443
+ .filter((x) => x.id !== authorId && x.adapter?.ask)
444
+ .map((w, i) => ({ w, i, busy: running.has(w.id) || reserved.has(w.id) ? 1 : 0 }))
445
+ .sort((a, b) => a.busy - b.busy || (buddyBy.get(a.w.id) ?? 0) - (buddyBy.get(b.w.id) ?? 0) || a.i - b.i)[0]?.w;
446
+ /** A teammate reads the work in progress and its notes land in the author's inbox now. Never blocks the author. */
447
+ const buddyCheck = async (taskId, pct, silentMs = 0) => {
448
+ if (!buddyOn || resolved || signal?.aborted || buddyBusy.has(taskId) || resolving.has(taskId))
449
+ return;
450
+ const t = db.getTask(taskId);
451
+ if (!t?.claimed_by || isSpecialTask(t))
452
+ return;
453
+ const author = t.claimed_by;
454
+ const h = running.get(author);
455
+ if (!h || h.taskId !== taskId || !h.child)
456
+ return; // local, and mid-task
457
+ const st = buddyDone.get(taskId) ?? { n: 0, notes: false, at: 0 };
458
+ // The second look is a follow-up on the first one's notes; a first look at 80% already was the late one.
459
+ // silentMs: the author never reported progress (found live: Claude called
460
+ // huddle_progress twice in 4 tasks), so the first look goes by time instead.
461
+ const due = st.n === 0 ? pct >= BUDDY_AT[0] || silentMs > 0 : st.n === 1 && st.notes && st.at < BUDDY_AT[1] && pct >= BUDDY_AT[1];
462
+ if (!due)
463
+ return;
464
+ const buddy = pickBuddy(author);
465
+ if (!buddy)
466
+ return;
467
+ // Nothing written yet: nothing to look at, and not worth a teammate's tokens. The next report tries again.
468
+ const wip = wipDiff(repoRoot, h.worktree, h.excludes, 16_000);
469
+ if (!wip.diff)
470
+ return;
471
+ st.n++;
472
+ if (st.n === 1)
473
+ st.at = pct;
474
+ buddyDone.set(taskId, st);
475
+ buddyBusy.add(taskId);
476
+ buddyBy.set(buddy.id, (buddyBy.get(buddy.id) ?? 0) + 1);
477
+ const t0 = Date.now();
478
+ let landed = () => { };
479
+ buddyRuns.set(taskId, { landed: new Promise((r) => (landed = r)), at: t0 });
480
+ try {
481
+ // The Room's huddle_done waits on this pair (server.ts), so the notes reach the author before it exits.
482
+ db.publish("buddy.start", { task_id: taskId, agent_id: buddy.id, payload: { author, pct } });
483
+ const when = silentMs ? `${Math.round(silentMs / 60_000) || 1} min in` : `${pct}%`;
484
+ const prompt = buddyPrompt({ author, when, title: t.title, spec: t.spec, contract: contract(), others: othersInFlight(taskId), diff: wip.diff });
485
+ // Its own signal, tied to the run's: askAgent never unhooks, and a long run would pile listeners on one signal.
486
+ const stop = new AbortController();
487
+ const off = () => stop.abort();
488
+ sideAsks.signal.addEventListener("abort", off);
489
+ const res = await askAgent(buddy.adapter, prompt, repoRoot, { signal: stop.signal, timeoutMs: 90_000, noTools: true, quick: true }).finally(() => sideAsks.signal.removeEventListener("abort", off));
490
+ if (resolved || !res.ok)
491
+ return; // no answer: the author never knew it was asked
492
+ const notes = parseBuddy(res.text);
493
+ st.notes = !!notes;
494
+ // Its author is past it (finish() waited buddyWaitMs; it's merging or landed): the notes would only reach the
495
+ // author's NEXT task, where they mean nothing. Shared memory keeps them for QA and later tasks.
496
+ const st2 = db.getTask(taskId)?.status;
497
+ const late = !!notes && !!st2 && !["claimed", "in_progress"].includes(st2);
498
+ if (notes && late)
499
+ db.remember({ kind: "gotcha", body: `Buddy note on "${t.title}" from ${buddy.id} (came back after it landed; nobody acted on it): ${notes}`.slice(0, 1500), author: buddy.id });
500
+ else if (notes) {
501
+ const m = notifyAgent(db, author, `buddy check from ${buddy.id} ${silentMs ? when : `at ${when}`}:\n${notes}`, "huddle", taskId);
502
+ trackNote(db, m, { by: buddy.id, kind: "buddy", worktree: h.worktree });
503
+ }
504
+ onEvent({ type: "checkpoint", agent: buddy.id, author, task: taskId, title: t.title, pct, notes: notes ?? "LOOKS GOOD", ms: Date.now() - t0, ...(late ? { late } : {}) });
505
+ }
506
+ catch {
507
+ /* a buddy check never breaks a run */
508
+ }
509
+ finally {
510
+ buddyBusy.delete(taskId);
511
+ buddyRuns.delete(taskId);
512
+ try {
513
+ db.publish("buddy.end", { task_id: taskId, agent_id: buddy.id });
514
+ }
515
+ catch {
516
+ /* closed db */
517
+ }
518
+ landed();
519
+ }
520
+ };
521
+ /** Tasks whose author never got to 40% in huddle_progress still get a look mid-task, after buddyAfterMs. */
522
+ const buddyBySilence = () => {
523
+ if (buddyAfterMs === null || !buddyOn)
524
+ return;
525
+ const now = Date.now();
526
+ for (const h of running.values()) {
527
+ if (!h.child || buddyDone.has(h.taskId) || buddyBusy.has(h.taskId) || now - h.startedAt < buddyAfterMs)
528
+ continue;
529
+ // Reached 40%: that report triggers it. One early "10%" and then quiet still counts as quiet.
530
+ const pct = db.getTask(h.taskId)?.progress_pct ?? 0;
531
+ if (pct >= BUDDY_AT[0])
532
+ continue;
533
+ void buddyCheck(h.taskId, pct, now - h.startedAt).catch(() => { });
534
+ }
535
+ };
536
+ /** Resolves with the author's "no: …" reason, or null when the window closes (or it says yes, or stops mattering). */
537
+ const waitForNo = (author, helper, since, ms, still) => new Promise((resolve) => {
538
+ let over = false;
539
+ const end = (v) => {
540
+ if (over)
541
+ return;
542
+ over = true;
543
+ clearTimeout(timer);
544
+ clearInterval(poll);
545
+ unsub();
546
+ resolve(v);
547
+ };
548
+ const look = () => {
549
+ if (over)
550
+ return;
551
+ for (const m of db.inbox("huddle", { peek: true })) {
552
+ if (m.from_agent !== author || m.created_at < since)
553
+ continue;
554
+ db.markRead(m.id);
555
+ const body = m.body.replace(/[*`_]/g, "").trim();
556
+ if (/^(yes|y|ok|okay|go|sure|fine|sounds good|go ahead)\b/i.test(body))
557
+ return end(null);
558
+ // Anything else the author sends Huddle in the window is an objection: it was asked for a no, not a chat.
559
+ return end(body.replace(/^(no|nope|nah|don't|do not)\b[\s:,.!—-]*/i, "").trim() || "no reason given");
560
+ }
561
+ // Models mix up recipients: a "no" sent to the helper itself counts too (left unread for the helper).
562
+ for (const m of db.inbox(helper, { peek: true })) {
563
+ if (m.from_agent !== author || m.created_at < since)
564
+ continue;
565
+ const body = m.body.replace(/[*`_]/g, "").trim();
566
+ if (/^(no|nope|nah)\b/i.test(body))
567
+ return end(body.replace(/^(no|nope|nah)\b[\s:,.!—-]*/i, "").trim() || "no reason given");
568
+ }
569
+ };
570
+ const unsub = db.onEvent((e) => {
571
+ if (e.type !== "agent.message")
572
+ return;
573
+ try {
574
+ look();
575
+ }
576
+ catch {
577
+ /* a read that fails is just no reply yet */
578
+ }
579
+ });
580
+ const timer = setTimeout(() => end(null), ms);
581
+ const poll = setInterval(() => {
582
+ try {
583
+ if (!still() || resolved || signal?.aborted)
584
+ end(null);
585
+ }
586
+ catch {
587
+ end(null);
588
+ }
589
+ }, 500);
590
+ timer.unref?.();
591
+ poll.unref?.();
592
+ look();
593
+ });
594
+ /**
595
+ * Pairing: a free agent proposes one separable part of the task furthest
596
+ * behind (or, if nothing is cleanly separable, its tests); the author is
597
+ * told and has pairWaitMs to say no; then the part goes on the board for
598
+ * the helper. Merge order makes landing safe: a code part lands before the
599
+ * parent (the parent is written to use it, so its tests need it on main);
600
+ * a tests part lands after the parent (its tests run against the parent's
601
+ * code at the gate, so they verify something). Either way they're
602
+ * disjoint files, so neither order conflicts; it's about green gates.
603
+ */
604
+ const pairUp = async (helper, author, h, t) => {
605
+ paired.add(t.id);
606
+ pairing.add(helper.id);
607
+ // Found live (round 4): 0 pairs, and nothing said whether one was ever tried. A dropped pairing says why.
608
+ const dropped = (reason) => {
609
+ try {
610
+ db.publish("pair.dropped", { task_id: t.id, agent_id: helper.id, payload: { with: author, title: t.title, reason } });
611
+ }
612
+ catch {
613
+ /* closed db */
614
+ }
615
+ };
616
+ try {
617
+ const still = () => running.get(author)?.taskId === t.id && (db.getTask(t.id)?.progress_pct ?? 0) < OFFER_MAX_PCT && !resolved && !signal?.aborted;
618
+ const wip = wipDiff(repoRoot, h.worktree, h.excludes, 12_000);
619
+ const files = JSON.parse(t.files ?? "[]");
620
+ let part = null;
621
+ if (helper.adapter?.ask) {
622
+ // The run waits for a pairing, so its ask ends as soon as the pairing stops mattering (the author finished, say).
623
+ const stop = new AbortController();
624
+ const off = () => stop.abort();
625
+ sideAsks.signal.addEventListener("abort", off);
626
+ const watch = setInterval(() => {
627
+ try {
628
+ if (!still())
629
+ stop.abort();
630
+ }
631
+ catch {
632
+ stop.abort();
633
+ }
634
+ }, 500);
635
+ watch.unref?.();
636
+ const res = await askAgent(helper.adapter, pairPrompt({ helper: helper.id, author, title: t.title, spec: t.spec, files, contract: contract(), diff: wip.diff }), repoRoot, { signal: stop.signal, timeoutMs: 90_000, noTools: true, quick: true }).finally(() => {
637
+ clearInterval(watch);
638
+ sideAsks.signal.removeEventListener("abort", off);
639
+ });
640
+ if (res.ok && !stop.signal.aborted)
641
+ part = parsePart(res.text, wip.files, files);
642
+ }
643
+ part ??= testsPart(t.title, t.spec);
644
+ if (!still())
645
+ return dropped(`${author} got past ${OFFER_MAX_PCT}% or finished while ${helper.id} was proposing a part`);
646
+ const note = notifyAgent(db, author, pairNote(helper.id, part, pairWaitMs), "huddle", t.id);
647
+ const no = await waitForNo(author, helper.id, note.created_at, pairWaitMs, still);
648
+ if (no !== null) {
649
+ db.remember({ kind: "fact", body: `${author} kept "${t.title}" whole (declined ${helper.id} taking “${part.title}”): ${no}`.slice(0, 600), author: "huddle" });
650
+ return dropped(`${author} said no: ${no}`.slice(0, 300));
651
+ }
652
+ const parent = db.getTask(t.id);
653
+ if (!parent || !still() || !["claimed", "in_progress"].includes(parent.status))
654
+ return dropped(`${author} got past ${OFFER_MAX_PCT}% or finished during the no-window`);
655
+ const pct = parent.progress_pct;
656
+ const secs = Math.round((Date.now() - h.startedAt) / 1000);
657
+ const spec = `${part.spec}\n\n${author} keeps the rest of “${t.title}” and was told not to edit ${part.files.length ? part.files.join(", ") : "your test file"}. ` +
658
+ `Match their names exactly; unsure? huddle_ask(…, to="${author}").` +
659
+ (wip.diff ? `\n\nTHEIR WORK SO FAR (when you were paired):\n${cap(wip.diff, 6000)}` : "");
660
+ const prefers = [`@${helper.id}`, ...JSON.parse(parent.prefers)];
661
+ const { task: pt } = addSplitPart(db, parent, author, { title: part.title, spec, files: part.files, prefers });
662
+ if (!pt)
663
+ return;
664
+ if (part.tests) {
665
+ holdFor.set(pt.id, parent.id);
666
+ testsOf.add(pt.id);
667
+ }
668
+ else
669
+ holdFor.set(parent.id, pt.id);
670
+ onEvent({
671
+ type: "pair",
672
+ agent: helper.id,
673
+ with: author,
674
+ task: parent.id,
675
+ title: parent.title,
676
+ part: part.tests ? `tests: ${pt.title}` : `${pt.title} (${part.files.join(", ")})`,
677
+ reason: `${parent.title} at ${pct}% after ${secs}s; ${helper.id} was free`,
678
+ });
679
+ }
680
+ catch {
681
+ /* pairing never breaks a run */
682
+ }
683
+ finally {
684
+ pairing.delete(helper.id);
685
+ if (!resolved)
686
+ fill(); // the part (if any) goes to the helper now
386
687
  }
387
688
  };
388
- /** A free agent with nothing to do offers to take part of each long-running task, once. */
689
+ /**
690
+ * Every tick: a free agent with nothing to do pairs up with the task
691
+ * furthest behind. A helper that can't be asked (no read-only mode) only
692
+ * sends the old help offer, which leaves the split to the busy agent.
693
+ */
389
694
  const offerHelp = () => {
390
695
  if (helpAfterMs === null || signal?.aborted || conflicts.length || rescues.length)
391
696
  return;
392
- const helper = workers.find((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id) && !db.nextBacklogTask(prefersOf(x)));
697
+ const helper = workers.find((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id) && !pairing.has(x.id) && !db.nextBacklogTask(prefersOf(x)));
393
698
  if (!helper)
394
699
  return;
700
+ if (helper.adapter.ask) {
701
+ const now = Date.now();
702
+ const left = (t, h) => ((now - h.startedAt) * (100 - t.progress_pct)) / Math.max(t.progress_pct, 5);
703
+ // A task that never called huddle_progress isn't at 0%: it has no number (see pairEligible).
704
+ const reported = new Set(taskViews(db, now).filter((v) => v.reported).map((v) => v.id));
705
+ const best = [...running]
706
+ .map(([id, h]) => ({ id, h, t: db.getTask(h.taskId) }))
707
+ .filter((c) => !!c.t &&
708
+ !!c.h.child &&
709
+ c.id !== helper.id &&
710
+ c.t.claimed_by === c.id &&
711
+ ["claimed", "in_progress"].includes(c.t.status) &&
712
+ !paired.has(c.t.id) &&
713
+ !resolving.has(c.t.id) &&
714
+ !testFixed.has(c.t.id) &&
715
+ !reviewed.has(c.t.id) &&
716
+ !splitFrom.has(c.t.id) && // a part isn't split again
717
+ !isSpecialTask(c.t) &&
718
+ now - c.h.startedAt >= helpAfterMs &&
719
+ pairEligible({
720
+ reported: reported.has(c.t.id),
721
+ pct: c.t.progress_pct,
722
+ ranMs: now - c.h.startedAt,
723
+ helpAfterMs,
724
+ planned: JSON.parse(c.t.files ?? "[]").length,
725
+ touched: () => wipDiff(repoRoot, c.h.worktree, c.h.excludes, 0).files.length,
726
+ }))
727
+ .sort((a, b) => left(b.t, b.h) - left(a.t, a.h))[0];
728
+ if (best)
729
+ void pairUp(helper, best.id, best.h, best.t);
730
+ return;
731
+ }
395
732
  for (const [id, h] of running) {
396
733
  if (id === helper.id || offered.has(h.taskId) || resolving.has(h.taskId) || Date.now() - h.startedAt < helpAfterMs)
397
734
  continue;
@@ -404,7 +741,7 @@ export async function runBoard(opts) {
404
741
  if ((t.progress_pct ?? 0) >= OFFER_MAX_PCT)
405
742
  continue;
406
743
  offered.add(t.id);
407
- db.sendMessage({ from: helper.id, to: id, kind: "note", task_id: t.id, body: helpOffer(helper.id, t.id, t.title) });
744
+ notifyAgent(db, id, helpOffer(helper.id, t.id, t.title), helper.id, t.id);
408
745
  onEvent({ type: "offer", agent: helper.id, to: id, task: t.id, title: t.title });
409
746
  }
410
747
  };
@@ -418,6 +755,16 @@ export async function runBoard(opts) {
418
755
  const by = db.getTask(e.task_id)?.claimed_by;
419
756
  if (by && running.has(by))
420
757
  lastSeen.set(by, Date.now());
758
+ let pct = 0;
759
+ try {
760
+ pct = Number(JSON.parse(e.payload || "{}").pct) || 0;
761
+ }
762
+ catch {
763
+ /* keep 0 */
764
+ }
765
+ const id = e.task_id;
766
+ if (pct >= BUDDY_AT[0])
767
+ setImmediate(() => void buddyCheck(id, pct).catch(() => { })); // never an unhandled rejection
421
768
  }
422
769
  else if (e.type === "task.split" && e.task_id) {
423
770
  let p = {};
@@ -430,17 +777,68 @@ export async function runBoard(opts) {
430
777
  splitFrom.set(e.task_id, { from: String(p.from ?? e.agent_id ?? ""), parentTitle: String(p.parent_title ?? "") });
431
778
  setImmediate(() => !resolved && fill()); // a free agent may take it right away
432
779
  }
780
+ else if (e.type === "note.closed" && e.task_id) {
781
+ // A note that needed an answer, settled at huddle_done (server.ts) or as its task finished.
782
+ let p = {};
783
+ try {
784
+ p = JSON.parse(e.payload || "{}");
785
+ }
786
+ catch {
787
+ /* keep {} */
788
+ }
789
+ const how = p.how === "replied" || p.how === "fixed" ? p.how : "ignored";
790
+ onEvent({ type: "followup", agent: String(p.to ?? e.agent_id ?? ""), by: String(p.by ?? ""), task: e.task_id, title: db.getTask(e.task_id)?.title ?? e.task_id, kind: p.kind === "radar" ? "radar" : "buddy", how, note: String(p.body ?? "") });
791
+ }
792
+ else if (e.type === "contract.amended") {
793
+ let p = {};
794
+ try {
795
+ p = JSON.parse(e.payload || "{}");
796
+ }
797
+ catch {
798
+ /* keep {} */
799
+ }
800
+ onEvent({ type: "contract", agent: String(p.by ?? e.agent_id ?? ""), version: Number(p.version) || 0, section: String(p.section ?? ""), change: String(p.change ?? "") });
801
+ }
433
802
  });
434
803
  const tick = setInterval(() => {
435
804
  watchSilence();
436
805
  offerHelp();
806
+ buddyBySilence();
437
807
  }, Math.max(50, Math.min(10_000, Math.floor(silenceMs / 4))));
438
808
  tick.unref();
439
809
  stopWatch = () => {
440
810
  clearInterval(tick);
441
811
  unwatch();
442
812
  };
813
+ // Integration radar: compare in-flight agents' uncommitted work for
814
+ // conflicts and name mismatches while they can still fix them (radar.ts).
815
+ // Local agents only — a teammate's work lives on their machine.
816
+ if (opts.radarMs !== null)
817
+ stopRadar = startRadar({
818
+ db,
819
+ repoRoot,
820
+ intervalMs: opts.radarMs,
821
+ inFlight: () => [...running.values()]
822
+ // Still working: an exited agent's worktree is being committed and rebased by finish().
823
+ .filter((h) => h.child && h.child.exitCode === null && h.child.signalCode === null && !resolving.has(h.taskId))
824
+ .map((h) => ({ agentId: h.agentId, taskId: h.taskId, title: db.getTask(h.taskId)?.title ?? h.taskId, worktree: h.worktree, excludes: taskWt.get(h.taskId)?.excludes ?? h.excludes })),
825
+ // A finding is a note its reader must answer (fix it, or say why not) before huddle_done.
826
+ notify: (to, message, key) => {
827
+ const h = running.get(to);
828
+ const m = notifyAgent(db, to, message, "huddle", h?.taskId ?? null);
829
+ if (h)
830
+ trackNote(db, m, { by: "huddle", kind: "radar", worktree: h.worktree, key });
831
+ },
832
+ // The radar no longer sees it: whoever fixed it, every note about it is answered.
833
+ resolved: (key) => resolveNotes(db, key),
834
+ contractNames: () => contractNames(getContract(db)),
835
+ onEvent,
836
+ });
443
837
  }
838
+ /** Notes that came back after the author exited: one more round to answer them, before anything merges. */
839
+ const followUpBrief = (notes) => `\n\nWhile you were finishing, a teammate looked over your work and these notes reached Huddle after you exited — nothing is merged yet. ` +
840
+ `Address each one: fix it, or reply huddle_send(to="<who>", reply_to="<id>", message="won't fix: <why>"). Then huddle_done.\n` +
841
+ notes.map((n) => `- [id ${n.id}, from ${n.kind === "radar" ? "huddle" : n.by}] ${cap(n.body, 800)}`).join("\n");
444
842
  const finish = async (w, h, exitCode, why) => {
445
843
  const workMs = Date.now() - h.startedAt; // agent time, not test/merge/approval time
446
844
  running.delete(w.id);
@@ -449,6 +847,19 @@ export async function runBoard(opts) {
449
847
  const task = db.getTask(h.taskId);
450
848
  const quiet = silenced.get(task.id);
451
849
  silenced.delete(task.id);
850
+ // A buddy check still being written is about THIS work. Found live (capstone
851
+ // round 4): 3 of 4 buddy notes came back after the author finished, and
852
+ // reached its next task instead. Hold the author (and its merge) for it, bounded.
853
+ const check = buddyRuns.get(task.id);
854
+ if (check && exitCode === 0 && !signal?.aborted) {
855
+ reserved.add(w.id);
856
+ pending++; // the run isn't over while this waits
857
+ let timer;
858
+ await Promise.race([check.landed, new Promise((r) => (timer = setTimeout(r, Math.max(0, check.at + buddyWaitMs - Date.now()))))]);
859
+ clearTimeout(timer);
860
+ pending--;
861
+ reserved.delete(w.id);
862
+ }
452
863
  if (signal?.aborted) {
453
864
  fail(w, h, task.title, "stopped by you", workMs);
454
865
  }
@@ -456,7 +867,26 @@ export async function runBoard(opts) {
456
867
  failOrRescue(w, h, task, quiet ?? why ?? `agent exited with code ${exitCode}`, workMs);
457
868
  }
458
869
  else {
870
+ // Notes it never saw (they came back after it exited). One it saw and didn't answer is just ignored.
871
+ const late = w.adapter ? openNotes(db, task.id).filter((n) => n.to === w.id && !db.getMessage(n.id)?.read_at) : [];
872
+ if (late.length && !followedUp.has(task.id)) {
873
+ followedUp.add(task.id);
874
+ for (const n of late)
875
+ db.markRead(n.id, "prompt"); // delivered in the brief, not the inbox
876
+ carry.set(task.id, totals(task.id, h, workMs));
877
+ db.remember({ kind: "gotcha", body: `"${task.title}" (${w.id}) got ${late.length} teammate note(s) after it finished; one more round to answer them`, author: "huddle" });
878
+ start(w, task, followUpBrief(late));
879
+ return fill();
880
+ }
881
+ closeNotes(db, task.id); // each note: addressed or ignored (the note.closed watcher reports it)
459
882
  db.setTaskStatus(task.id, "review");
883
+ // A paired task lands in a safe order (see pairUp): wait for its partner first.
884
+ const first = holdFor.get(task.id);
885
+ if (first && !settled(first)) {
886
+ held.push({ w, h, task, workMs, after: first });
887
+ pending++;
888
+ return fill();
889
+ }
460
890
  return mergeTask(w, h, task, workMs);
461
891
  }
462
892
  fill();
@@ -489,6 +919,7 @@ export async function runBoard(opts) {
489
919
  // Cross-review by an otherwise-idle agent: one round, never blocks on its own.
490
920
  const reviewer = reviewed.has(task.id) ? undefined : idleReviewer(w.id);
491
921
  let skippedReview = false;
922
+ let shallowReview = false;
492
923
  if (reviewer) {
493
924
  reviewed.add(task.id);
494
925
  reviewsBy.set(reviewer.id, (reviewsBy.get(reviewer.id) ?? 0) + 1);
@@ -499,7 +930,7 @@ export async function runBoard(opts) {
499
930
  onEvent({ type: "review", agent: reviewer.id, author: w.id, task: task.id, title: task.title });
500
931
  fill(); // everyone else keeps going
501
932
  const t0 = Date.now();
502
- const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt)), wt.path, {
933
+ const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt), contract()), wt.path, {
503
934
  signal,
504
935
  timeoutMs: 90_000,
505
936
  noTools: true, // the diff is in the prompt — no need to explore
@@ -510,6 +941,7 @@ export async function runBoard(opts) {
510
941
  db.setAgentStatus(reviewer.id, "idle");
511
942
  pending--;
512
943
  skippedReview = !!verdict.skipped;
944
+ shallowReview = !!verdict.shallow;
513
945
  onEvent({ type: "reviewed", agent: reviewer.id, author: w.id, task: task.id, title: task.title, ...verdict, ms: Date.now() - t0 });
514
946
  if (!verdict.approve && !signal?.aborted) {
515
947
  db.remember({ kind: "gotcha", body: `Review of "${task.title}" by ${reviewer.id}: ${verdict.notes}`, author: reviewer.id });
@@ -553,7 +985,7 @@ export async function runBoard(opts) {
553
985
  recordHandoff(db, repoRoot, { taskId: task.id, agentId: w.id, title: task.title, base, since: firstStart.get(task.id) ?? h.startedAt });
554
986
  const resolvedBy = resolving.get(task.id)?.by.at(-1);
555
987
  const reason = prep.reason +
556
- (reviewer && !skippedReview ? ` · reviewed by ${reviewer.id}` : "") +
988
+ (reviewer && !skippedReview ? ` · reviewed by ${reviewer.id}${shallowReview ? " (shallow approve)" : ""}` : "") +
557
989
  (resolvedBy ? ` · conflict resolved by ${resolvedBy}` : "");
558
990
  onEvent({ type: "merged", agent: w.id, task: task.id, title: task.title, reason, ...totals(task.id, h, workMs) });
559
991
  }
@@ -622,8 +1054,11 @@ export async function runBoard(opts) {
622
1054
  db.publish("handoff", { task_id: task.id, agent_id: h.agent_id, payload: { from: h.agent_id, to: w.id, task: h.task_id, title: h.title, files: h.files, symbols: h.symbols } });
623
1055
  onEvent({ type: "handoff", agent: w.id, from: h.agent_id, task: task.id, title: h.title, files: h.files.length, symbols: h.symbols.length });
624
1056
  }
1057
+ // The contract as it stands now (amendments included): each launch is a fresh process.
1058
+ const cb = contractBrief(db);
1059
+ const contract = cb ? `\n\n${cb}` : "";
625
1060
  if (w.remote)
626
- return void startRemote(w, { ...task, spec: task.spec + ho.text }, extra);
1061
+ return void startRemote(w, { ...task, spec: task.spec + contract + ho.text }, extra);
627
1062
  // Who else is mid-task right now, so this agent knows whom to tell (first round only;
628
1063
  // fill() claims a whole wave before launching any of it, so siblings see each other).
629
1064
  const along = first && !takeoverFrom ? alongsideBrief(db, task.id, w.id) : "";
@@ -635,7 +1070,7 @@ export async function runBoard(opts) {
635
1070
  agentId: w.id,
636
1071
  role: "worker",
637
1072
  taskId: task.id,
638
- taskSpec: `${task.title}\n\n${task.spec}${ho.text}${along}${extra}`,
1073
+ taskSpec: `${task.title}\n\n${task.spec}${contract}${ho.text}${along}${extra}`,
639
1074
  roomUrl,
640
1075
  roomSecret: opts.roomSecret,
641
1076
  approvals: opts.approvals,
@@ -650,17 +1085,33 @@ export async function runBoard(opts) {
650
1085
  running.set(w.id, h);
651
1086
  heard(w.id, h);
652
1087
  onEvent({ type: "start", agent: w.id, task: task.id, title: task.title, log: h.logPath, fixing: !!extra && !takeoverFrom, ...(takeoverFrom ? { takeover: takeoverFrom } : {}) });
653
- let exited = false; // 'error' and 'exit' can both fire
654
- const once = (code) => {
655
- if (exited)
656
- return;
657
- exited = true;
658
- void finish(w, h, code);
659
- };
660
- h.child.on("exit", once);
661
- h.child.on("error", () => once(-1));
1088
+ // On 'close', capped at 2s after 'exit': found live, an agent's final stdout
1089
+ // lines (its token usage, the log's tail) were lost when finish() ran on 'exit'.
1090
+ onFinished(h.child, (code) => void finish(w, h, code));
662
1091
  };
663
1092
  const fill = () => {
1093
+ // Paired tasks whose partner has landed (or failed: then nothing to wait for) merge now.
1094
+ // Every ready one is taken out before any merge starts: a merge re-enters fill().
1095
+ const ready = held.filter((x) => signal?.aborted || settled(x.after));
1096
+ if (ready.length) {
1097
+ held.splice(0, held.length, ...held.filter((x) => !ready.includes(x)));
1098
+ let merging = 0;
1099
+ for (const x of ready) {
1100
+ pending--;
1101
+ holdFor.delete(x.task.id);
1102
+ if (signal?.aborted)
1103
+ fail(x.w, x.h, x.task.title, "stopped by you", x.workMs);
1104
+ // Tests for code that never landed would only go red at the gate and burn a fix round.
1105
+ else if (testsOf.has(x.task.id) && db.getTask(x.after)?.status !== "merged")
1106
+ fail(x.w, x.h, x.task.title, `the task it tests (“${db.getTask(x.after)?.title ?? x.after}”) didn't land — work kept on branch ${x.h.branch}`, x.workMs);
1107
+ else {
1108
+ merging++;
1109
+ void mergeTask(x.w, x.h, x.task, x.workMs);
1110
+ }
1111
+ }
1112
+ if (merging)
1113
+ return; // each merge fills again when it's done
1114
+ }
664
1115
  // Conflicts first: they hold finished work, and the author is usually free.
665
1116
  for (const c of conflicts.splice(0)) {
666
1117
  if (signal?.aborted) {
@@ -730,7 +1181,15 @@ export async function runBoard(opts) {
730
1181
  start(w, task);
731
1182
  }
732
1183
  offerHelp();
733
- if (running.size === 0 && pending === 0) {
1184
+ // Nobody is working and only paired merges wait: their partner can't land now, so don't wait for it.
1185
+ if (running.size === 0 && held.length && pending === held.length && !pairing.size) {
1186
+ const x = held.shift();
1187
+ pending--;
1188
+ holdFor.delete(x.task.id);
1189
+ void mergeTask(x.w, x.h, x.task, x.workMs);
1190
+ return;
1191
+ }
1192
+ if (running.size === 0 && pending === 0 && !pairing.size) {
734
1193
  // Nobody is running yet a conflict still waits: no agent on this machine can resolve it.
735
1194
  for (const c of conflicts.splice(0))
736
1195
  parkForYou(c, "no agent on this machine could take the resolution");