huddle-cli 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/QUICKSTART.md +135 -0
- package/README.md +26 -4
- package/dist/adapters/amp.js +1 -1
- package/dist/adapters/antigravity.js +2 -0
- package/dist/adapters/claude.js +19 -3
- package/dist/adapters/codex.js +9 -1
- package/dist/adapters/cursor.js +12 -5
- package/dist/adapters/custom.js +4 -2
- package/dist/adapters/droid.js +1 -0
- package/dist/adapters/goose.js +2 -0
- package/dist/adapters/index.js +44 -18
- package/dist/adapters/kiro.js +1 -1
- package/dist/adapters/opencode.js +1 -1
- package/dist/app/live.js +142 -0
- package/dist/app/page.js +701 -0
- package/dist/app/server.js +262 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +7 -4
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +21 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +134 -42
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +57 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +16 -4
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +434 -87
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +31 -13
- package/dist/orchestrator/learn.js +101 -0
- package/dist/orchestrator/planner.js +54 -9
- package/dist/orchestrator/qa.js +71 -15
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +575 -50
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +377 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +79 -16
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +2 -2
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +75 -20
- package/dist/room/mail.js +225 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +220 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/room/user.js +54 -0
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +963 -159
- package/dist/ui/brief.js +494 -0
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +269 -55
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/export.js +1194 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +11 -5
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +716 -94
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +182 -16
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/watch.js +149 -48
- package/package.json +6 -3
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
// The design council: for a big goal, every ready agent brainstorms before
|
|
2
|
-
// anyone writes code — not just the brain.
|
|
2
|
+
// anyone writes code — not just the brain. Short rounds:
|
|
3
3
|
//
|
|
4
|
-
// 1. propose
|
|
5
|
-
//
|
|
6
|
-
// 2.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
10
16
|
//
|
|
11
17
|
// Every call is time-boxed: an agent that is slow or errors is skipped, never
|
|
12
18
|
// waited on. Small goals skip the council and keep the fast single-brain plan.
|
|
19
|
+
import { setContract, capContract } from "../room/contract.js";
|
|
13
20
|
import { askAgent } from "./ask.js";
|
|
14
21
|
import { repoContext } from "./brain.js";
|
|
15
|
-
import {
|
|
16
|
-
|
|
22
|
+
import { carryGoal } from "./goal.js";
|
|
23
|
+
import { goalSize, jsonCandidates, listsOwnTasks, parsePlain, 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 });
|
|
17
25
|
const PROPOSAL_CAP = 1500;
|
|
18
|
-
const CRITIQUE_CAP =
|
|
26
|
+
const CRITIQUE_CAP = 1200;
|
|
19
27
|
/** Default per-call time box; HUDDLE_COUNCIL_TIMEOUT_S overrides. */
|
|
20
28
|
export function councilTimeoutMs() {
|
|
21
29
|
const s = Number(process.env.HUDDLE_COUNCIL_TIMEOUT_S);
|
|
@@ -86,18 +94,18 @@ TASKS: 2-8 lines "- title — files — depends on"
|
|
|
86
94
|
CONTRACTS: the interfaces tasks share (exact function names, file paths, data shapes)
|
|
87
95
|
RISKS: the flaws, unknowns and edge cases most likely to break this`;
|
|
88
96
|
}
|
|
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.
|
|
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.
|
|
91
99
|
|
|
92
100
|
GOAL: ${goal}
|
|
93
101
|
|
|
94
102
|
${others.map((p) => `--- ${p.agent} proposes ---\n${clean(p.text, 1200)}`).join("\n\n")}
|
|
95
|
-
|
|
103
|
+
${mine ? `\n--- your own proposal ---\n${clean(mine.text, 800)}\n` : ""}
|
|
96
104
|
You have no tools in this step. Do not edit anything.
|
|
97
|
-
Reply in plain text, at most
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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`;
|
|
101
109
|
}
|
|
102
110
|
/** First meaningful line of a section ("ARCHITECTURE:", "FLAWS:") for a one-line summary. */
|
|
103
111
|
export function headline(text, section) {
|
|
@@ -114,79 +122,255 @@ export function headline(text, section) {
|
|
|
114
122
|
}
|
|
115
123
|
return pick(lines.find(Boolean) ?? "");
|
|
116
124
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
|
132
148
|
}
|
|
133
|
-
|
|
134
|
-
|
|
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() };
|
|
135
173
|
}
|
|
136
|
-
const
|
|
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);
|
|
137
205
|
const text = res.ok ? clean(res.text, cap) : "";
|
|
138
206
|
const entry = text
|
|
139
|
-
? { agent: m.id, round: name, ok: true, text, ms }
|
|
207
|
+
? { agent: m.id, round: name, ok: true, text, ms, ...(parse ? parse(m, res.text) : {}) }
|
|
140
208
|
: { agent: m.id, round: name, ok: false, text: "", ms, skipped: res.late || ms >= o.timeoutMs ? "timed out" : "no answer" };
|
|
141
209
|
o.onEvent?.({ type: "entry", entry });
|
|
142
210
|
return entry;
|
|
143
211
|
}));
|
|
144
212
|
}
|
|
145
|
-
/**
|
|
146
|
-
function
|
|
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) {
|
|
147
277
|
const parts = [`DESIGN COUNCIL — proposals (each agent planned independently):`];
|
|
148
278
|
for (const p of proposals)
|
|
149
279
|
parts.push(`--- ${p.agent} ---\n${p.text}`);
|
|
150
280
|
if (critiques.length) {
|
|
151
|
-
parts.push(`DESIGN COUNCIL —
|
|
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):`);
|
|
152
282
|
for (const c of critiques)
|
|
153
283
|
parts.push(`--- ${c.agent} ---\n${c.text}`);
|
|
154
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.`);
|
|
155
288
|
return parts.join("\n\n");
|
|
156
289
|
}
|
|
157
|
-
/**
|
|
158
|
-
function
|
|
290
|
+
/** The first JSON body in a reply that holds a tasks array. */
|
|
291
|
+
function planJson(text) {
|
|
159
292
|
for (const body of jsonCandidates(text)) {
|
|
160
|
-
let raw;
|
|
161
293
|
try {
|
|
162
|
-
raw = JSON.parse(body);
|
|
294
|
+
const raw = JSON.parse(body);
|
|
295
|
+
if (Array.isArray(raw?.tasks))
|
|
296
|
+
return raw;
|
|
163
297
|
}
|
|
164
298
|
catch {
|
|
165
|
-
|
|
299
|
+
/* next candidate */
|
|
166
300
|
}
|
|
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
301
|
}
|
|
180
|
-
return
|
|
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, plain, example), plus "objections":[{"by":"agent id","taken":true,"why":"short"}] — one entry per objection above. No prose, no code fences.`;
|
|
181
361
|
}
|
|
182
362
|
/**
|
|
183
|
-
* Brainstorm with every member, then have the
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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.
|
|
186
367
|
*/
|
|
187
368
|
export async function runCouncil(goal, crew, members, repoRoot, opts) {
|
|
188
369
|
const t0 = Date.now();
|
|
189
370
|
const timeoutMs = opts.timeoutMs ?? councilTimeoutMs();
|
|
371
|
+
// Ratifying is "OK" or up to 3 objections: it never needs a design's time box. Found live
|
|
372
|
+
// (0.1.2): the whole team waited 2 minutes on one agent's ratify that then timed out.
|
|
373
|
+
const ratifyMs = Math.min(timeoutMs, (Number(process.env.HUDDLE_RATIFY_TIMEOUT_S) || 75) * 1000);
|
|
190
374
|
const ro = { ask: opts.ask ?? defaultAsk, cwd: repoRoot, timeoutMs, signal: opts.signal, onEvent: opts.onEvent };
|
|
191
375
|
const context = repoContext(repoRoot, 150);
|
|
192
376
|
const ids = members.map((m) => m.id);
|
|
@@ -205,70 +389,233 @@ export async function runCouncil(goal, crew, members, repoRoot, opts) {
|
|
|
205
389
|
};
|
|
206
390
|
if (!good.length || opts.signal?.aborted)
|
|
207
391
|
return fallback([], "council: no proposals came back");
|
|
208
|
-
// A
|
|
392
|
+
// A cross-review needs someone else's proposal to read.
|
|
209
393
|
const critics = good.length >= 2 ? members.filter((m) => good.some((p) => p.agent === m.id)) : [];
|
|
394
|
+
const proposers = good.map((p) => p.agent);
|
|
210
395
|
const critiques = critics.length
|
|
211
|
-
? await round(critics, "critique", (m) => critiquePrompt(goal, m, good.filter((p) => p.agent !== m.id)), CRITIQUE_CAP, ro)
|
|
396
|
+
? 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)))
|
|
212
397
|
: [];
|
|
213
398
|
const heard = critiques.filter((c) => c.ok);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
399
|
+
const adopted = adoptTally(heard);
|
|
400
|
+
if (opts.signal?.aborted)
|
|
401
|
+
return fallback(critiques, "council: stopped");
|
|
217
402
|
const speaking = [...new Set([...good, ...heard].map((e) => e.agent))];
|
|
218
|
-
const prompt = plannerPrompt(goal, crew, context, { notes: councilNotes(good, heard), members: speaking });
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
403
|
+
const prompt = plannerPrompt(goal, crew, context, { notes: councilNotes(good, heard, adopted), members: speaking });
|
|
404
|
+
// The synthesizers to try, in order: the most-adopted agent, then the brain.
|
|
405
|
+
const synths = [];
|
|
406
|
+
const hung = new Set(); // a synthesizer that ran out its box isn't asked to ratify (it would hang again)
|
|
407
|
+
const pick = pickSynthesizer(proposers, adopted, opts.pastSyntheses, reviewTally(heard));
|
|
408
|
+
const agentSynth = pick && members.find((m) => m.id === pick.id);
|
|
409
|
+
if (pick && agentSynth) {
|
|
410
|
+
const box = opts.synthTimeoutMs ?? 5 * 60_000;
|
|
411
|
+
synths.push({
|
|
412
|
+
id: pick.id,
|
|
413
|
+
kind: "agent",
|
|
414
|
+
why: pick.why,
|
|
415
|
+
run: async (p) => {
|
|
416
|
+
const { res, ms } = await boxed(() => ro.ask(agentSynth, p, repoRoot, { timeoutMs: box, signal: opts.signal, quick: false }), box);
|
|
417
|
+
if (res.late || ms >= box)
|
|
418
|
+
hung.add(agentSynth.id);
|
|
419
|
+
return res;
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
const brain = opts.brain;
|
|
424
|
+
if (brain)
|
|
425
|
+
synths.push({
|
|
426
|
+
id: brain.label,
|
|
427
|
+
kind: "brain",
|
|
428
|
+
why: pick ? `${pick.id} didn't write a usable plan` : "no idea was adopted by a teammate, so the brain writes it",
|
|
429
|
+
run: (p) => brain.think(p, repoRoot, { signal: opts.signal, timeoutMs: synthTimeoutMs(brain), json: true, selfContained: true }),
|
|
430
|
+
});
|
|
431
|
+
if (!synths.length)
|
|
432
|
+
return fallback(critiques, "council: no brain to synthesize");
|
|
433
|
+
let synth = null;
|
|
434
|
+
let synthText = "";
|
|
435
|
+
let tasks = null;
|
|
436
|
+
let tokens;
|
|
437
|
+
const missed = [];
|
|
438
|
+
for (const s of synths) {
|
|
439
|
+
if (opts.signal?.aborted)
|
|
440
|
+
break;
|
|
441
|
+
opts.onEvent?.({ type: "synthesizing", brain: s.id, why: s.why });
|
|
442
|
+
const res = await s.run(prompt);
|
|
443
|
+
tasks = res.ok ? parsePlan(res.text) : null;
|
|
444
|
+
if (tasks) {
|
|
445
|
+
synth = s;
|
|
446
|
+
synthText = res.text;
|
|
447
|
+
tokens = res.tokens;
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
missed.push(s.id);
|
|
451
|
+
}
|
|
452
|
+
if (!synth || !tasks)
|
|
453
|
+
return fallback(critiques, `council: ${missed.join(", then ") || "nobody"} didn't synthesize a plan`);
|
|
454
|
+
let extras = parseExtras(synthText, speaking);
|
|
455
|
+
let plain = parsePlain(synthText);
|
|
456
|
+
// Ratify: everyone else on the council checks the contract once, in parallel.
|
|
457
|
+
// The brain's own CLI doesn't ratify the brain's plan (it would grade itself).
|
|
458
|
+
const ratify = [];
|
|
459
|
+
const objections = [];
|
|
460
|
+
const brainCli = brain?.spec.split(":")[0];
|
|
461
|
+
const ratifiers = members.filter((m) => proposers.includes(m.id) && m.id !== synth.id && !hung.has(m.id) && !(synth.kind === "brain" && m.adapter.id === brainCli));
|
|
462
|
+
if (extras.contract && ratifiers.length && !opts.signal?.aborted) {
|
|
463
|
+
opts.onEvent?.({ type: "ratify", by: synth.id, agents: ratifiers.map((m) => m.id) });
|
|
464
|
+
const verdicts = await Promise.all(ratifiers.map(async (m) => {
|
|
465
|
+
const { res, ms } = await boxed(() => ro.ask(m, ratifyPrompt(goal, m, synth.id, tasks, extras.contract), repoRoot, { timeoutMs: ratifyMs, signal: opts.signal }), ratifyMs);
|
|
466
|
+
const e = res.ok && res.text.trim()
|
|
467
|
+
? { agent: m.id, ...parseRatify(res.text), ms }
|
|
468
|
+
: { agent: m.id, verdict: "skipped", objections: [], ms, skipped: res.late || ms >= ratifyMs ? "timed out" : "no answer" };
|
|
469
|
+
opts.onEvent?.({ type: "ratified", entry: e });
|
|
470
|
+
return e;
|
|
471
|
+
}));
|
|
472
|
+
ratify.push(...verdicts);
|
|
473
|
+
const raised = verdicts.flatMap((v) => v.objections.map((objection) => ({ by: v.agent, objection })));
|
|
474
|
+
if (raised.length && !opts.signal?.aborted) {
|
|
475
|
+
// One revision by the synthesizer, never a loop.
|
|
476
|
+
const res = await synth.run(revisionPrompt(goal, raised, planJson(synthText) ? JSON.stringify(planJson(synthText)) : synthText));
|
|
477
|
+
const revised = res.ok ? parsePlan(res.text) : null;
|
|
478
|
+
if (revised) {
|
|
479
|
+
const next = parseExtras(res.text, speaking);
|
|
480
|
+
const said = next.objections.filter((x) => x && typeof x === "object");
|
|
481
|
+
const seenBy = {};
|
|
482
|
+
for (const o of raised) {
|
|
483
|
+
// The synthesizer's answer to this objection: one quoting it, else the nth answer to the nth objection by that agent.
|
|
484
|
+
const nth = (seenBy[o.by] = (seenBy[o.by] ?? -1) + 1);
|
|
485
|
+
const mine = said.filter((x) => String(x.by ?? "").toLowerCase() === o.by.toLowerCase());
|
|
486
|
+
const s = mine.find((x) => x.objection && String(x.objection).includes(o.objection.slice(0, 20))) ?? mine[nth];
|
|
487
|
+
// No word on it: count it taken only if the contract actually changed.
|
|
488
|
+
const taken = s ? s.taken === true || s.taken === "true" : next.contract !== extras.contract;
|
|
489
|
+
objections.push({ ...o, taken, ...(s?.why ? { why: clean(String(s.why), 200) } : {}) });
|
|
490
|
+
}
|
|
491
|
+
// A revision that drops the idea credit keeps the first plan's, task by task (same title).
|
|
492
|
+
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 } : {}) }));
|
|
493
|
+
if (res.tokens)
|
|
494
|
+
tokens = (tokens ?? 0) + res.tokens;
|
|
495
|
+
extras = { ...next, contract: next.contract || extras.contract };
|
|
496
|
+
plain = parsePlain(res.text) ?? plain;
|
|
497
|
+
}
|
|
498
|
+
else
|
|
499
|
+
for (const o of raised)
|
|
500
|
+
objections.push({ ...o, taken: false, why: `${synth.id} didn't revise the plan` });
|
|
501
|
+
opts.onEvent?.({ type: "revised", by: synth.id, ok: !!revised, taken: objections.filter((o) => o.taken).length, rejected: objections.filter((o) => !o.taken).length });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const by = speaking.join(", ");
|
|
230
505
|
const attributed = tasks.map((t) => {
|
|
231
|
-
|
|
232
|
-
|
|
506
|
+
// ONE primary idea author (first in `from`), plus contributors.
|
|
507
|
+
// Ids as the members spell them: a model may write "Codex" for codex.
|
|
508
|
+
const from = [...new Set((t.from ?? []).map((f) => speaking.find((x) => x.toLowerCase() === f.trim().toLowerCase())).filter((f) => !!f))];
|
|
509
|
+
const credit = from.length
|
|
510
|
+
? `idea from ${from[0]}${from.length > 1 ? ` (with ${from.slice(1).join(", ")})` : ""} in Huddle's design council (${by})`
|
|
511
|
+
: `planned by Huddle's design council (${by})`;
|
|
233
512
|
return {
|
|
234
513
|
...t,
|
|
235
514
|
from,
|
|
515
|
+
...(from.length ? { primary: from[0] } : {}),
|
|
236
516
|
spec: `${t.spec}\n\nDesign: ${credit}. Its decisions and risks are in shared memory — huddle_recall "council" before you start.`,
|
|
237
517
|
};
|
|
238
518
|
});
|
|
519
|
+
// The goal's exact requirements ride in every spec, and any string no spec carried gets an owner (goal.ts).
|
|
520
|
+
const carried = carryGoal(goal, attributed, extras.contract);
|
|
239
521
|
return {
|
|
240
|
-
tasks:
|
|
241
|
-
by: `council (${by}) → ${
|
|
522
|
+
tasks: carried.tasks,
|
|
523
|
+
by: `council (${by}) → ${synth.id}`,
|
|
524
|
+
goalCheck: carried.check,
|
|
525
|
+
...(carried.check.missing.length ? { note: goalNote(carried.check) } : {}),
|
|
242
526
|
ms: Date.now() - t0,
|
|
243
|
-
tokens
|
|
244
|
-
|
|
527
|
+
tokens,
|
|
528
|
+
...(plain ? { plain } : {}),
|
|
529
|
+
council: result({
|
|
530
|
+
proposals,
|
|
531
|
+
critiques,
|
|
532
|
+
synthesizedBy: synth.id,
|
|
533
|
+
synthesizer: { id: synth.id, kind: synth.kind, why: synth.why },
|
|
534
|
+
adoptedFrom: adopted,
|
|
535
|
+
contract: extras.contract,
|
|
536
|
+
ratify,
|
|
537
|
+
objections,
|
|
538
|
+
decisions: extras.decisions,
|
|
539
|
+
risks: extras.risks,
|
|
540
|
+
goalCheck: carried.check,
|
|
541
|
+
}),
|
|
245
542
|
};
|
|
246
543
|
}
|
|
544
|
+
/** One line for the plan: how many of the goal's exact strings the plan had dropped, and who owns them now. */
|
|
545
|
+
export function goalNote(c) {
|
|
546
|
+
const owners = Object.keys(c.carried);
|
|
547
|
+
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"}`;
|
|
548
|
+
}
|
|
247
549
|
/**
|
|
248
550
|
* Put the council in the Room once the board exists: every proposal and
|
|
249
|
-
*
|
|
250
|
-
*
|
|
551
|
+
* cross-review as an event (and each ADOPT / FLAW, so `huddle story` shows who
|
|
552
|
+
* took what from whom), the ratify verdicts, the plan with who-proposed-what,
|
|
553
|
+
* the contract, and the decisions and risks in shared memory so agents can
|
|
554
|
+
* huddle_recall them mid-task.
|
|
251
555
|
*/
|
|
252
556
|
export function recordCouncil(db, council, tasks, taskIds) {
|
|
253
557
|
for (const e of [...council.proposals, ...council.critiques]) {
|
|
254
558
|
const type = e.ok ? `council.${e.round}` : "council.skipped";
|
|
255
|
-
db.publish(type, {
|
|
559
|
+
db.publish(type, {
|
|
560
|
+
agent_id: e.agent,
|
|
561
|
+
payload: {
|
|
562
|
+
agent: e.agent,
|
|
563
|
+
round: e.round,
|
|
564
|
+
text: e.text,
|
|
565
|
+
ms: e.ms,
|
|
566
|
+
...(e.skipped ? { reason: e.skipped } : {}),
|
|
567
|
+
...(e.adopts?.length ? { adopts: e.adopts.map((a) => ({ of: a.of, text: a.text })) } : {}),
|
|
568
|
+
...(e.flaws?.length ? { flaws: e.flaws.map((a) => ({ of: a.of, text: a.text })) } : {}),
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
for (const a of e.adopts ?? [])
|
|
572
|
+
db.publish("council.adopt", { agent_id: a.by, payload: { agent: a.by, of: a.of, body: a.text } });
|
|
573
|
+
for (const f of e.flaws ?? [])
|
|
574
|
+
db.publish("council.flaw", { agent_id: f.by, payload: { agent: f.by, of: f.of, body: f.text } });
|
|
575
|
+
}
|
|
576
|
+
for (const r of council.ratify ?? []) {
|
|
577
|
+
if (r.verdict === "skipped")
|
|
578
|
+
db.publish("council.skipped", { agent_id: r.agent, payload: { agent: r.agent, round: "ratify", ms: r.ms, reason: r.skipped ?? "no answer" } });
|
|
579
|
+
else
|
|
580
|
+
db.publish("council.ratify", { agent_id: r.agent, payload: { agent: r.agent, verdict: r.verdict, objections: r.objections, ms: r.ms } });
|
|
256
581
|
}
|
|
257
582
|
db.publish("council.plan", {
|
|
258
583
|
agent_id: "council",
|
|
259
584
|
payload: {
|
|
260
585
|
members: council.members,
|
|
261
586
|
by: council.synthesizedBy,
|
|
587
|
+
...(council.synthesizer ? { synthesizer: council.synthesizer } : {}),
|
|
262
588
|
ms: council.ms,
|
|
263
|
-
tasks: tasks.map((t, i) => ({ id: taskIds[i] ?? null, title: t.title, from: t.from ?? [] })),
|
|
589
|
+
tasks: tasks.map((t, i) => ({ id: taskIds[i] ?? null, title: t.title, from: t.from ?? [], primary: t.primary ?? t.from?.[0] ?? null })),
|
|
264
590
|
decisions: council.decisions,
|
|
265
591
|
risks: council.risks,
|
|
592
|
+
...(council.adoptedFrom ? { adopted: council.adoptedFrom } : {}),
|
|
593
|
+
...(council.objections?.length ? { objections: council.objections } : {}),
|
|
594
|
+
contract_chars: council.contract?.length ?? 0,
|
|
595
|
+
...(council.goalCheck ? { goal_check: { atoms: council.goalCheck.atoms, missing: council.goalCheck.missing.slice(0, 40), carried: council.goalCheck.carried } } : {}),
|
|
266
596
|
},
|
|
267
597
|
});
|
|
598
|
+
if (council.contract)
|
|
599
|
+
setContract(db, council.contract, council.synthesizer?.id ?? council.synthesizedBy);
|
|
268
600
|
const who = council.members.join(", ");
|
|
269
601
|
for (const d of council.decisions)
|
|
270
602
|
db.remember({ kind: "decision", body: `council: ${d}`, why: `agreed by the design council (${who}) before work started`, author: "council" });
|
|
271
603
|
for (const r of council.risks)
|
|
272
604
|
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
605
|
}
|
|
606
|
+
/** How many plans each agent synthesized in earlier runs in this Room (for rotation ties). */
|
|
607
|
+
export function pastSyntheses(db) {
|
|
608
|
+
const out = {};
|
|
609
|
+
for (const e of db.eventsOfType(["council.plan"], 0, 500)) {
|
|
610
|
+
try {
|
|
611
|
+
const s = JSON.parse(e.payload || "{}").synthesizer;
|
|
612
|
+
if (s?.kind === "agent" && s.id)
|
|
613
|
+
out[s.id] = (out[s.id] ?? 0) + 1;
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
/* an old or odd event */
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return out;
|
|
620
|
+
}
|
|
274
621
|
//# sourceMappingURL=council.js.map
|