huddle-cli 0.0.0-stage → 0.1.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 +357 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +52 -0
- package/dist/adapters/claude.js +70 -0
- package/dist/adapters/codex.js +59 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +85 -0
- package/dist/adapters/custom.js +340 -0
- package/dist/adapters/droid.js +57 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +55 -0
- package/dist/adapters/index.js +135 -0
- package/dist/adapters/kiro.js +41 -0
- package/dist/adapters/opencode.js +63 -0
- package/dist/adapters/qwen.js +67 -0
- package/dist/adapters/types.js +6 -0
- package/dist/adapters/usage.js +141 -0
- package/dist/attach/connect.js +409 -0
- package/dist/attach/guard.js +64 -0
- package/dist/attach/live.js +222 -0
- package/dist/attach/plug.js +174 -0
- package/dist/attach/session.js +172 -0
- package/dist/cli.js +176 -0
- package/dist/mcp/server.js +240 -0
- package/dist/orchestrator/ask.js +51 -0
- package/dist/orchestrator/brain.js +164 -0
- package/dist/orchestrator/council.js +274 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +155 -0
- package/dist/orchestrator/planner.js +194 -0
- package/dist/orchestrator/qa.js +350 -0
- package/dist/orchestrator/scheduler.js +748 -0
- package/dist/orchestrator/takeover.js +85 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/db.js +617 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/live.js +293 -0
- package/dist/room/progress.js +110 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +752 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +85 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +261 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1534 -0
- package/dist/ui/catalog.js +75 -0
- package/dist/ui/dashboard.js +192 -0
- package/dist/ui/doctor.js +54 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/remote.js +332 -0
- package/dist/ui/story.js +932 -0
- package/dist/ui/term.js +114 -0
- package/dist/ui/watch.js +174 -0
- package/install.sh +44 -0
- package/package.json +60 -4
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// The design council: for a big goal, every ready agent brainstorms before
|
|
2
|
+
// anyone writes code — not just the brain. Three short rounds:
|
|
3
|
+
//
|
|
4
|
+
// 1. propose each agent, in parallel and read-only, sketches architecture,
|
|
5
|
+
// a task breakdown, the contracts between tasks, and the risks
|
|
6
|
+
// 2. critique each agent reads the OTHERS' proposals and lists flaws,
|
|
7
|
+
// missing pieces and what should come next
|
|
8
|
+
// 3. synthesize the brain turns all of it into the plan (the same PlannedTask
|
|
9
|
+
// JSON the scheduler already runs), noting whose idea each task was
|
|
10
|
+
//
|
|
11
|
+
// Every call is time-boxed: an agent that is slow or errors is skipped, never
|
|
12
|
+
// waited on. Small goals skip the council and keep the fast single-brain plan.
|
|
13
|
+
import { askAgent } from "./ask.js";
|
|
14
|
+
import { repoContext } from "./brain.js";
|
|
15
|
+
import { goalSize, jsonCandidates, listsOwnTasks, parsePlan, planGoal, plannerPrompt } from "./planner.js";
|
|
16
|
+
const defaultAsk = (m, prompt, cwd, o) => askAgent(m.adapter, prompt, cwd, { ...o, noTools: true, quick: true });
|
|
17
|
+
const PROPOSAL_CAP = 1500;
|
|
18
|
+
const CRITIQUE_CAP = 900;
|
|
19
|
+
/** Default per-call time box; HUDDLE_COUNCIL_TIMEOUT_S overrides. */
|
|
20
|
+
export function councilTimeoutMs() {
|
|
21
|
+
const s = Number(process.env.HUDDLE_COUNCIL_TIMEOUT_S);
|
|
22
|
+
return Number.isFinite(s) && s > 0 ? s * 1000 : 120_000;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The synthesis time box. A council only runs for big goals, whose plan is
|
|
26
|
+
* long (10+ self-contained specs): 2 minutes, the small-goal planner's box,
|
|
27
|
+
* can cut it off half-written, and the fallback then plans the big goal alone.
|
|
28
|
+
*/
|
|
29
|
+
export function synthTimeoutMs(brain) {
|
|
30
|
+
return brain.spec.startsWith("ollama") ? 6 * 60_000 : 5 * 60_000;
|
|
31
|
+
}
|
|
32
|
+
/** The agents that sit on the council: one seat per CLI (claude#2 thinks like claude), and only those that can answer. */
|
|
33
|
+
export function councilMembers(workers) {
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const w of workers) {
|
|
37
|
+
if (!w.adapter?.ask || seen.has(w.adapter.id))
|
|
38
|
+
continue;
|
|
39
|
+
seen.add(w.adapter.id);
|
|
40
|
+
out.push({ id: w.id, adapter: w.adapter });
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Should this goal get a design council? `flag` is --council (true) /
|
|
46
|
+
* --no-council (false). Without a flag, only big goals do: a from-scratch
|
|
47
|
+
* project, a long spec, or a real goal in a (nearly) empty repo — and not when
|
|
48
|
+
* the goal already spells out its own tasks.
|
|
49
|
+
*/
|
|
50
|
+
export function councilWanted(goal, o) {
|
|
51
|
+
if (o.flag === false)
|
|
52
|
+
return { yes: false, why: "--no-council" };
|
|
53
|
+
if (o.members < 2)
|
|
54
|
+
return { yes: false, why: "needs 2+ agents that can answer" };
|
|
55
|
+
if (o.flag === true)
|
|
56
|
+
return { yes: true, why: "--council" };
|
|
57
|
+
// A goal that already spells out its tasks needs no brainstorm, even a big one.
|
|
58
|
+
if (listsOwnTasks(goal))
|
|
59
|
+
return { yes: false, why: "the goal already lists its tasks" };
|
|
60
|
+
const size = goalSize(goal, { repoFiles: o.repoFiles });
|
|
61
|
+
return { yes: size.big, why: size.why };
|
|
62
|
+
}
|
|
63
|
+
/** Strip escape codes and cap; a council answer is advice, not a novel. */
|
|
64
|
+
function clean(text, cap) {
|
|
65
|
+
// eslint-disable-next-line no-control-regex
|
|
66
|
+
const t = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
67
|
+
return t.length <= cap ? t : t.slice(0, cap - 1).replace(/\s+\S*$/, "") + "…";
|
|
68
|
+
}
|
|
69
|
+
function crewLine(members) {
|
|
70
|
+
return members.map((m) => `${m.id} (good at: ${m.adapter.prefers.join(", ") || "anything"})`).join("; ");
|
|
71
|
+
}
|
|
72
|
+
function proposalPrompt(goal, me, members, context) {
|
|
73
|
+
return `You are ${me.id}, on Huddle's design council: several AI coding agents (${members.map((m) => m.id).join(", ")}) each sketch a plan for the goal below before anyone writes code. Then the agents critique each other's sketches, and one plan is made from the best ideas. Each task will run in its own git worktree in parallel.
|
|
74
|
+
|
|
75
|
+
GOAL: ${goal}
|
|
76
|
+
|
|
77
|
+
CREW: ${crewLine(members)}
|
|
78
|
+
|
|
79
|
+
REPO SNAPSHOT:
|
|
80
|
+
${context}
|
|
81
|
+
|
|
82
|
+
You have no tools in this step and cannot read files: the snapshot above is everything. Do not edit anything.
|
|
83
|
+
Reply in plain text, at most 200 words, with exactly these four sections:
|
|
84
|
+
ARCHITECTURE: one or two sentences — the shape of the solution (modules, layers, data flow)
|
|
85
|
+
TASKS: 2-8 lines "- title — files — depends on"
|
|
86
|
+
CONTRACTS: the interfaces tasks share (exact function names, file paths, data shapes)
|
|
87
|
+
RISKS: the flaws, unknowns and edge cases most likely to break this`;
|
|
88
|
+
}
|
|
89
|
+
function critiquePrompt(goal, me, others) {
|
|
90
|
+
return `You are ${me.id}, on Huddle's design council. Your teammates proposed these plans for the goal below. Find what is wrong or missing before anyone builds it.
|
|
91
|
+
|
|
92
|
+
GOAL: ${goal}
|
|
93
|
+
|
|
94
|
+
${others.map((p) => `--- ${p.agent} proposes ---\n${clean(p.text, 1200)}`).join("\n\n")}
|
|
95
|
+
|
|
96
|
+
You have no tools in this step. Do not edit anything.
|
|
97
|
+
Reply in plain text, at most 120 words, with exactly these three sections (1-3 short lines each, name whose proposal you mean):
|
|
98
|
+
FLAWS: bugs, broken contracts, wrong assumptions, tasks that would collide
|
|
99
|
+
MISSING: requirements, tests, edge cases or pieces nobody planned
|
|
100
|
+
NEXT: what should be built first, or what to decide before work starts`;
|
|
101
|
+
}
|
|
102
|
+
/** First meaningful line of a section ("ARCHITECTURE:", "FLAWS:") for a one-line summary. */
|
|
103
|
+
export function headline(text, section) {
|
|
104
|
+
const lines = text.split("\n").map((l) => l.replace(/[*#`_]/g, "").trim());
|
|
105
|
+
const i = lines.findIndex((l) => l.toUpperCase().startsWith(section.toUpperCase() + ":") || l.toUpperCase() === section.toUpperCase());
|
|
106
|
+
const pick = (l) => l.replace(/^[-•\d.)\s]+/, "").trim();
|
|
107
|
+
if (i >= 0) {
|
|
108
|
+
const rest = pick(lines[i].slice(section.length + 1));
|
|
109
|
+
if (rest)
|
|
110
|
+
return rest;
|
|
111
|
+
const next = lines.slice(i + 1).find((l) => l && !/^[A-Z ]+:/.test(l));
|
|
112
|
+
if (next)
|
|
113
|
+
return pick(next);
|
|
114
|
+
}
|
|
115
|
+
return pick(lines.find(Boolean) ?? "");
|
|
116
|
+
}
|
|
117
|
+
async function round(members, name, promptFor, cap, o) {
|
|
118
|
+
o.onEvent?.({ type: "round", round: name, agents: members.map((m) => m.id) });
|
|
119
|
+
return Promise.all(members.map(async (m) => {
|
|
120
|
+
const t0 = Date.now();
|
|
121
|
+
// The time box is ours too, not only the adapter's: a call that ignores it still can't hold up the run.
|
|
122
|
+
let timer;
|
|
123
|
+
const late = new Promise((r) => {
|
|
124
|
+
timer = setTimeout(() => r({ ok: false, text: "", late: true }), o.timeoutMs + 2000);
|
|
125
|
+
});
|
|
126
|
+
let res;
|
|
127
|
+
try {
|
|
128
|
+
res = await Promise.race([o.ask(m, promptFor(m), o.cwd, { timeoutMs: o.timeoutMs, signal: o.signal }), late]);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
res = { ok: false, text: String(e?.message ?? e) };
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
}
|
|
136
|
+
const ms = Date.now() - t0;
|
|
137
|
+
const text = res.ok ? clean(res.text, cap) : "";
|
|
138
|
+
const entry = text
|
|
139
|
+
? { agent: m.id, round: name, ok: true, text, ms }
|
|
140
|
+
: { agent: m.id, round: name, ok: false, text: "", ms, skipped: res.late || ms >= o.timeoutMs ? "timed out" : "no answer" };
|
|
141
|
+
o.onEvent?.({ type: "entry", entry });
|
|
142
|
+
return entry;
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
/** The council's material for the brain, compact. */
|
|
146
|
+
function councilNotes(proposals, critiques) {
|
|
147
|
+
const parts = [`DESIGN COUNCIL — proposals (each agent planned independently):`];
|
|
148
|
+
for (const p of proposals)
|
|
149
|
+
parts.push(`--- ${p.agent} ---\n${p.text}`);
|
|
150
|
+
if (critiques.length) {
|
|
151
|
+
parts.push(`DESIGN COUNCIL — critiques (each agent read the others' proposals):`);
|
|
152
|
+
for (const c of critiques)
|
|
153
|
+
parts.push(`--- ${c.agent} ---\n${c.text}`);
|
|
154
|
+
}
|
|
155
|
+
return parts.join("\n\n");
|
|
156
|
+
}
|
|
157
|
+
/** decisions / risks from the synthesis reply (the tasks come from parsePlan). */
|
|
158
|
+
function parseExtras(text, members) {
|
|
159
|
+
for (const body of jsonCandidates(text)) {
|
|
160
|
+
let raw;
|
|
161
|
+
try {
|
|
162
|
+
raw = JSON.parse(body);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!Array.isArray(raw?.tasks))
|
|
168
|
+
continue;
|
|
169
|
+
const decisions = (Array.isArray(raw.decisions) ? raw.decisions : []).map((d) => clean(String(d ?? ""), 300)).filter(Boolean).slice(0, 6);
|
|
170
|
+
const risks = (Array.isArray(raw.risks) ? raw.risks : [])
|
|
171
|
+
.map((r) => {
|
|
172
|
+
const body = clean(String(typeof r === "string" ? r : r?.risk ?? r?.body ?? ""), 300);
|
|
173
|
+
const by = typeof r === "object" && members.includes(String(r?.by)) ? String(r.by) : undefined;
|
|
174
|
+
return body ? { body, ...(by ? { by } : {}) } : null;
|
|
175
|
+
})
|
|
176
|
+
.filter(Boolean)
|
|
177
|
+
.slice(0, 6);
|
|
178
|
+
return { decisions, risks };
|
|
179
|
+
}
|
|
180
|
+
return { decisions: [], risks: [] };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Brainstorm with every member, then have the brain write the plan. Falls
|
|
184
|
+
* back to today's single-brain planner whenever the council can't help (no
|
|
185
|
+
* proposals came back, or the synthesis wasn't a plan), so it never blocks a run.
|
|
186
|
+
*/
|
|
187
|
+
export async function runCouncil(goal, crew, members, repoRoot, opts) {
|
|
188
|
+
const t0 = Date.now();
|
|
189
|
+
const timeoutMs = opts.timeoutMs ?? councilTimeoutMs();
|
|
190
|
+
const ro = { ask: opts.ask ?? defaultAsk, cwd: repoRoot, timeoutMs, signal: opts.signal, onEvent: opts.onEvent };
|
|
191
|
+
const context = repoContext(repoRoot, 150);
|
|
192
|
+
const ids = members.map((m) => m.id);
|
|
193
|
+
const result = (extra) => ({
|
|
194
|
+
members: ids,
|
|
195
|
+
decisions: [],
|
|
196
|
+
risks: [],
|
|
197
|
+
ms: Date.now() - t0,
|
|
198
|
+
...extra,
|
|
199
|
+
});
|
|
200
|
+
const proposals = await round(members, "proposal", (m) => proposalPrompt(goal, m, members, context), PROPOSAL_CAP, ro);
|
|
201
|
+
const good = proposals.filter((p) => p.ok);
|
|
202
|
+
const fallback = async (critiques, why) => {
|
|
203
|
+
const plan = await planGoal(goal, crew, repoRoot, { brain: opts.brain, signal: opts.signal, project: true });
|
|
204
|
+
return { ...plan, note: [why, plan.note].filter(Boolean).join("; "), council: result({ proposals, critiques, synthesizedBy: `${plan.by} (${why})` }) };
|
|
205
|
+
};
|
|
206
|
+
if (!good.length || opts.signal?.aborted)
|
|
207
|
+
return fallback([], "council: no proposals came back");
|
|
208
|
+
// A critique needs someone else's proposal to read.
|
|
209
|
+
const critics = good.length >= 2 ? members.filter((m) => good.some((p) => p.agent === m.id)) : [];
|
|
210
|
+
const critiques = critics.length
|
|
211
|
+
? await round(critics, "critique", (m) => critiquePrompt(goal, m, good.filter((p) => p.agent !== m.id)), CRITIQUE_CAP, ro)
|
|
212
|
+
: [];
|
|
213
|
+
const heard = critiques.filter((c) => c.ok);
|
|
214
|
+
if (!opts.brain || opts.signal?.aborted)
|
|
215
|
+
return fallback(critiques, "council: no brain to synthesize");
|
|
216
|
+
opts.onEvent?.({ type: "synthesizing", brain: opts.brain.label });
|
|
217
|
+
const speaking = [...new Set([...good, ...heard].map((e) => e.agent))];
|
|
218
|
+
const prompt = plannerPrompt(goal, crew, context, { notes: councilNotes(good, heard), members: speaking });
|
|
219
|
+
const res = await opts.brain.think(prompt, repoRoot, {
|
|
220
|
+
signal: opts.signal,
|
|
221
|
+
timeoutMs: synthTimeoutMs(opts.brain),
|
|
222
|
+
json: true,
|
|
223
|
+
selfContained: true,
|
|
224
|
+
});
|
|
225
|
+
const tasks = res.ok ? parsePlan(res.text) : null;
|
|
226
|
+
if (!tasks)
|
|
227
|
+
return fallback(critiques, `council: ${opts.brain.label} didn't synthesize a plan`);
|
|
228
|
+
const extras = parseExtras(res.text, speaking);
|
|
229
|
+
const by = members.length ? speaking.join(", ") : "";
|
|
230
|
+
const attributed = tasks.map((t) => {
|
|
231
|
+
const from = (t.from ?? []).filter((f) => speaking.includes(f));
|
|
232
|
+
const credit = from.length ? `proposed by ${from.join(", ")} in Huddle's design council (${by})` : `planned by Huddle's design council (${by})`;
|
|
233
|
+
return {
|
|
234
|
+
...t,
|
|
235
|
+
from,
|
|
236
|
+
spec: `${t.spec}\n\nDesign: ${credit}. Its decisions and risks are in shared memory — huddle_recall "council" before you start.`,
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
tasks: attributed,
|
|
241
|
+
by: `council (${by}) → ${opts.brain.label}`,
|
|
242
|
+
ms: Date.now() - t0,
|
|
243
|
+
tokens: res.tokens,
|
|
244
|
+
council: result({ proposals, critiques, synthesizedBy: opts.brain.label, ...extras }),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Put the council in the Room once the board exists: every proposal and
|
|
249
|
+
* critique as an event, the plan with who-proposed-what, and the decisions
|
|
250
|
+
* and risks in shared memory so agents can huddle_recall them mid-task.
|
|
251
|
+
*/
|
|
252
|
+
export function recordCouncil(db, council, tasks, taskIds) {
|
|
253
|
+
for (const e of [...council.proposals, ...council.critiques]) {
|
|
254
|
+
const type = e.ok ? `council.${e.round}` : "council.skipped";
|
|
255
|
+
db.publish(type, { agent_id: e.agent, payload: { agent: e.agent, round: e.round, text: e.text, ms: e.ms, ...(e.skipped ? { reason: e.skipped } : {}) } });
|
|
256
|
+
}
|
|
257
|
+
db.publish("council.plan", {
|
|
258
|
+
agent_id: "council",
|
|
259
|
+
payload: {
|
|
260
|
+
members: council.members,
|
|
261
|
+
by: council.synthesizedBy,
|
|
262
|
+
ms: council.ms,
|
|
263
|
+
tasks: tasks.map((t, i) => ({ id: taskIds[i] ?? null, title: t.title, from: t.from ?? [] })),
|
|
264
|
+
decisions: council.decisions,
|
|
265
|
+
risks: council.risks,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
const who = council.members.join(", ");
|
|
269
|
+
for (const d of council.decisions)
|
|
270
|
+
db.remember({ kind: "decision", body: `council: ${d}`, why: `agreed by the design council (${who}) before work started`, author: "council" });
|
|
271
|
+
for (const r of council.risks)
|
|
272
|
+
db.remember({ kind: "gotcha", body: `council risk: ${r.body}`, why: r.by ? `flagged by ${r.by} in the design council` : "flagged in the design council", author: "council" });
|
|
273
|
+
}
|
|
274
|
+
//# sourceMappingURL=council.js.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Handoffs: when a task merges, the agents whose tasks depended on it start
|
|
2
|
+
// with a compact brief from the author — what changed, the exact signatures it
|
|
3
|
+
// exports, and the notes it left — instead of rediscovering it from the code.
|
|
4
|
+
//
|
|
5
|
+
// Captured right after the merge lands (base = main before the fast-forward),
|
|
6
|
+
// stored in the Room, and injected into each dependent's launch prompt.
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
const MAX_FILES = 30;
|
|
9
|
+
const MAX_SYMBOLS = 25;
|
|
10
|
+
const MAX_NOTES = 8;
|
|
11
|
+
function git(cwd, args) {
|
|
12
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 32 * 1024 * 1024 }).trim();
|
|
13
|
+
}
|
|
14
|
+
export function headSha(repoRoot) {
|
|
15
|
+
try {
|
|
16
|
+
return git(repoRoot, ["rev-parse", "HEAD"]);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Lines that declare something other code can use. Deliberately simple: the
|
|
23
|
+
// signature line is what a dependent needs, not a parse tree.
|
|
24
|
+
const DECL = [
|
|
25
|
+
/^\s*export\s+(default\s+)?(declare\s+)?(async\s+)?(function\*?|class|interface|type|enum|const|let|var|abstract\s+class)\s+[\w$]+/, // TS / JS
|
|
26
|
+
/^\s*export\s*\{[^}]+\}/, // export { a, b }
|
|
27
|
+
/^module\.exports\b|^exports\.[\w$]+\s*=/, // CommonJS
|
|
28
|
+
/^(async\s+)?def\s+[A-Za-z]\w*\s*\(|^class\s+[A-Za-z]\w*/, // Python (top level, public)
|
|
29
|
+
/^func\s+(\([^)]*\)\s*)?[A-Z]\w*\s*\(|^type\s+[A-Z]\w*\s/, // Go (exported)
|
|
30
|
+
/^\s*pub\s+(async\s+)?(fn|struct|enum|trait|type|const|mod)\s+\w+/, // Rust
|
|
31
|
+
];
|
|
32
|
+
/** Exported signature lines added by a unified diff. */
|
|
33
|
+
export function exportedSymbols(diff) {
|
|
34
|
+
const out = [];
|
|
35
|
+
let file = "";
|
|
36
|
+
for (const line of diff.split("\n")) {
|
|
37
|
+
if (line.startsWith("+++ ")) {
|
|
38
|
+
file = line.replace(/^\+\+\+ (b\/)?/, "");
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!line.startsWith("+") || line.startsWith("+++"))
|
|
42
|
+
continue;
|
|
43
|
+
const src = line.slice(1);
|
|
44
|
+
if (!DECL.some((r) => r.test(src)))
|
|
45
|
+
continue;
|
|
46
|
+
const sig = src.trim().replace(/\s*[{=]>?\s*$/, "").replace(/\s+/g, " ").slice(0, 160);
|
|
47
|
+
const entry = `${file}: ${sig}`;
|
|
48
|
+
if (!out.includes(entry))
|
|
49
|
+
out.push(entry);
|
|
50
|
+
if (out.length >= MAX_SYMBOLS)
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** What a merge changed on main: name-status lines and exported signatures. */
|
|
56
|
+
export function captureChanges(repoRoot, base) {
|
|
57
|
+
try {
|
|
58
|
+
const files = git(repoRoot, ["diff", "--name-status", "--no-renames", base, "HEAD"])
|
|
59
|
+
.split("\n")
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.map((l) => l.replace(/\t/, " "))
|
|
62
|
+
.slice(0, MAX_FILES);
|
|
63
|
+
const diff = git(repoRoot, ["diff", "--unified=0", "--no-color", base, "HEAD"]);
|
|
64
|
+
return { files, symbols: exportedSymbols(diff) };
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { files: [], symbols: [] };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Record the handoff for a just-merged task (base = main before it landed). */
|
|
71
|
+
export function recordHandoff(db, repoRoot, opts) {
|
|
72
|
+
const { files, symbols } = opts.base ? captureChanges(repoRoot, opts.base) : { files: [], symbols: [] };
|
|
73
|
+
// The author's own notes from this task (its "Task … done" line is the summary).
|
|
74
|
+
const notes = db
|
|
75
|
+
.recall()
|
|
76
|
+
.filter((m) => m.author === opts.agentId && m.created_at >= opts.since && !m.body.startsWith(`Task ${opts.taskId} done:`))
|
|
77
|
+
.reverse()
|
|
78
|
+
.slice(0, MAX_NOTES)
|
|
79
|
+
.map((m) => `${m.kind}: ${m.body}${m.why ? ` (why: ${m.why})` : ""}`.slice(0, 400));
|
|
80
|
+
return db.saveHandoff({ task_id: opts.taskId, agent_id: opts.agentId, title: opts.title, files, symbols, notes });
|
|
81
|
+
}
|
|
82
|
+
export function handoffText(h) {
|
|
83
|
+
const lines = [`— from ${h.agent_id}, task "${h.title}" (${h.task_id}), merged into main:`];
|
|
84
|
+
if (h.summary)
|
|
85
|
+
lines.push(` summary: ${h.summary}`);
|
|
86
|
+
if (h.files.length)
|
|
87
|
+
lines.push(` files: ${h.files.join(", ")}`);
|
|
88
|
+
if (h.symbols.length) {
|
|
89
|
+
lines.push(" exports (use these exact names/signatures):");
|
|
90
|
+
for (const s of h.symbols)
|
|
91
|
+
lines.push(` ${s}`);
|
|
92
|
+
}
|
|
93
|
+
if (h.notes.length) {
|
|
94
|
+
lines.push(" notes:");
|
|
95
|
+
for (const n of h.notes)
|
|
96
|
+
lines.push(` - ${n}`);
|
|
97
|
+
}
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The handoff block for a task's launch prompt: one entry per merged
|
|
102
|
+
* dependency that has one. Empty string when there's nothing to hand off.
|
|
103
|
+
*/
|
|
104
|
+
export function handoffsFor(db, taskId) {
|
|
105
|
+
const task = db.getTask(taskId);
|
|
106
|
+
if (!task)
|
|
107
|
+
return { text: "", from: [] };
|
|
108
|
+
const deps = JSON.parse(task.depends_on || "[]");
|
|
109
|
+
const from = deps.map((d) => db.getHandoff(d)).filter((h) => !!h);
|
|
110
|
+
if (!from.length)
|
|
111
|
+
return { text: "", from };
|
|
112
|
+
return {
|
|
113
|
+
text: `\n\nHANDOFF — the tasks yours depends on are merged; their work is already in your directory:\n${from.map(handoffText).join("\n")}`,
|
|
114
|
+
from,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=handoff.js.map
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The launcher: for a scheduled task, it (1) makes the agent's sandbox worktree,
|
|
2
|
+
// (2) hands the agent the Plug (MCP) in whatever way its vendor expects,
|
|
3
|
+
// (3) spawns the agent CLI non-interactively with a prompt that tells it to use
|
|
4
|
+
// the Room. Output goes to .huddle/logs/, not the user's terminal — the Huddle
|
|
5
|
+
// screen shows progress from Room events instead.
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { createWorktree, worktreeFor } from "../sandbox/worktree.js";
|
|
11
|
+
import { UsageMeter } from "../adapters/usage.js";
|
|
12
|
+
const CHECKIN = `
|
|
13
|
+
You are collaborating with other AI agents in a shared repo via the "huddle" MCP tools.
|
|
14
|
+
RULES:
|
|
15
|
+
1. Call huddle_recall() and huddle_board() BEFORE you start — inherit context, don't re-derive it.
|
|
16
|
+
2. Your task below is already claimed for you — don't claim it again.
|
|
17
|
+
3. Call huddle_announce_plan() with the files/symbols you'll touch BEFORE editing. If huddle_check_overlap flags a teammate, coordinate via huddle_remember instead of colliding.
|
|
18
|
+
Your task's file list is a plan, not a fence: if the work needs another file (e.g. a test wants your module registered in an index),
|
|
19
|
+
announce it and make the edit — Huddle lands same-file edits from different agents safely. Never finish with failing tests
|
|
20
|
+
because a file wasn't on your list; Huddle's merge gate runs the full test suite and rejects red work.
|
|
21
|
+
4. Call huddle_progress() after each meaningful step.
|
|
22
|
+
5. Record decisions with huddle_remember(kind="decision", ..., why=...) so teammates learn WHY.
|
|
23
|
+
Unsure about a shape or contract another task owns? huddle_ask("…", to="<agent>") before guessing — teammates may be on other machines.
|
|
24
|
+
Changing something a teammate relies on? Tell them: huddle_send(to="<agent>" or "all", message="…").
|
|
25
|
+
If a tool result shows unread_messages, call huddle_inbox() and answer questions with huddle_send(reply_to=…).
|
|
26
|
+
A teammate offers help and part of your task is separable? huddle_split it to them instead of doing it all yourself.
|
|
27
|
+
A HANDOFF section below (if any) is what the tasks you depend on built — use those exact names.
|
|
28
|
+
6. Do the work by editing files in your current directory. Don't commit — Huddle tests and merges for you.
|
|
29
|
+
7. When finished, call huddle_done(task_id, summary).
|
|
30
|
+
`.trim();
|
|
31
|
+
// dist/orchestrator/launcher.js -> dist/cli.js
|
|
32
|
+
const CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
33
|
+
function modelFor(adapter, role) {
|
|
34
|
+
return role === "orchestrator"
|
|
35
|
+
? adapter.models.orchestrator
|
|
36
|
+
: role === "fast"
|
|
37
|
+
? adapter.models.fast
|
|
38
|
+
: role === "reviewer"
|
|
39
|
+
? adapter.models.reviewer
|
|
40
|
+
: adapter.models.worker;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Launch `agentId` (an instance of `adapter`) on a task. `dryRun` returns the
|
|
44
|
+
* exact command without creating a worktree or spawning anything.
|
|
45
|
+
*/
|
|
46
|
+
export function launchAgent(opts) {
|
|
47
|
+
const { repoRoot, adapter, agentId, role, taskId, taskSpec, roomUrl } = opts;
|
|
48
|
+
const wt = opts.worktree ?? (opts.dryRun ? worktreeFor(repoRoot, agentId, taskId) : createWorktree(repoRoot, agentId, taskId));
|
|
49
|
+
const slug = `${agentId}-${taskId}`.replace(/[^\w.-]/g, "_");
|
|
50
|
+
const runDir = path.join(repoRoot, ".huddle", "run", slug);
|
|
51
|
+
const logPath = path.join(repoRoot, ".huddle", "logs", `${slug}.log`);
|
|
52
|
+
const plug = {
|
|
53
|
+
command: process.execPath,
|
|
54
|
+
args: [CLI_PATH, "plug"],
|
|
55
|
+
env: { HUDDLE_ROOM_URL: roomUrl, HUDDLE_AGENT_ID: agentId, HUDDLE_SECRET: opts.roomSecret ?? "" },
|
|
56
|
+
};
|
|
57
|
+
let mcpConfigPath = null;
|
|
58
|
+
const excludes = [];
|
|
59
|
+
if (adapter.plug.file && adapter.plug.entry) {
|
|
60
|
+
const inWorktree = adapter.plug.file.in === "worktree";
|
|
61
|
+
mcpConfigPath = path.join(inWorktree ? wt.path : runDir, adapter.plug.file.path);
|
|
62
|
+
if (inWorktree)
|
|
63
|
+
excludes.push(adapter.plug.file.path);
|
|
64
|
+
if (!opts.dryRun)
|
|
65
|
+
writeJsonMerged(mcpConfigPath, adapter.plug.entry(plug));
|
|
66
|
+
}
|
|
67
|
+
const prompt = `${CHECKIN}\n\nYOUR TASK (task_id: ${taskId}): ${taskSpec}`;
|
|
68
|
+
const { cmd, cwd, env } = adapter.launch({
|
|
69
|
+
worktree: wt.path,
|
|
70
|
+
prompt,
|
|
71
|
+
model: modelFor(adapter, role),
|
|
72
|
+
mcpConfigPath,
|
|
73
|
+
plug,
|
|
74
|
+
approvals: opts.approvals,
|
|
75
|
+
});
|
|
76
|
+
const handle = {
|
|
77
|
+
agentId,
|
|
78
|
+
taskId,
|
|
79
|
+
worktree: wt.path,
|
|
80
|
+
branch: wt.branch,
|
|
81
|
+
cmd,
|
|
82
|
+
logPath,
|
|
83
|
+
excludes,
|
|
84
|
+
startedAt: Date.now(),
|
|
85
|
+
meter: new UsageMeter(),
|
|
86
|
+
};
|
|
87
|
+
if (opts.dryRun)
|
|
88
|
+
return handle;
|
|
89
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
90
|
+
const log = fs.createWriteStream(logPath, { flags: "a" });
|
|
91
|
+
handle.child = spawn(cmd[0], cmd.slice(1), {
|
|
92
|
+
cwd,
|
|
93
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
94
|
+
env: { ...process.env, ...env, ...plug.env },
|
|
95
|
+
});
|
|
96
|
+
// Everything still goes to the log; stdout is also scanned for token usage.
|
|
97
|
+
let partial = "";
|
|
98
|
+
handle.child.stdout.on("data", (chunk) => {
|
|
99
|
+
log.write(chunk);
|
|
100
|
+
if (!adapter.usage)
|
|
101
|
+
return;
|
|
102
|
+
const lines = (partial + chunk.toString("utf8")).split("\n");
|
|
103
|
+
partial = lines.pop() ?? "";
|
|
104
|
+
let changed = false;
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
const r = adapter.usage(line.trim());
|
|
107
|
+
if (r) {
|
|
108
|
+
handle.meter.add(r);
|
|
109
|
+
changed = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (changed)
|
|
113
|
+
opts.onTokens?.(handle.meter.tokens);
|
|
114
|
+
});
|
|
115
|
+
handle.child.stderr.on("data", (chunk) => log.write(chunk));
|
|
116
|
+
handle.child.on("exit", () => log.end());
|
|
117
|
+
return handle;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Stop an agent's process: SIGTERM, then SIGKILL if it is still alive after
|
|
121
|
+
* `graceMs`. Found live on the capstone: a cursor-agent QA check whose
|
|
122
|
+
* connection stalled ignored SIGTERM, so QA's 15-minute time box did nothing
|
|
123
|
+
* and the run hung for 65 more minutes.
|
|
124
|
+
*/
|
|
125
|
+
export function stopChild(child, graceMs = 5000) {
|
|
126
|
+
if (!child || child.exitCode !== null || child.signalCode !== null)
|
|
127
|
+
return;
|
|
128
|
+
child.kill("SIGTERM");
|
|
129
|
+
setTimeout(() => {
|
|
130
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
131
|
+
child.kill("SIGKILL");
|
|
132
|
+
}, graceMs).unref();
|
|
133
|
+
}
|
|
134
|
+
// --- tiny helpers ---------------------------------------------------------
|
|
135
|
+
function writeJsonMerged(file, entry) {
|
|
136
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
137
|
+
let existing = {};
|
|
138
|
+
if (fs.existsSync(file)) {
|
|
139
|
+
try {
|
|
140
|
+
existing = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
/* overwrite malformed */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
fs.writeFileSync(file, JSON.stringify(deepMerge(existing, entry), null, 2));
|
|
147
|
+
}
|
|
148
|
+
function deepMerge(a, b) {
|
|
149
|
+
const out = { ...a };
|
|
150
|
+
for (const [k, v] of Object.entries(b)) {
|
|
151
|
+
out[k] = v && typeof v === "object" && !Array.isArray(v) ? deepMerge(a[k] ?? {}, v) : v;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=launcher.js.map
|