huddle-cli 0.0.0-stage → 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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +102 -0
- package/README.md +376 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +54 -0
- package/dist/adapters/claude.js +86 -0
- package/dist/adapters/codex.js +64 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +92 -0
- package/dist/adapters/custom.js +342 -0
- package/dist/adapters/droid.js +58 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +57 -0
- package/dist/adapters/index.js +161 -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 +412 -0
- package/dist/attach/guard.js +83 -0
- package/dist/attach/live.js +226 -0
- package/dist/attach/plug.js +185 -0
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +177 -0
- package/dist/cli.js +244 -0
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +267 -0
- package/dist/orchestrator/ask.js +54 -0
- package/dist/orchestrator/brain.js +174 -0
- package/dist/orchestrator/council.js +615 -0
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +171 -0
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +212 -0
- package/dist/orchestrator/qa.js +398 -0
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +1207 -0
- package/dist/orchestrator/takeover.js +240 -0
- package/dist/platform.js +273 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +680 -0
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +348 -0
- package/dist/room/mail.js +212 -0
- package/dist/room/progress.js +149 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +882 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +86 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +392 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1955 -0
- package/dist/ui/catalog.js +113 -0
- package/dist/ui/dashboard.js +335 -0
- package/dist/ui/doctor.js +58 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +338 -0
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +1529 -0
- package/dist/ui/term.js +249 -0
- package/dist/ui/watch.js +271 -0
- package/install.sh +44 -0
- package/package.json +63 -4
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
// The design council: for a big goal, every ready agent brainstorms before
|
|
2
|
+
// anyone writes code — not just the brain. 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. cross-review each agent reads the OTHERS' proposals and answers in fixed
|
|
7
|
+
// sections: ADOPT (what <agent> got right that I missed), FLAW
|
|
8
|
+
// (what <agent> got wrong and why), REVISED (my new position) —
|
|
9
|
+
// what a person does by pasting one chat's answer into another
|
|
10
|
+
// 3. synthesize the agent whose ideas the others ADOPTed most writes the plan
|
|
11
|
+
// (the brain when nobody adopted anything, or as the fallback),
|
|
12
|
+
// with ONE primary idea author per task and a CONTRACT: the
|
|
13
|
+
// exact names every task codes to
|
|
14
|
+
// 4. ratify every other agent replies OK or OBJECT: <change>; objections
|
|
15
|
+
// get one revision by the synthesizer, never more
|
|
16
|
+
//
|
|
17
|
+
// Every call is time-boxed: an agent that is slow or errors is skipped, never
|
|
18
|
+
// waited on. Small goals skip the council and keep the fast single-brain plan.
|
|
19
|
+
import { setContract, capContract } from "../room/contract.js";
|
|
20
|
+
import { askAgent } from "./ask.js";
|
|
21
|
+
import { repoContext } from "./brain.js";
|
|
22
|
+
import { carryGoal } from "./goal.js";
|
|
23
|
+
import { goalSize, jsonCandidates, listsOwnTasks, parsePlan, planGoal, plannerPrompt } from "./planner.js";
|
|
24
|
+
const defaultAsk = (m, prompt, cwd, o) => askAgent(m.adapter, prompt, cwd, { timeoutMs: o.timeoutMs, signal: o.signal, noTools: true, quick: o.quick ?? true });
|
|
25
|
+
const PROPOSAL_CAP = 1500;
|
|
26
|
+
const CRITIQUE_CAP = 1200;
|
|
27
|
+
/** Default per-call time box; HUDDLE_COUNCIL_TIMEOUT_S overrides. */
|
|
28
|
+
export function councilTimeoutMs() {
|
|
29
|
+
const s = Number(process.env.HUDDLE_COUNCIL_TIMEOUT_S);
|
|
30
|
+
return Number.isFinite(s) && s > 0 ? s * 1000 : 120_000;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The synthesis time box. A council only runs for big goals, whose plan is
|
|
34
|
+
* long (10+ self-contained specs): 2 minutes, the small-goal planner's box,
|
|
35
|
+
* can cut it off half-written, and the fallback then plans the big goal alone.
|
|
36
|
+
*/
|
|
37
|
+
export function synthTimeoutMs(brain) {
|
|
38
|
+
return brain.spec.startsWith("ollama") ? 6 * 60_000 : 5 * 60_000;
|
|
39
|
+
}
|
|
40
|
+
/** The agents that sit on the council: one seat per CLI (claude#2 thinks like claude), and only those that can answer. */
|
|
41
|
+
export function councilMembers(workers) {
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const w of workers) {
|
|
45
|
+
if (!w.adapter?.ask || seen.has(w.adapter.id))
|
|
46
|
+
continue;
|
|
47
|
+
seen.add(w.adapter.id);
|
|
48
|
+
out.push({ id: w.id, adapter: w.adapter });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Should this goal get a design council? `flag` is --council (true) /
|
|
54
|
+
* --no-council (false). Without a flag, only big goals do: a from-scratch
|
|
55
|
+
* project, a long spec, or a real goal in a (nearly) empty repo — and not when
|
|
56
|
+
* the goal already spells out its own tasks.
|
|
57
|
+
*/
|
|
58
|
+
export function councilWanted(goal, o) {
|
|
59
|
+
if (o.flag === false)
|
|
60
|
+
return { yes: false, why: "--no-council" };
|
|
61
|
+
if (o.members < 2)
|
|
62
|
+
return { yes: false, why: "needs 2+ agents that can answer" };
|
|
63
|
+
if (o.flag === true)
|
|
64
|
+
return { yes: true, why: "--council" };
|
|
65
|
+
// A goal that already spells out its tasks needs no brainstorm, even a big one.
|
|
66
|
+
if (listsOwnTasks(goal))
|
|
67
|
+
return { yes: false, why: "the goal already lists its tasks" };
|
|
68
|
+
const size = goalSize(goal, { repoFiles: o.repoFiles });
|
|
69
|
+
return { yes: size.big, why: size.why };
|
|
70
|
+
}
|
|
71
|
+
/** Strip escape codes and cap; a council answer is advice, not a novel. */
|
|
72
|
+
function clean(text, cap) {
|
|
73
|
+
// eslint-disable-next-line no-control-regex
|
|
74
|
+
const t = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
75
|
+
return t.length <= cap ? t : t.slice(0, cap - 1).replace(/\s+\S*$/, "") + "…";
|
|
76
|
+
}
|
|
77
|
+
function crewLine(members) {
|
|
78
|
+
return members.map((m) => `${m.id} (good at: ${m.adapter.prefers.join(", ") || "anything"})`).join("; ");
|
|
79
|
+
}
|
|
80
|
+
function proposalPrompt(goal, me, members, context) {
|
|
81
|
+
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.
|
|
82
|
+
|
|
83
|
+
GOAL: ${goal}
|
|
84
|
+
|
|
85
|
+
CREW: ${crewLine(members)}
|
|
86
|
+
|
|
87
|
+
REPO SNAPSHOT:
|
|
88
|
+
${context}
|
|
89
|
+
|
|
90
|
+
You have no tools in this step and cannot read files: the snapshot above is everything. Do not edit anything.
|
|
91
|
+
Reply in plain text, at most 200 words, with exactly these four sections:
|
|
92
|
+
ARCHITECTURE: one or two sentences — the shape of the solution (modules, layers, data flow)
|
|
93
|
+
TASKS: 2-8 lines "- title — files — depends on"
|
|
94
|
+
CONTRACTS: the interfaces tasks share (exact function names, file paths, data shapes)
|
|
95
|
+
RISKS: the flaws, unknowns and edge cases most likely to break this`;
|
|
96
|
+
}
|
|
97
|
+
function critiquePrompt(goal, me, others, mine) {
|
|
98
|
+
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 — and what they got right that you missed.
|
|
99
|
+
|
|
100
|
+
GOAL: ${goal}
|
|
101
|
+
|
|
102
|
+
${others.map((p) => `--- ${p.agent} proposes ---\n${clean(p.text, 1200)}`).join("\n\n")}
|
|
103
|
+
${mine ? `\n--- your own proposal ---\n${clean(mine.text, 800)}\n` : ""}
|
|
104
|
+
You have no tools in this step. Do not edit anything.
|
|
105
|
+
Reply in plain text, at most 160 words, with exactly these three sections. Start every ADOPT and FLAW line with the agent it is about (${others.map((p) => p.agent).join(", ")}):
|
|
106
|
+
ADOPT: 1-3 lines "- <agent>: what they got right that you missed"
|
|
107
|
+
FLAW: 1-3 lines "- <agent>: what they got wrong and why — a concrete break (a name mismatch, two tasks in one file, a missed requirement)"
|
|
108
|
+
REVISED: your updated position, 3-6 lines`;
|
|
109
|
+
}
|
|
110
|
+
/** First meaningful line of a section ("ARCHITECTURE:", "FLAWS:") for a one-line summary. */
|
|
111
|
+
export function headline(text, section) {
|
|
112
|
+
const lines = text.split("\n").map((l) => l.replace(/[*#`_]/g, "").trim());
|
|
113
|
+
const i = lines.findIndex((l) => l.toUpperCase().startsWith(section.toUpperCase() + ":") || l.toUpperCase() === section.toUpperCase());
|
|
114
|
+
const pick = (l) => l.replace(/^[-•\d.)\s]+/, "").trim();
|
|
115
|
+
if (i >= 0) {
|
|
116
|
+
const rest = pick(lines[i].slice(section.length + 1));
|
|
117
|
+
if (rest)
|
|
118
|
+
return rest;
|
|
119
|
+
const next = lines.slice(i + 1).find((l) => l && !/^[A-Z ]+:/.test(l));
|
|
120
|
+
if (next)
|
|
121
|
+
return pick(next);
|
|
122
|
+
}
|
|
123
|
+
return pick(lines.find(Boolean) ?? "");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The lines under each SECTION header, header-line remainder included. Real
|
|
127
|
+
* models dress headers up: "ADOPT:", "ADOPTS", "**Adopt:**", "## ADOPT",
|
|
128
|
+
* "1. ADOPT:", "ADOPTED IDEAS:" — all count. `ids` are agent ids, so a
|
|
129
|
+
* "CODEX: …" line is content, not a new section.
|
|
130
|
+
*/
|
|
131
|
+
function sections(text, names, ids = []) {
|
|
132
|
+
const out = Object.fromEntries(names.map((n) => [n, []]));
|
|
133
|
+
const alias = (w) => names.find((n) => w === n || w === n + "S" || w === n + "ED" || (n === "REVISED" && /^REVISIONS?$/.test(w)));
|
|
134
|
+
let cur = null;
|
|
135
|
+
for (const raw of text.split("\n")) {
|
|
136
|
+
// Strip markdown emphasis and heading marks, but keep a "#" inside an id (claude#2).
|
|
137
|
+
const line = raw.replace(/[*`]|__/g, "").replace(/^\s*#+\s*/, "").trim();
|
|
138
|
+
const head = /^([-•]\s*)?(?:\d+[.)]\s*)?([A-Za-z]+)((?:\s+[A-Za-z]+){0,2})\s*(?:(:)\s*(.*))?$/.exec(line);
|
|
139
|
+
const word = head?.[2].toUpperCase() ?? "";
|
|
140
|
+
const isId = !!head && !head[3] && ids.some((i) => i.toUpperCase() === word);
|
|
141
|
+
// With a colon, "ADOPT:" / "Adopted ideas:"; without one only a bare word ("ADOPT"), so "- revised the schema" stays content.
|
|
142
|
+
const name = head && !isId && (head[4] || (!head[1] && !head[3])) ? alias(word) : undefined;
|
|
143
|
+
if (head && name) {
|
|
144
|
+
cur = name;
|
|
145
|
+
if (head[5]?.trim())
|
|
146
|
+
out[name].push(head[5].trim());
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// Another all-caps section (MISSING:, NEXT:) ends this one.
|
|
150
|
+
if (head && head[4] && !isId && !head[1] && /^[A-Z]{3,}$/.test(head[2] + head[3].replace(/\s+/g, "")))
|
|
151
|
+
cur = null;
|
|
152
|
+
else if (cur && line)
|
|
153
|
+
out[cur].push(line);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* ADOPT / FLAW lines from a cross-review, each tied to the teammate it is
|
|
159
|
+
* about: "- codex: …" (or a line naming one teammate). A line about yourself
|
|
160
|
+
* or nobody is dropped — credit has to point at someone else.
|
|
161
|
+
*/
|
|
162
|
+
export function parseReview(text, me, others) {
|
|
163
|
+
const s = sections(text, ["ADOPT", "FLAW", "REVISED"], [me, ...others]);
|
|
164
|
+
const target = (line) => {
|
|
165
|
+
const body = line.replace(/^[-•*\d.)\s]+/, "").trim();
|
|
166
|
+
if (!body || /^(none|nothing|n\/a)\b/i.test(body))
|
|
167
|
+
return null;
|
|
168
|
+
const lead = /^@?([\w#./-]+?)(?:'s)?\s*(?:[:—–]|\s-\s)\s*(.+)$/.exec(body);
|
|
169
|
+
if (lead) {
|
|
170
|
+
const of = others.find((o) => o.toLowerCase() === lead[1].toLowerCase());
|
|
171
|
+
if (of)
|
|
172
|
+
return { of, text: lead[2].trim() };
|
|
173
|
+
}
|
|
174
|
+
const named = others.filter((o) => new RegExp(`(^|[^\\w#/-])${o.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\w#/-])`, "i").test(body));
|
|
175
|
+
return named.length === 1 ? { of: named[0], text: body } : null;
|
|
176
|
+
};
|
|
177
|
+
const points = (lines) => lines
|
|
178
|
+
.map(target)
|
|
179
|
+
.filter((x) => !!x && x.of !== me)
|
|
180
|
+
.slice(0, 4)
|
|
181
|
+
.map((x) => ({ by: me, of: x.of, text: clean(x.text, 300) }));
|
|
182
|
+
return { adopts: points(s.ADOPT), flaws: points(s.FLAW), revised: clean(s.REVISED.join("\n"), 600) };
|
|
183
|
+
}
|
|
184
|
+
/** Run a call under our own time box too: a call that ignores its timeout still can't hold up the run. */
|
|
185
|
+
async function boxed(call, timeoutMs) {
|
|
186
|
+
const t0 = Date.now();
|
|
187
|
+
let timer;
|
|
188
|
+
const late = new Promise((r) => {
|
|
189
|
+
timer = setTimeout(() => r({ ok: false, text: "", late: true }), timeoutMs + 2000);
|
|
190
|
+
});
|
|
191
|
+
try {
|
|
192
|
+
return { res: await Promise.race([call(), late]), ms: Date.now() - t0 };
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
return { res: { ok: false, text: String(e?.message ?? e) }, ms: Date.now() - t0 };
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function round(members, name, promptFor, cap, o, parse) {
|
|
202
|
+
o.onEvent?.({ type: "round", round: name, agents: members.map((m) => m.id) });
|
|
203
|
+
return Promise.all(members.map(async (m) => {
|
|
204
|
+
const { res, ms } = await boxed(() => o.ask(m, promptFor(m), o.cwd, { timeoutMs: o.timeoutMs, signal: o.signal }), o.timeoutMs);
|
|
205
|
+
const text = res.ok ? clean(res.text, cap) : "";
|
|
206
|
+
const entry = text
|
|
207
|
+
? { agent: m.id, round: name, ok: true, text, ms, ...(parse ? parse(m, res.text) : {}) }
|
|
208
|
+
: { agent: m.id, round: name, ok: false, text: "", ms, skipped: res.late || ms >= o.timeoutMs ? "timed out" : "no answer" };
|
|
209
|
+
o.onEvent?.({ type: "entry", entry });
|
|
210
|
+
return entry;
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
/** Who earned ADOPTs from whom (only from OTHERS — you can't adopt your own idea). */
|
|
214
|
+
export function adoptTally(critiques) {
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const c of critiques)
|
|
217
|
+
for (const a of c.adopts ?? [])
|
|
218
|
+
if (a.of !== a.by)
|
|
219
|
+
out[a.of] = [...new Set([...(out[a.of] ?? []), a.by])];
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
/** Total ADOPT points and FLAW points each proposer received from OTHERS (tie-breaks for who writes the plan). */
|
|
223
|
+
export function reviewTally(critiques) {
|
|
224
|
+
const adopts = {};
|
|
225
|
+
const flaws = {};
|
|
226
|
+
for (const c of critiques) {
|
|
227
|
+
for (const a of c.adopts ?? [])
|
|
228
|
+
if (a.of !== a.by)
|
|
229
|
+
adopts[a.of] = (adopts[a.of] ?? 0) + 1;
|
|
230
|
+
for (const f of c.flaws ?? [])
|
|
231
|
+
if (f.of !== f.by)
|
|
232
|
+
flaws[f.of] = (flaws[f.of] ?? 0) + 1;
|
|
233
|
+
}
|
|
234
|
+
return { adopts, flaws };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Who writes the plan: the proposer whose ideas the most teammates ADOPTed.
|
|
238
|
+
* Ties, in order: more ADOPT points in all, fewer FLAWs found in its
|
|
239
|
+
* proposal, fewest plans synthesized so far, then roster order. Found live
|
|
240
|
+
* (capstone round 4): all three were adopted by 2 teammates, the tie fell
|
|
241
|
+
* through to roster order and claude wrote the plan although codex's ideas
|
|
242
|
+
* were adopted 4 times to claude's 2.
|
|
243
|
+
* null = nobody adopted anything: no signal, so the brain writes it.
|
|
244
|
+
*/
|
|
245
|
+
export function pickSynthesizer(candidates, adopted, past = {}, tally = {}) {
|
|
246
|
+
const score = (id) => adopted[id]?.length ?? 0;
|
|
247
|
+
const best = Math.max(0, ...candidates.map(score));
|
|
248
|
+
if (!best)
|
|
249
|
+
return null;
|
|
250
|
+
const top = candidates.filter((c) => score(c) === best);
|
|
251
|
+
const total = (id) => tally.adopts?.[id] ?? score(id);
|
|
252
|
+
const flaws = (id) => tally.flaws?.[id] ?? 0;
|
|
253
|
+
const plans = (id) => past[id] ?? 0;
|
|
254
|
+
// Each tie-break in turn, as [value, larger wins?, how the reason says it].
|
|
255
|
+
const breaks = [
|
|
256
|
+
[total, true, (v) => `won on most adoptions in all (${v})`],
|
|
257
|
+
[flaws, false, (v) => `won on fewest flaws found in its proposal (${v})`],
|
|
258
|
+
[plans, false, () => "won on fewest plans written so far"],
|
|
259
|
+
];
|
|
260
|
+
let field = top;
|
|
261
|
+
let won = "first on the roster";
|
|
262
|
+
for (const [f, more, say] of breaks) {
|
|
263
|
+
if (field.length < 2)
|
|
264
|
+
break;
|
|
265
|
+
const v = more ? Math.max(...field.map(f)) : Math.min(...field.map(f));
|
|
266
|
+
const next = field.filter((c) => f(c) === v);
|
|
267
|
+
if (next.length < field.length)
|
|
268
|
+
won = say(v);
|
|
269
|
+
field = next;
|
|
270
|
+
}
|
|
271
|
+
const id = field[0];
|
|
272
|
+
const tie = top.length > 1 ? `; tied with ${top.filter((c) => c !== id).join(", ")}, ${won}` : "";
|
|
273
|
+
return { id, why: `most ideas adopted by teammates (${best}: ${adopted[id].join(", ")})${tie}` };
|
|
274
|
+
}
|
|
275
|
+
/** The council's material for the synthesizer, compact. */
|
|
276
|
+
function councilNotes(proposals, critiques, adopted) {
|
|
277
|
+
const parts = [`DESIGN COUNCIL — proposals (each agent planned independently):`];
|
|
278
|
+
for (const p of proposals)
|
|
279
|
+
parts.push(`--- ${p.agent} ---\n${p.text}`);
|
|
280
|
+
if (critiques.length) {
|
|
281
|
+
parts.push(`DESIGN COUNCIL — cross-reviews (each agent read the others' proposals: ADOPT = what it took from a teammate, FLAW = what it found wrong):`);
|
|
282
|
+
for (const c of critiques)
|
|
283
|
+
parts.push(`--- ${c.agent} ---\n${c.text}`);
|
|
284
|
+
}
|
|
285
|
+
const tally = Object.entries(adopted).map(([a, by]) => `${a} (by ${by.join(", ")})`);
|
|
286
|
+
if (tally.length)
|
|
287
|
+
parts.push(`ADOPTED IDEAS: ${tally.join("; ")}. Build on these; fix every FLAW.`);
|
|
288
|
+
return parts.join("\n\n");
|
|
289
|
+
}
|
|
290
|
+
/** The first JSON body in a reply that holds a tasks array. */
|
|
291
|
+
function planJson(text) {
|
|
292
|
+
for (const body of jsonCandidates(text)) {
|
|
293
|
+
try {
|
|
294
|
+
const raw = JSON.parse(body);
|
|
295
|
+
if (Array.isArray(raw?.tasks))
|
|
296
|
+
return raw;
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
/* next candidate */
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
/** decisions / risks / contract from the synthesis reply (the tasks come from parsePlan). */
|
|
305
|
+
function parseExtras(text, members) {
|
|
306
|
+
const raw = planJson(text);
|
|
307
|
+
if (!raw)
|
|
308
|
+
return { decisions: [], risks: [], contract: "", objections: [] };
|
|
309
|
+
const decisions = (Array.isArray(raw.decisions) ? raw.decisions : []).map((d) => clean(String(d ?? ""), 300)).filter(Boolean).slice(0, 6);
|
|
310
|
+
const risks = (Array.isArray(raw.risks) ? raw.risks : [])
|
|
311
|
+
.map((r) => {
|
|
312
|
+
const body = clean(String(typeof r === "string" ? r : r?.risk ?? r?.body ?? ""), 300);
|
|
313
|
+
const by = typeof r === "object" ? members.find((m) => m.toLowerCase() === String(r?.by ?? "").trim().toLowerCase()) : undefined;
|
|
314
|
+
return body ? { body, ...(by ? { by } : {}) } : null;
|
|
315
|
+
})
|
|
316
|
+
.filter(Boolean)
|
|
317
|
+
.slice(0, 6);
|
|
318
|
+
// A model may hand back the contract as an object; keep it readable either way.
|
|
319
|
+
const c = raw.contract;
|
|
320
|
+
const contract = typeof c === "string" ? c : c && typeof c === "object" ? JSON.stringify(c, null, 1) : "";
|
|
321
|
+
return { decisions, risks, contract: capContract(clean(contract, 100_000)), objections: Array.isArray(raw.objections) ? raw.objections : [] };
|
|
322
|
+
}
|
|
323
|
+
function ratifyPrompt(goal, me, by, tasks, contract) {
|
|
324
|
+
return `You are ${me.id}, on Huddle's design council. ${by} wrote the plan below from everyone's proposals. Before anyone codes, check its CONTRACT: the exact names every task will code to.
|
|
325
|
+
|
|
326
|
+
GOAL: ${goal}
|
|
327
|
+
|
|
328
|
+
TASKS:
|
|
329
|
+
${tasks.map((t, i) => `${i}. ${t.title}${t.files?.length ? ` (${t.files.join(", ")})` : ""}`).join("\n")}
|
|
330
|
+
|
|
331
|
+
CONTRACT:
|
|
332
|
+
${contract}
|
|
333
|
+
|
|
334
|
+
You have no tools in this step. Do not edit anything.
|
|
335
|
+
Reply with ONLY "OK", or up to 3 lines "OBJECT: <one concrete change>". Object only to what would break a task: a missing or mismatched name, a wrong signature, two tasks owning one file, a requirement no task covers, or a name, message or format that differs from what the GOAL states exactly (e.g. a new error message for a case the goal already gives one).`;
|
|
336
|
+
}
|
|
337
|
+
/** OK / OBJECT: … → a verdict. Anything else is unclear, which never blocks the plan. */
|
|
338
|
+
export function parseRatify(text) {
|
|
339
|
+
const lines = text.split("\n").map((l) => l.replace(/[*`_]/g, "").trim());
|
|
340
|
+
const objections = lines
|
|
341
|
+
// "OBJECT: x", "OBJECTION - x", "I object: x" — models vary the punctuation.
|
|
342
|
+
.map((l) => /^[-•\d.)\s]*(?:I\s+)?OBJECT(?:ION)?S?\s*(?::|\s[-—–])\s*(.+)$/i.exec(l)?.[1]?.trim())
|
|
343
|
+
.filter((x) => !!x && !/^(none|nothing)\b/i.test(x))
|
|
344
|
+
.slice(0, 3)
|
|
345
|
+
.map((x) => clean(x, 400));
|
|
346
|
+
if (objections.length)
|
|
347
|
+
return { verdict: "object", objections };
|
|
348
|
+
return { verdict: lines.some((l) => /^[-•\s]*(OK|APPROVED?|LGTM|NO OBJECTIONS?)\b/i.test(l)) ? "ok" : "unclear", objections: [] };
|
|
349
|
+
}
|
|
350
|
+
function revisionPrompt(goal, objections, planText) {
|
|
351
|
+
return `You wrote this plan for Huddle's design council. Before work starts, teammates object to it:
|
|
352
|
+
${objections.map((o) => `- ${o.by}: ${o.objection}`).join("\n")}
|
|
353
|
+
|
|
354
|
+
GOAL: ${goal}
|
|
355
|
+
|
|
356
|
+
YOUR PLAN:
|
|
357
|
+
${planText}
|
|
358
|
+
|
|
359
|
+
Apply each objection that is right and reject the others. You have no tools in this step.
|
|
360
|
+
Reply with ONLY the full revised JSON in the same shape (tasks, decisions, risks, contract), plus "objections":[{"by":"agent id","taken":true,"why":"short"}] — one entry per objection above. No prose, no code fences.`;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Brainstorm with every member, cross-review, then have the most-adopted
|
|
364
|
+
* agent (or the brain) write the plan and its contract, and the others ratify
|
|
365
|
+
* it. Falls back to today's single-brain planner whenever the council can't
|
|
366
|
+
* help (no proposals came back, or no synthesis was a plan), so it never blocks a run.
|
|
367
|
+
*/
|
|
368
|
+
export async function runCouncil(goal, crew, members, repoRoot, opts) {
|
|
369
|
+
const t0 = Date.now();
|
|
370
|
+
const timeoutMs = opts.timeoutMs ?? councilTimeoutMs();
|
|
371
|
+
const ro = { ask: opts.ask ?? defaultAsk, cwd: repoRoot, timeoutMs, signal: opts.signal, onEvent: opts.onEvent };
|
|
372
|
+
const context = repoContext(repoRoot, 150);
|
|
373
|
+
const ids = members.map((m) => m.id);
|
|
374
|
+
const result = (extra) => ({
|
|
375
|
+
members: ids,
|
|
376
|
+
decisions: [],
|
|
377
|
+
risks: [],
|
|
378
|
+
ms: Date.now() - t0,
|
|
379
|
+
...extra,
|
|
380
|
+
});
|
|
381
|
+
const proposals = await round(members, "proposal", (m) => proposalPrompt(goal, m, members, context), PROPOSAL_CAP, ro);
|
|
382
|
+
const good = proposals.filter((p) => p.ok);
|
|
383
|
+
const fallback = async (critiques, why) => {
|
|
384
|
+
const plan = await planGoal(goal, crew, repoRoot, { brain: opts.brain, signal: opts.signal, project: true });
|
|
385
|
+
return { ...plan, note: [why, plan.note].filter(Boolean).join("; "), council: result({ proposals, critiques, synthesizedBy: `${plan.by} (${why})` }) };
|
|
386
|
+
};
|
|
387
|
+
if (!good.length || opts.signal?.aborted)
|
|
388
|
+
return fallback([], "council: no proposals came back");
|
|
389
|
+
// A cross-review needs someone else's proposal to read.
|
|
390
|
+
const critics = good.length >= 2 ? members.filter((m) => good.some((p) => p.agent === m.id)) : [];
|
|
391
|
+
const proposers = good.map((p) => p.agent);
|
|
392
|
+
const critiques = critics.length
|
|
393
|
+
? await round(critics, "critique", (m) => critiquePrompt(goal, m, good.filter((p) => p.agent !== m.id), good.find((p) => p.agent === m.id)), CRITIQUE_CAP, ro, (m, text) => parseReview(text, m.id, proposers.filter((p) => p !== m.id)))
|
|
394
|
+
: [];
|
|
395
|
+
const heard = critiques.filter((c) => c.ok);
|
|
396
|
+
const adopted = adoptTally(heard);
|
|
397
|
+
if (opts.signal?.aborted)
|
|
398
|
+
return fallback(critiques, "council: stopped");
|
|
399
|
+
const speaking = [...new Set([...good, ...heard].map((e) => e.agent))];
|
|
400
|
+
const prompt = plannerPrompt(goal, crew, context, { notes: councilNotes(good, heard, adopted), members: speaking });
|
|
401
|
+
// The synthesizers to try, in order: the most-adopted agent, then the brain.
|
|
402
|
+
const synths = [];
|
|
403
|
+
const hung = new Set(); // a synthesizer that ran out its box isn't asked to ratify (it would hang again)
|
|
404
|
+
const pick = pickSynthesizer(proposers, adopted, opts.pastSyntheses, reviewTally(heard));
|
|
405
|
+
const agentSynth = pick && members.find((m) => m.id === pick.id);
|
|
406
|
+
if (pick && agentSynth) {
|
|
407
|
+
const box = opts.synthTimeoutMs ?? 5 * 60_000;
|
|
408
|
+
synths.push({
|
|
409
|
+
id: pick.id,
|
|
410
|
+
kind: "agent",
|
|
411
|
+
why: pick.why,
|
|
412
|
+
run: async (p) => {
|
|
413
|
+
const { res, ms } = await boxed(() => ro.ask(agentSynth, p, repoRoot, { timeoutMs: box, signal: opts.signal, quick: false }), box);
|
|
414
|
+
if (res.late || ms >= box)
|
|
415
|
+
hung.add(agentSynth.id);
|
|
416
|
+
return res;
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
const brain = opts.brain;
|
|
421
|
+
if (brain)
|
|
422
|
+
synths.push({
|
|
423
|
+
id: brain.label,
|
|
424
|
+
kind: "brain",
|
|
425
|
+
why: pick ? `${pick.id} didn't write a usable plan` : "no idea was adopted by a teammate, so the brain writes it",
|
|
426
|
+
run: (p) => brain.think(p, repoRoot, { signal: opts.signal, timeoutMs: synthTimeoutMs(brain), json: true, selfContained: true }),
|
|
427
|
+
});
|
|
428
|
+
if (!synths.length)
|
|
429
|
+
return fallback(critiques, "council: no brain to synthesize");
|
|
430
|
+
let synth = null;
|
|
431
|
+
let synthText = "";
|
|
432
|
+
let tasks = null;
|
|
433
|
+
let tokens;
|
|
434
|
+
const missed = [];
|
|
435
|
+
for (const s of synths) {
|
|
436
|
+
if (opts.signal?.aborted)
|
|
437
|
+
break;
|
|
438
|
+
opts.onEvent?.({ type: "synthesizing", brain: s.id, why: s.why });
|
|
439
|
+
const res = await s.run(prompt);
|
|
440
|
+
tasks = res.ok ? parsePlan(res.text) : null;
|
|
441
|
+
if (tasks) {
|
|
442
|
+
synth = s;
|
|
443
|
+
synthText = res.text;
|
|
444
|
+
tokens = res.tokens;
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
missed.push(s.id);
|
|
448
|
+
}
|
|
449
|
+
if (!synth || !tasks)
|
|
450
|
+
return fallback(critiques, `council: ${missed.join(", then ") || "nobody"} didn't synthesize a plan`);
|
|
451
|
+
let extras = parseExtras(synthText, speaking);
|
|
452
|
+
// Ratify: everyone else on the council checks the contract once, in parallel.
|
|
453
|
+
// The brain's own CLI doesn't ratify the brain's plan (it would grade itself).
|
|
454
|
+
const ratify = [];
|
|
455
|
+
const objections = [];
|
|
456
|
+
const brainCli = brain?.spec.split(":")[0];
|
|
457
|
+
const ratifiers = members.filter((m) => proposers.includes(m.id) && m.id !== synth.id && !hung.has(m.id) && !(synth.kind === "brain" && m.adapter.id === brainCli));
|
|
458
|
+
if (extras.contract && ratifiers.length && !opts.signal?.aborted) {
|
|
459
|
+
opts.onEvent?.({ type: "ratify", by: synth.id, agents: ratifiers.map((m) => m.id) });
|
|
460
|
+
const verdicts = await Promise.all(ratifiers.map(async (m) => {
|
|
461
|
+
const { res, ms } = await boxed(() => ro.ask(m, ratifyPrompt(goal, m, synth.id, tasks, extras.contract), repoRoot, { timeoutMs, signal: opts.signal }), timeoutMs);
|
|
462
|
+
const e = res.ok && res.text.trim()
|
|
463
|
+
? { agent: m.id, ...parseRatify(res.text), ms }
|
|
464
|
+
: { agent: m.id, verdict: "skipped", objections: [], ms, skipped: res.late || ms >= timeoutMs ? "timed out" : "no answer" };
|
|
465
|
+
opts.onEvent?.({ type: "ratified", entry: e });
|
|
466
|
+
return e;
|
|
467
|
+
}));
|
|
468
|
+
ratify.push(...verdicts);
|
|
469
|
+
const raised = verdicts.flatMap((v) => v.objections.map((objection) => ({ by: v.agent, objection })));
|
|
470
|
+
if (raised.length && !opts.signal?.aborted) {
|
|
471
|
+
// One revision by the synthesizer, never a loop.
|
|
472
|
+
const res = await synth.run(revisionPrompt(goal, raised, planJson(synthText) ? JSON.stringify(planJson(synthText)) : synthText));
|
|
473
|
+
const revised = res.ok ? parsePlan(res.text) : null;
|
|
474
|
+
if (revised) {
|
|
475
|
+
const next = parseExtras(res.text, speaking);
|
|
476
|
+
const said = next.objections.filter((x) => x && typeof x === "object");
|
|
477
|
+
const seenBy = {};
|
|
478
|
+
for (const o of raised) {
|
|
479
|
+
// The synthesizer's answer to this objection: one quoting it, else the nth answer to the nth objection by that agent.
|
|
480
|
+
const nth = (seenBy[o.by] = (seenBy[o.by] ?? -1) + 1);
|
|
481
|
+
const mine = said.filter((x) => String(x.by ?? "").toLowerCase() === o.by.toLowerCase());
|
|
482
|
+
const s = mine.find((x) => x.objection && String(x.objection).includes(o.objection.slice(0, 20))) ?? mine[nth];
|
|
483
|
+
// No word on it: count it taken only if the contract actually changed.
|
|
484
|
+
const taken = s ? s.taken === true || s.taken === "true" : next.contract !== extras.contract;
|
|
485
|
+
objections.push({ ...o, taken, ...(s?.why ? { why: clean(String(s.why), 200) } : {}) });
|
|
486
|
+
}
|
|
487
|
+
// A revision that drops the idea credit keeps the first plan's, task by task (same title).
|
|
488
|
+
tasks = revised.map((t) => (t.from?.length ? t : { ...t, ...(tasks.find((x) => x.title === t.title)?.from ? { from: tasks.find((x) => x.title === t.title).from } : {}) }));
|
|
489
|
+
if (res.tokens)
|
|
490
|
+
tokens = (tokens ?? 0) + res.tokens;
|
|
491
|
+
extras = { ...next, contract: next.contract || extras.contract };
|
|
492
|
+
}
|
|
493
|
+
else
|
|
494
|
+
for (const o of raised)
|
|
495
|
+
objections.push({ ...o, taken: false, why: `${synth.id} didn't revise the plan` });
|
|
496
|
+
opts.onEvent?.({ type: "revised", by: synth.id, ok: !!revised, taken: objections.filter((o) => o.taken).length, rejected: objections.filter((o) => !o.taken).length });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const by = speaking.join(", ");
|
|
500
|
+
const attributed = tasks.map((t) => {
|
|
501
|
+
// ONE primary idea author (first in `from`), plus contributors.
|
|
502
|
+
// Ids as the members spell them: a model may write "Codex" for codex.
|
|
503
|
+
const from = [...new Set((t.from ?? []).map((f) => speaking.find((x) => x.toLowerCase() === f.trim().toLowerCase())).filter((f) => !!f))];
|
|
504
|
+
const credit = from.length
|
|
505
|
+
? `idea from ${from[0]}${from.length > 1 ? ` (with ${from.slice(1).join(", ")})` : ""} in Huddle's design council (${by})`
|
|
506
|
+
: `planned by Huddle's design council (${by})`;
|
|
507
|
+
return {
|
|
508
|
+
...t,
|
|
509
|
+
from,
|
|
510
|
+
...(from.length ? { primary: from[0] } : {}),
|
|
511
|
+
spec: `${t.spec}\n\nDesign: ${credit}. Its decisions and risks are in shared memory — huddle_recall "council" before you start.`,
|
|
512
|
+
};
|
|
513
|
+
});
|
|
514
|
+
// The goal's exact requirements ride in every spec, and any string no spec carried gets an owner (goal.ts).
|
|
515
|
+
const carried = carryGoal(goal, attributed, extras.contract);
|
|
516
|
+
return {
|
|
517
|
+
tasks: carried.tasks,
|
|
518
|
+
by: `council (${by}) → ${synth.id}`,
|
|
519
|
+
goalCheck: carried.check,
|
|
520
|
+
...(carried.check.missing.length ? { note: goalNote(carried.check) } : {}),
|
|
521
|
+
ms: Date.now() - t0,
|
|
522
|
+
tokens,
|
|
523
|
+
council: result({
|
|
524
|
+
proposals,
|
|
525
|
+
critiques,
|
|
526
|
+
synthesizedBy: synth.id,
|
|
527
|
+
synthesizer: { id: synth.id, kind: synth.kind, why: synth.why },
|
|
528
|
+
adoptedFrom: adopted,
|
|
529
|
+
contract: extras.contract,
|
|
530
|
+
ratify,
|
|
531
|
+
objections,
|
|
532
|
+
decisions: extras.decisions,
|
|
533
|
+
risks: extras.risks,
|
|
534
|
+
goalCheck: carried.check,
|
|
535
|
+
}),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/** One line for the plan: how many of the goal's exact strings the plan had dropped, and who owns them now. */
|
|
539
|
+
export function goalNote(c) {
|
|
540
|
+
const owners = Object.keys(c.carried);
|
|
541
|
+
return `goal check: ${c.missing.length}/${c.atoms} exact string(s) were in no spec or the contract — carried verbatim to ${owners.length ? owners.map((t) => `"${t}"`).join(", ") : "the specs"}`;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Put the council in the Room once the board exists: every proposal and
|
|
545
|
+
* cross-review as an event (and each ADOPT / FLAW, so `huddle story` shows who
|
|
546
|
+
* took what from whom), the ratify verdicts, the plan with who-proposed-what,
|
|
547
|
+
* the contract, and the decisions and risks in shared memory so agents can
|
|
548
|
+
* huddle_recall them mid-task.
|
|
549
|
+
*/
|
|
550
|
+
export function recordCouncil(db, council, tasks, taskIds) {
|
|
551
|
+
for (const e of [...council.proposals, ...council.critiques]) {
|
|
552
|
+
const type = e.ok ? `council.${e.round}` : "council.skipped";
|
|
553
|
+
db.publish(type, {
|
|
554
|
+
agent_id: e.agent,
|
|
555
|
+
payload: {
|
|
556
|
+
agent: e.agent,
|
|
557
|
+
round: e.round,
|
|
558
|
+
text: e.text,
|
|
559
|
+
ms: e.ms,
|
|
560
|
+
...(e.skipped ? { reason: e.skipped } : {}),
|
|
561
|
+
...(e.adopts?.length ? { adopts: e.adopts.map((a) => ({ of: a.of, text: a.text })) } : {}),
|
|
562
|
+
...(e.flaws?.length ? { flaws: e.flaws.map((a) => ({ of: a.of, text: a.text })) } : {}),
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
for (const a of e.adopts ?? [])
|
|
566
|
+
db.publish("council.adopt", { agent_id: a.by, payload: { agent: a.by, of: a.of, body: a.text } });
|
|
567
|
+
for (const f of e.flaws ?? [])
|
|
568
|
+
db.publish("council.flaw", { agent_id: f.by, payload: { agent: f.by, of: f.of, body: f.text } });
|
|
569
|
+
}
|
|
570
|
+
for (const r of council.ratify ?? []) {
|
|
571
|
+
if (r.verdict === "skipped")
|
|
572
|
+
db.publish("council.skipped", { agent_id: r.agent, payload: { agent: r.agent, round: "ratify", ms: r.ms, reason: r.skipped ?? "no answer" } });
|
|
573
|
+
else
|
|
574
|
+
db.publish("council.ratify", { agent_id: r.agent, payload: { agent: r.agent, verdict: r.verdict, objections: r.objections, ms: r.ms } });
|
|
575
|
+
}
|
|
576
|
+
db.publish("council.plan", {
|
|
577
|
+
agent_id: "council",
|
|
578
|
+
payload: {
|
|
579
|
+
members: council.members,
|
|
580
|
+
by: council.synthesizedBy,
|
|
581
|
+
...(council.synthesizer ? { synthesizer: council.synthesizer } : {}),
|
|
582
|
+
ms: council.ms,
|
|
583
|
+
tasks: tasks.map((t, i) => ({ id: taskIds[i] ?? null, title: t.title, from: t.from ?? [], primary: t.primary ?? t.from?.[0] ?? null })),
|
|
584
|
+
decisions: council.decisions,
|
|
585
|
+
risks: council.risks,
|
|
586
|
+
...(council.adoptedFrom ? { adopted: council.adoptedFrom } : {}),
|
|
587
|
+
...(council.objections?.length ? { objections: council.objections } : {}),
|
|
588
|
+
contract_chars: council.contract?.length ?? 0,
|
|
589
|
+
...(council.goalCheck ? { goal_check: { atoms: council.goalCheck.atoms, missing: council.goalCheck.missing.slice(0, 40), carried: council.goalCheck.carried } } : {}),
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
if (council.contract)
|
|
593
|
+
setContract(db, council.contract, council.synthesizer?.id ?? council.synthesizedBy);
|
|
594
|
+
const who = council.members.join(", ");
|
|
595
|
+
for (const d of council.decisions)
|
|
596
|
+
db.remember({ kind: "decision", body: `council: ${d}`, why: `agreed by the design council (${who}) before work started`, author: "council" });
|
|
597
|
+
for (const r of council.risks)
|
|
598
|
+
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" });
|
|
599
|
+
}
|
|
600
|
+
/** How many plans each agent synthesized in earlier runs in this Room (for rotation ties). */
|
|
601
|
+
export function pastSyntheses(db) {
|
|
602
|
+
const out = {};
|
|
603
|
+
for (const e of db.eventsOfType(["council.plan"], 0, 500)) {
|
|
604
|
+
try {
|
|
605
|
+
const s = JSON.parse(e.payload || "{}").synthesizer;
|
|
606
|
+
if (s?.kind === "agent" && s.id)
|
|
607
|
+
out[s.id] = (out[s.id] ?? 0) + 1;
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
/* an old or odd event */
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
//# sourceMappingURL=council.js.map
|