huddle-cli 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/QUICKSTART.md +135 -0
  3. package/README.md +26 -4
  4. package/dist/adapters/amp.js +1 -1
  5. package/dist/adapters/antigravity.js +2 -0
  6. package/dist/adapters/claude.js +19 -3
  7. package/dist/adapters/codex.js +9 -1
  8. package/dist/adapters/cursor.js +12 -5
  9. package/dist/adapters/custom.js +4 -2
  10. package/dist/adapters/droid.js +1 -0
  11. package/dist/adapters/goose.js +2 -0
  12. package/dist/adapters/index.js +44 -18
  13. package/dist/adapters/kiro.js +1 -1
  14. package/dist/adapters/opencode.js +1 -1
  15. package/dist/app/live.js +142 -0
  16. package/dist/app/page.js +701 -0
  17. package/dist/app/server.js +262 -0
  18. package/dist/args.js +18 -0
  19. package/dist/attach/connect.js +7 -4
  20. package/dist/attach/guard.js +21 -2
  21. package/dist/attach/live.js +5 -1
  22. package/dist/attach/plug.js +21 -3
  23. package/dist/attach/screen.js +150 -0
  24. package/dist/attach/session.js +7 -2
  25. package/dist/cli.js +134 -42
  26. package/dist/mcp/context-tools.js +79 -0
  27. package/dist/mcp/server.js +57 -11
  28. package/dist/orchestrator/ask.js +13 -10
  29. package/dist/orchestrator/brain.js +16 -4
  30. package/dist/orchestrator/budget.js +283 -0
  31. package/dist/orchestrator/busy.js +106 -0
  32. package/dist/orchestrator/council.js +434 -87
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +2 -2
  35. package/dist/orchestrator/launcher.js +31 -13
  36. package/dist/orchestrator/learn.js +101 -0
  37. package/dist/orchestrator/planner.js +54 -9
  38. package/dist/orchestrator/qa.js +71 -15
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +575 -50
  41. package/dist/orchestrator/takeover.js +155 -0
  42. package/dist/platform.js +377 -0
  43. package/dist/room/context.js +1285 -0
  44. package/dist/room/contract.js +132 -0
  45. package/dist/room/db.js +79 -16
  46. package/dist/room/followup.js +233 -0
  47. package/dist/room/identity.js +2 -2
  48. package/dist/room/lessons.js +275 -0
  49. package/dist/room/live.js +75 -20
  50. package/dist/room/mail.js +225 -0
  51. package/dist/room/progress.js +40 -1
  52. package/dist/room/server.js +220 -39
  53. package/dist/room/tunnel.js +6 -5
  54. package/dist/room/user.js +54 -0
  55. package/dist/sandbox/sync.js +4 -4
  56. package/dist/sandbox/worktree.js +145 -14
  57. package/dist/ui/app.js +963 -159
  58. package/dist/ui/brief.js +494 -0
  59. package/dist/ui/catalog.js +62 -24
  60. package/dist/ui/dashboard.js +269 -55
  61. package/dist/ui/doctor.js +15 -11
  62. package/dist/ui/export.js +1194 -0
  63. package/dist/ui/lessons.js +81 -0
  64. package/dist/ui/narrator.js +56 -0
  65. package/dist/ui/paste.js +515 -0
  66. package/dist/ui/remote.js +11 -5
  67. package/dist/ui/setup.js +152 -0
  68. package/dist/ui/story.js +716 -94
  69. package/dist/ui/talk.js +176 -0
  70. package/dist/ui/term.js +182 -16
  71. package/dist/ui/transcript.js +434 -0
  72. package/dist/ui/watch.js +149 -48
  73. package/package.json +6 -3
@@ -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. Three short rounds:
2
+ // anyone writes code — not just the brain. Short rounds:
3
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
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 { 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 });
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 = 900;
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 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`;
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
- 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) };
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
- finally {
134
- clearTimeout(timer);
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 ms = Date.now() - t0;
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
- /** The council's material for the brain, compact. */
146
- function councilNotes(proposals, critiques) {
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 — critiques (each agent read the others' proposals):`);
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
- /** decisions / risks from the synthesis reply (the tasks come from parsePlan). */
158
- function parseExtras(text, members) {
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
- continue;
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 { decisions: [], risks: [] };
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 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.
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 critique needs someone else's proposal to read.
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
- if (!opts.brain || opts.signal?.aborted)
215
- return fallback(critiques, "council: no brain to synthesize");
216
- opts.onEvent?.({ type: "synthesizing", brain: opts.brain.label });
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
- 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(", ") : "";
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
- 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})`;
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: attributed,
241
- by: `council (${by}) → ${opts.brain.label}`,
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: res.tokens,
244
- council: result({ proposals, critiques, synthesizedBy: opts.brain.label, ...extras }),
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
- * 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.
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, { agent_id: e.agent, payload: { agent: e.agent, round: e.round, text: e.text, ms: e.ms, ...(e.skipped ? { reason: e.skipped } : {}) } });
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