huddle-cli 0.1.1 → 0.1.5

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 (45) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/QUICKSTART.md +63 -2
  3. package/README.md +15 -0
  4. package/dist/adapters/codex.js +3 -0
  5. package/dist/app/live.js +142 -0
  6. package/dist/app/page.js +775 -0
  7. package/dist/app/qr.js +47 -0
  8. package/dist/app/server.js +433 -0
  9. package/dist/args.js +18 -0
  10. package/dist/attach/connect.js +3 -3
  11. package/dist/attach/plug.js +8 -1
  12. package/dist/cli.js +46 -4
  13. package/dist/connectors/cli.js +121 -0
  14. package/dist/connectors/github.js +162 -0
  15. package/dist/connectors/hub.js +234 -0
  16. package/dist/connectors/types.js +3 -0
  17. package/dist/mcp/context-tools.js +2 -0
  18. package/dist/mcp/server.js +42 -3
  19. package/dist/orchestrator/brain.js +3 -1
  20. package/dist/orchestrator/budget.js +283 -0
  21. package/dist/orchestrator/busy.js +106 -0
  22. package/dist/orchestrator/council.js +11 -5
  23. package/dist/orchestrator/launcher.js +3 -1
  24. package/dist/orchestrator/learn.js +3 -7
  25. package/dist/orchestrator/planner.js +32 -5
  26. package/dist/orchestrator/qa.js +9 -1
  27. package/dist/orchestrator/scheduler.js +105 -19
  28. package/dist/platform.js +104 -0
  29. package/dist/room/mail.js +19 -6
  30. package/dist/room/server.js +82 -2
  31. package/dist/room/user.js +54 -0
  32. package/dist/ui/app.js +511 -69
  33. package/dist/ui/brief.js +494 -0
  34. package/dist/ui/catalog.js +1 -1
  35. package/dist/ui/dashboard.js +92 -21
  36. package/dist/ui/export.js +1194 -0
  37. package/dist/ui/narrator.js +56 -0
  38. package/dist/ui/story.js +118 -93
  39. package/dist/ui/talk.js +176 -0
  40. package/dist/ui/term.js +31 -0
  41. package/dist/ui/transcript.js +434 -0
  42. package/dist/ui/undo.js +131 -0
  43. package/dist/ui/watch.js +4 -0
  44. package/dist/update.js +84 -0
  45. package/package.json +3 -1
@@ -54,6 +54,8 @@ export function contextTools(room) {
54
54
  * machines (a teammate's agent) are saved here and replaced by a local path.
55
55
  */
56
56
  export function toContent(result) {
57
+ if (typeof result === "string")
58
+ return [{ type: "text", text: result }];
57
59
  if (!result || typeof result !== "object" || Array.isArray(result))
58
60
  return [{ type: "text", text: JSON.stringify(result, null, 2) }];
59
61
  const { image, file, ...rest } = result;
@@ -141,6 +141,24 @@ const TOOLS = [
141
141
  },
142
142
  handler: (a) => room("/ask", "POST", { question: a.question, to: a.to }),
143
143
  },
144
+ {
145
+ name: "huddle_ask_user",
146
+ description: "Ask the USER (the person this work is for) to decide something BIG that the goal doesn't settle: a choice that changes what they get " +
147
+ "(scope, a feature in or out, a user-visible behavior, an irreversible step, real money or data). NOT for names, file layout, code style " +
148
+ "or anything you or a teammate can decide — the user isn't an engineer and sees many agents. Give 2-4 short options in plain words " +
149
+ "(no jargon), the one you recommend FIRST; they may also answer in their own words. Waits a short while; with no answer, go with option 1. " +
150
+ "At most 2 per run.",
151
+ inputSchema: {
152
+ type: "object",
153
+ properties: {
154
+ question: { type: "string", description: "one plain sentence, e.g. 'Should it also convert Rankine?'" },
155
+ options: { type: "array", items: { type: "string" }, description: "2-4 plain-words options, your recommendation first" },
156
+ why: { type: "string", description: "optional: one line on what changes for them either way" },
157
+ },
158
+ required: ["question", "options"],
159
+ },
160
+ handler: (a) => room("/ask_user", "POST", { question: a.question, options: a.options, why: a.why }),
161
+ },
144
162
  {
145
163
  name: "huddle_send",
146
164
  description: "Message a teammate agent directly: a heads-up ('I renamed X to Y'), an interface you're exposing, or an answer to their question (pass reply_to = the question's id). `to` = an agent id from huddle_peers, a task id, \"all\" for everyone working now, or \"huddle\" to answer Huddle itself. They see it the next time they call any huddle tool.",
@@ -239,10 +257,12 @@ export const PLUG_INSTRUCTIONS = `Huddle: you are one of several AI coding agent
239
257
  - Start: huddle_board (the plan and who is doing what), huddle_recall (shared memory: decisions, contracts, the design council's notes: huddle_recall "council"), huddle_peers, huddle_inbox.
240
258
  - Before editing: huddle_announce_plan (what you will touch), huddle_check_overlap (is someone else in that file), huddle_contract (the exact names tasks share).
241
259
  - Names other tasks use: code to the contract verbatim. Never invent one that isn't in it — huddle_amend_contract(section, change, why) first; it tells every teammate.
260
+ - The user: messages whose sender is "the user" come from the person this work is for — they win over the plan and the contract. A big decision the goal doesn't settle (scope, user-visible behavior, anything irreversible): huddle_ask_user with 2-4 plain options, your pick first. Never for names or code style.
242
261
  - While working: huddle_progress (a short note; the reply shows the team's in-flight work), huddle_remember (a decision or contract others need; kind "lesson" is kept for future runs), huddle_send (message one teammate), huddle_ask (ask a teammate or the brain a question), huddle_inbox (read replies).
243
262
  - Sharing work: when huddle_offer_help reaches you, huddle_split hands part of your task to a free agent; huddle_take_backlog picks up the next task; huddle_claim takes a specific one.
244
263
  - QA rounds: huddle_report_bug files one reproducible bug in the merged build; it becomes a fix task for the file's author.
245
264
  - The shared screen (what the user pasted, dropped or uploaded — logs, docs, screenshots): huddle_context lists it, huddle_context_read opens one item in chunks (images/PDFs as a file path), huddle_context_search finds text in it, huddle_context_note pins a comment on an item for everyone.
265
+ - Connectors: tools named github_*, atlassian_*, linear_*… (when listed) reach the team's outside tools — issues, tickets, docs, CI. When the goal names an issue or ticket, read it (and its comments) before you start. Post or change things there only when your task asks for it.
246
266
  - Finish: huddle_done with a short summary. Huddle tests and merges it.`;
247
267
  /** The Plug's verb names, in the order listed to agents. */
248
268
  export const PLUG_VERBS = TOOLS.map((t) => t.name);
@@ -251,13 +271,32 @@ export async function runPlug() {
251
271
  if (process.env.HUDDLE_LIVE)
252
272
  return (await import("../attach/plug.js")).runLivePlug();
253
273
  const server = new Server({ name: "huddle", version: packageInfo().version }, { capabilities: { tools: {} }, instructions: PLUG_INSTRUCTIONS });
274
+ // Connectors (GitHub, Jira, …) the Room runs for this session, after Huddle's own verbs.
275
+ let extra = null;
276
+ const connectorTools = async () => {
277
+ if (extra)
278
+ return extra;
279
+ try {
280
+ const r = await room("/tools", "GET");
281
+ extra = Array.isArray(r?.tools) ? r.tools : [];
282
+ }
283
+ catch {
284
+ extra = [];
285
+ }
286
+ return extra;
287
+ };
254
288
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
255
- tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
289
+ tools: [...TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })), ...(await connectorTools())],
256
290
  }));
257
291
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
258
292
  const tool = TOOLS.find((t) => t.name === req.params.name);
259
- if (!tool)
260
- throw new Error(`unknown tool: ${req.params.name}`);
293
+ if (!tool) {
294
+ if (!(await connectorTools()).some((t) => t.name === req.params.name))
295
+ throw new Error(`unknown tool: ${req.params.name}`);
296
+ const r = await room("/tool", "POST", { name: req.params.name, arguments: req.params.arguments ?? {} });
297
+ const result = r?.result ?? r;
298
+ return { content: toContent(result), isError: !!(result && typeof result === "object" && "error" in result) };
299
+ }
261
300
  const result = await tool.handler((req.params.arguments ?? {}));
262
301
  return { content: toContent(result) };
263
302
  });
@@ -123,7 +123,9 @@ export function repoContext(cwd, maxFiles = 250) {
123
123
  return null;
124
124
  }
125
125
  };
126
- const parts = [`FILES (${files.length}${files.length > maxFiles ? `, first ${maxFiles}` : ""}):\n${files.slice(0, maxFiles).join("\n") || "(empty repo)"}`];
126
+ // Found live (0.1.2): agents set "engines": {"node": ">=22"} on a machine with Node 20.
127
+ const machine = `MACHINE: ${process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux"}, Node ${process.version}. Target what runs here: never require a newer Node (engines, syntax, built-in APIs) than this.`;
128
+ const parts = [machine, `FILES (${files.length}${files.length > maxFiles ? `, first ${maxFiles}` : ""}):\n${files.slice(0, maxFiles).join("\n") || "(empty repo)"}`];
127
129
  for (const f of ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"]) {
128
130
  const h = head(f, 40);
129
131
  if (h)
@@ -0,0 +1,283 @@
1
+ // The cost guard. A run costs real money on someone's subscriptions: the
2
+ // round-6 capstone runs took ~4–6M tokens, an improve run once hit 13M. So:
3
+ //
4
+ // - Before a run, an estimate printed with the plan: "≈ 4–6M tokens, ~15–20
5
+ // min with 3 agents", from the plan's size, the agent count and what past
6
+ // runs in this repo's Room cost (built-in defaults from the capstone data
7
+ // until there is history). It is an estimate, and says so.
8
+ // - --budget=5M (or prefs "budget"): at 90% Huddle starts no new work — no
9
+ // new tasks, takeovers, QA rounds, buddy checks or pairings — and lets the
10
+ // running tasks finish; at 120% it stops the running agents, as Ctrl-C does.
11
+ // Either way the RESULT says what was left undone.
12
+ //
13
+ // What counts: every token the agents' meters report (usage.ts), cached
14
+ // context INCLUDED — the same number the live footer, the estimate and the
15
+ // RESULT's COST line show, so "--budget=5M" means the 5M you see there. Cached
16
+ // reads are billed at a fraction (~10%), so this is conservative: a
17
+ // cache-heavy run reaches its budget before its bill does. The COST line shows
18
+ // the cached share next to it. Read-only asks (reviews, buddy checks, council
19
+ // turns) report no usage, so the budget can't see them; the estimate counts
20
+ // council turns from its defaults.
21
+ import { fmtDuration, fmtTokens } from "../adapters/usage.js";
22
+ /** Stop new work at this share of the budget… */
23
+ export const SOFT_AT = 0.9;
24
+ /** …and stop the running agents at this one. */
25
+ export const HARD_AT = 1.2;
26
+ /** "5M", "800k", "1.5m", "250000", "2,000,000" → tokens; null if it isn't one. */
27
+ export function parseTokens(v) {
28
+ if (typeof v === "number")
29
+ return Number.isFinite(v) && v > 0 ? Math.round(v) : null;
30
+ const m = /^\s*(\d+(?:\.\d+)?)\s*([kmb])?\s*(?:tok(?:ens)?)?\s*$/i.exec(String(v ?? "").replace(/[,_]/g, ""));
31
+ if (!m)
32
+ return null;
33
+ const n = Number(m[1]) * ({ k: 1e3, m: 1e6, b: 1e9 }[(m[2] ?? "").toLowerCase()] ?? 1);
34
+ return n > 0 ? Math.round(n) : null;
35
+ }
36
+ /** `--budget=5M` (or `--budget 5M`) anywhere in a typed goal: the goal without it, and the value. */
37
+ export function budgetFlag(goal) {
38
+ const m = /(^|\s)--budget(?:=|\s+)(\S+)(?=\s|$)/.exec(goal);
39
+ if (!m)
40
+ return { goal };
41
+ return { goal: goal.replace(m[0], " ").replace(/\s+/g, " ").trim(), budget: m[2] };
42
+ }
43
+ export class Budget {
44
+ limit;
45
+ meters = new Map();
46
+ extra = { tokens: 0, cached: 0 };
47
+ listeners = [];
48
+ /** Only goes up: once new work stopped, it stays stopped for the run. */
49
+ level = "ok";
50
+ constructor(limit) {
51
+ this.limit = limit;
52
+ }
53
+ /** Count this agent launch's meter (read live, every check). `who`: the agent, for its pace. */
54
+ track(m, who = "", now = Date.now()) {
55
+ if (!this.meters.has(m))
56
+ this.meters.set(m, { who: who.split("#")[0], start: now, lastAt: now, last: 0 });
57
+ }
58
+ /** Tokens measured outside a meter (the planner's own). */
59
+ add(tokens, cached = 0) {
60
+ this.extra.tokens += Math.max(0, tokens || 0);
61
+ this.extra.cached += Math.max(0, cached || 0);
62
+ }
63
+ get tokens() {
64
+ let n = this.extra.tokens;
65
+ for (const m of this.meters.keys())
66
+ n += m.tokens;
67
+ return n;
68
+ }
69
+ get cached() {
70
+ let n = this.extra.cached;
71
+ for (const m of this.meters.keys())
72
+ n += m.cached;
73
+ return n;
74
+ }
75
+ /**
76
+ * Tokens used plus an estimate of what's in flight but not reported yet.
77
+ * Found live (0.1.2): Codex reports usage only when its whole turn ends, so
78
+ * a 1m45s task counted 0 and then 406k at once, and the 90% line fired at
79
+ * 108%. A launch that hasn't reported is estimated at its agent's own pace
80
+ * this run (tokens per second of its earlier launches), capped at twice
81
+ * that agent's average launch. Only the soft line reads this: stopping
82
+ * running agents (the hard line) waits for real numbers.
83
+ */
84
+ projected(now = Date.now()) {
85
+ this.note(now);
86
+ const pace = new Map();
87
+ for (const [m, t] of this.meters) {
88
+ if (!m.seen || m.tokens <= 0)
89
+ continue;
90
+ const p = pace.get(t.who) ?? { tokens: 0, ms: 0, n: 0 };
91
+ p.tokens += m.tokens;
92
+ p.ms += Math.max(1000, t.lastAt - t.start);
93
+ p.n++;
94
+ pace.set(t.who, p);
95
+ }
96
+ let guess = 0;
97
+ for (const [m, t] of this.meters) {
98
+ if (m.seen)
99
+ continue;
100
+ const p = pace.get(t.who);
101
+ if (!p)
102
+ continue;
103
+ guess += Math.min((p.tokens / p.ms) * (now - t.start), (2 * p.tokens) / p.n);
104
+ }
105
+ return this.tokens + Math.round(guess);
106
+ }
107
+ /** Each meter's count went up: when (its pace). */
108
+ note(now) {
109
+ for (const [m, t] of this.meters)
110
+ if (m.tokens > t.last) {
111
+ t.last = m.tokens;
112
+ t.lastAt = now;
113
+ }
114
+ }
115
+ /** Share of the budget used (0 with no budget). */
116
+ get used() {
117
+ return this.limit ? this.tokens / this.limit : 0;
118
+ }
119
+ /** Past the soft line: start nothing new. */
120
+ get stopping() {
121
+ return this.check() !== "ok";
122
+ }
123
+ /** Re-read the meters; tells the listeners when the level goes up. */
124
+ check() {
125
+ if (!this.limit)
126
+ return this.level;
127
+ const u = this.used;
128
+ const next = u >= HARD_AT ? "hard" : u >= SOFT_AT || this.projected() / this.limit >= SOFT_AT ? "soft" : "ok";
129
+ const rank = { ok: 0, soft: 1, hard: 2 };
130
+ if (rank[next] <= rank[this.level])
131
+ return this.level;
132
+ // Straight to hard: the soft listeners still hear "soft" first (stop new work, then stop agents).
133
+ if (this.level === "ok" && next === "hard") {
134
+ this.level = "soft";
135
+ for (const f of this.listeners)
136
+ f("soft", this);
137
+ }
138
+ this.level = next;
139
+ for (const f of this.listeners)
140
+ f(next, this);
141
+ return this.level;
142
+ }
143
+ onLevel(f) {
144
+ this.listeners.push(f);
145
+ }
146
+ /** Check every `ms` until the returned stop() (agents' meters move between scheduler ticks). */
147
+ watch(ms = 1000) {
148
+ if (!this.limit)
149
+ return () => { };
150
+ const t = setInterval(() => this.check(), ms);
151
+ t.unref?.();
152
+ return () => clearInterval(t);
153
+ }
154
+ }
155
+ /** "5.1M of 6M budget (85%)" — or "" with no budget. */
156
+ export function budgetUse(b) {
157
+ if (!b.limit)
158
+ return "";
159
+ return `${fmtTokens(b.tokens)} of ${fmtTokens(b.limit)} budget (${Math.round(b.used * 100)}%)`;
160
+ }
161
+ /** From the capstone runs (round 6): ~350k tokens a task, ~550k a QA round, ~60k a council turn. */
162
+ export const DEFAULT_COSTS = { perTask: 350_000, perQaRound: 550_000, perCouncilTurn: 60_000, taskMs: 4 * 60_000, qaRoundMs: 4 * 60_000 };
163
+ const avg = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
164
+ /**
165
+ * What past runs in this Room cost: tokens and agent time per task (merged or
166
+ * failed, a takeover's first rounds included) and per QA check. Each number
167
+ * falls back to its default until there are enough samples (3 tasks, 2 checks).
168
+ */
169
+ export function runHistory(db) {
170
+ const costs = { ...DEFAULT_COSTS };
171
+ if (!db)
172
+ return { costs, tasks: 0, checks: 0 };
173
+ let evs = [];
174
+ try {
175
+ evs = db.eventsOfType(["run.merged", "run.failed", "run.takeover", "qa.done"], 0, 1000);
176
+ }
177
+ catch {
178
+ return { costs, tasks: 0, checks: 0 };
179
+ }
180
+ const p = (e) => {
181
+ try {
182
+ return JSON.parse(e.payload || "{}");
183
+ }
184
+ catch {
185
+ return {};
186
+ }
187
+ };
188
+ const tasks = [];
189
+ let handedOver = 0; // a rescued task's first agent's rounds: no merged/failed event carries them
190
+ const checks = [];
191
+ for (const e of evs) {
192
+ const x = p(e);
193
+ if (e.type === "run.takeover")
194
+ handedOver += x.kind === "rescue" ? Number(x.tokens) || 0 : 0;
195
+ else if (e.type === "qa.done") {
196
+ if (x.ok && Number(x.tokens) > 0)
197
+ checks.push({ tokens: Number(x.tokens), ms: Number(x.ms) || 0, bugs: Number(x.bugs) || 0 });
198
+ }
199
+ else if (Number(x.tokens) > 0)
200
+ tasks.push({ tokens: Number(x.tokens), ms: Number(x.ms) || 0 });
201
+ }
202
+ if (tasks.length >= 3) {
203
+ costs.perTask = Math.round((tasks.reduce((n, t) => n + t.tokens, 0) + handedOver) / tasks.length);
204
+ const ms = tasks.map((t) => t.ms).filter((m) => m > 0);
205
+ if (ms.length)
206
+ costs.taskMs = Math.round(avg(ms));
207
+ }
208
+ if (checks.length >= 2) {
209
+ const bugs = avg(checks.map((c) => c.bugs));
210
+ costs.perQaRound = Math.round(avg(checks.map((c) => c.tokens)) + bugs * costs.perTask);
211
+ costs.qaRoundMs = Math.round(avg(checks.map((c) => c.ms)) + (bugs > 0 ? costs.taskMs : 0));
212
+ }
213
+ return { costs, tasks: tasks.length >= 3 ? tasks.length : 0, checks: checks.length >= 2 ? checks.length : 0 };
214
+ }
215
+ /**
216
+ * A range, not a promise: tasks × per-task cost (±15%), QA as 1 round (low)
217
+ * to all rounds (high), plus the council's turns. Time: the tasks in waves of
218
+ * `agents` at a time, then QA.
219
+ */
220
+ export function estimateRun(o) {
221
+ const h = o.history ?? { costs: DEFAULT_COSTS, tasks: 0, checks: 0 };
222
+ const c = h.costs;
223
+ const agents = Math.max(1, o.agents);
224
+ const council = (o.councilTurns ?? 0) * c.perCouncilTurn;
225
+ const qaLow = o.qaRounds ? 1 : 0;
226
+ const low = o.tasks * c.perTask * 0.85 + qaLow * c.perQaRound + council;
227
+ const high = o.tasks * c.perTask * 1.15 + o.qaRounds * c.perQaRound + council;
228
+ const waves = Math.ceil(o.tasks / agents);
229
+ const lowMs = waves * c.taskMs * 0.85 + qaLow * c.qaRoundMs;
230
+ const highMs = waves * c.taskMs * 1.15 + o.qaRounds * c.qaRoundMs;
231
+ const from = [h.tasks ? `${h.tasks} past task(s)` : "", h.checks ? `${h.checks} past QA check(s)` : ""].filter(Boolean);
232
+ const basis = from.length ? `from this repo's ${from.join(" and ")}` : "from Huddle's defaults (no history in this repo yet)";
233
+ return { low, high, lowMs, highMs, agents, basis };
234
+ }
235
+ /** Round a token range outward to steps that read well: 4–6M, 1.5–2M, 350–500k. */
236
+ export function fmtTokenRange(low, high) {
237
+ const step = high >= 3e6 ? 1e6 : high >= 1e6 ? 5e5 : high >= 1e5 ? 5e4 : 1e4;
238
+ const lo = Math.max(step, Math.floor(low / step) * step);
239
+ const hi = Math.max(lo + step, Math.ceil(high / step) * step);
240
+ const unit = (n) => (n >= 1e6 ? `${+(n / 1e6).toFixed(1)}` : `${Math.round(n / 1e3)}`);
241
+ const suffix = (n) => (n >= 1e6 ? "M" : "k");
242
+ return suffix(lo) === suffix(hi) ? `${unit(lo)}–${unit(hi)}${suffix(hi)}` : `${fmtTokens(lo)}–${fmtTokens(hi)}`;
243
+ }
244
+ /** "~15–20 min", "~1h05m–1h20m" */
245
+ export function fmtMsRange(low, high) {
246
+ const lo = Math.max(1, Math.round(low / 60_000));
247
+ const hi = Math.max(lo + 1, Math.round(high / 60_000));
248
+ return hi < 60 ? `~${lo}–${hi} min` : `~${fmtDuration(lo * 60_000)}–${fmtDuration(hi * 60_000)}`;
249
+ }
250
+ /** "≈ 4–6M tokens, ~15–20 min with 3 agents" */
251
+ export function estimateText(e) {
252
+ return `≈ ${fmtTokenRange(e.low, e.high)} tokens, ${fmtMsRange(e.lowMs, e.highMs)} with ${e.agents} agent${e.agents === 1 ? "" : "s"}`;
253
+ }
254
+ /**
255
+ * The line printed with the plan: "≈ 4–6M tokens, ~15–20 min with 3 agents ·
256
+ * an estimate, from …", plus what the budget will do.
257
+ */
258
+ export function costPreview(o) {
259
+ const turns = o.council ? o.council.proposals.length + o.council.critiques.length + (o.council.ratify?.length ?? 0) : 0;
260
+ const e = estimateRun({ tasks: o.tasks, agents: o.agents, qaRounds: o.qaRounds, councilTurns: turns, history: o.history });
261
+ const lim = o.budget?.limit;
262
+ const over = lim && e.low > lim ? " — the low end is past it: expect a partial run" : "";
263
+ const b = lim ? ` · budget ${fmtTokens(lim)}: new work stops at ${fmtTokens(lim * SOFT_AT)}, agents at ${fmtTokens(lim * HARD_AT)}${over}` : "";
264
+ return `${estimateText(e)} · an estimate, ${e.basis}${b}`;
265
+ }
266
+ /** The RESULT's budget lines: why the run stopped short and what it left undone. [] when it didn't. */
267
+ export function budgetNotes(b, o) {
268
+ if (!b.limit || b.level === "ok")
269
+ return [];
270
+ const out = [
271
+ b.level === "hard"
272
+ ? `budget: ${fmtTokens(b.tokens)} passed ${Math.round(HARD_AT * 100)}% of ${fmtTokens(b.limit)} — running agents were stopped`
273
+ : `budget: ${Math.round(SOFT_AT * 100)}% of ${fmtTokens(b.limit)} reached — no new work started after it; running tasks finished`,
274
+ ];
275
+ if (o.left.length)
276
+ out.push(`left undone (${o.left.length}): ${o.left.join(" · ")}`);
277
+ if (o.qaCut)
278
+ out.push("left undone: QA (no round started past the budget line)");
279
+ if (o.left.length || o.qaCut)
280
+ out.push("what merged stays on main; a bigger --budget (or a follow-up goal) finishes the rest");
281
+ return out;
282
+ }
283
+ //# sourceMappingURL=budget.js.map
@@ -0,0 +1,106 @@
1
+ // Busy, or stuck? An agent that prints nothing and calls no huddle_* tool for
2
+ // the silence window used to be taken over. Found live (evolve run,
3
+ // 2026-10-16): claude was taken over by codex while it was running Huddle's
4
+ // own 8-minute test suite — quiet, but working. So before an agent counts as
5
+ // silent, Huddle looks at what it is doing:
6
+ //
7
+ // - a child or grandchild process used CPU since the last look (the test
8
+ // suite, a build, an install), or
9
+ // - files in its worktree changed since the last look, or
10
+ // - its log grew.
11
+ //
12
+ // Any of those = busy: the silence clock restarts from that look, so a long
13
+ // command gets its full window again once it ends. A hard cap (default 3× the
14
+ // window, HUDDLE_SILENCE_MAX_MIN) since its last real output still takes it
15
+ // over, so a runaway loop can't hold a task forever.
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { descendants } from "../platform.js";
19
+ /** Longest an agent may go without output or a huddle_* call, busy or not. HUDDLE_SILENCE_MAX_MIN overrides; default 3× the silence window. */
20
+ export function silenceMaxMsFromEnv(silenceMs) {
21
+ const v = Number(process.env.HUDDLE_SILENCE_MAX_MIN);
22
+ return Math.max(silenceMs, v > 0 ? v * 60_000 : 3 * silenceMs);
23
+ }
24
+ /** CPU a process tree must use between two looks to count as working: 2% of the wall time, at least 50ms. */
25
+ export const busyCpuMs = (elapsedMs) => Math.max(50, elapsedMs * 0.02);
26
+ /** Folders never walked for changed files: git's own, and installs too big to walk every tick. */
27
+ const SKIP_DIRS = new Set([".git", "node_modules"]);
28
+ const MAX_WALK = 5000;
29
+ /** A file under `dir` modified after `since`, or null. Bounded: at most MAX_WALK entries. */
30
+ export function changedSince(dir, since) {
31
+ const stack = [dir];
32
+ let n = 0;
33
+ while (stack.length && n < MAX_WALK) {
34
+ const d = stack.pop();
35
+ let entries;
36
+ try {
37
+ entries = fs.readdirSync(d, { withFileTypes: true });
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ for (const e of entries) {
43
+ if (++n > MAX_WALK)
44
+ break;
45
+ const p = path.join(d, e.name);
46
+ if (e.isDirectory()) {
47
+ if (!SKIP_DIRS.has(e.name))
48
+ stack.push(p);
49
+ continue;
50
+ }
51
+ try {
52
+ if (fs.statSync(p).mtimeMs > since)
53
+ return path.relative(dir, p);
54
+ }
55
+ catch {
56
+ /* gone while we looked */
57
+ }
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ const sizeOf = (f) => {
63
+ try {
64
+ return fs.statSync(f).size;
65
+ }
66
+ catch {
67
+ return 0;
68
+ }
69
+ };
70
+ /** Remembers each agent's last look and compares the next one with it. */
71
+ export class BusyWatch {
72
+ looks = new Map();
73
+ /** Compare `t` (agent `id`) with its last look. `table` = one process snapshot (null: the OS wouldn't say). */
74
+ look(id, t, table, now = Date.now()) {
75
+ const kids = table ? descendants(table, t.pid) : [];
76
+ const cpu = new Map(kids.map((p) => [p.pid, p.cpuMs]));
77
+ const before = this.looks.get(id);
78
+ const log = sizeOf(t.logPath);
79
+ this.looks.set(id, { at: now, pid: t.pid, cpu, log });
80
+ if (!before || before.pid !== t.pid)
81
+ return null;
82
+ // A process born since the last look did all its work since then.
83
+ let used = 0;
84
+ let top = null;
85
+ for (const p of kids) {
86
+ const d = p.cpuMs - (before.cpu.get(p.pid) ?? 0);
87
+ if (d <= 0)
88
+ continue;
89
+ used += d;
90
+ if (!top || d > top.ms)
91
+ top = { name: p.name, ms: d };
92
+ }
93
+ if (used >= busyCpuMs(now - before.at))
94
+ return { busy: true, why: `${top?.name || "a child process"} is using CPU (${(used / 1000).toFixed(1)}s since the last look)` };
95
+ if (log > before.log)
96
+ return { busy: true, why: "its log is growing" };
97
+ const f = changedSince(t.worktree, before.at);
98
+ if (f)
99
+ return { busy: true, why: `files are changing (${f})` };
100
+ return { busy: false, why: kids.length ? `${kids.length} child process(es), none using CPU; no files changing` : "no child processes; no files changing" };
101
+ }
102
+ forget(id) {
103
+ this.looks.delete(id);
104
+ }
105
+ }
106
+ //# sourceMappingURL=busy.js.map
@@ -20,7 +20,7 @@ import { setContract, capContract } from "../room/contract.js";
20
20
  import { askAgent } from "./ask.js";
21
21
  import { repoContext } from "./brain.js";
22
22
  import { carryGoal } from "./goal.js";
23
- import { goalSize, jsonCandidates, listsOwnTasks, parsePlan, planGoal, plannerPrompt } from "./planner.js";
23
+ import { goalSize, jsonCandidates, listsOwnTasks, parsePlain, parsePlan, planGoal, plannerPrompt } from "./planner.js";
24
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
25
  const PROPOSAL_CAP = 1500;
26
26
  const CRITIQUE_CAP = 1200;
@@ -357,7 +357,7 @@ YOUR PLAN:
357
357
  ${planText}
358
358
 
359
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.`;
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.`;
361
361
  }
362
362
  /**
363
363
  * Brainstorm with every member, cross-review, then have the most-adopted
@@ -368,8 +368,11 @@ Reply with ONLY the full revised JSON in the same shape (tasks, decisions, risks
368
368
  export async function runCouncil(goal, crew, members, repoRoot, opts) {
369
369
  const t0 = Date.now();
370
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);
371
374
  const ro = { ask: opts.ask ?? defaultAsk, cwd: repoRoot, timeoutMs, signal: opts.signal, onEvent: opts.onEvent };
372
- const context = repoContext(repoRoot, 150);
375
+ const context = repoContext(repoRoot, 150) + (opts.outside ? `\n\n${opts.outside}` : "");
373
376
  const ids = members.map((m) => m.id);
374
377
  const result = (extra) => ({
375
378
  members: ids,
@@ -449,6 +452,7 @@ export async function runCouncil(goal, crew, members, repoRoot, opts) {
449
452
  if (!synth || !tasks)
450
453
  return fallback(critiques, `council: ${missed.join(", then ") || "nobody"} didn't synthesize a plan`);
451
454
  let extras = parseExtras(synthText, speaking);
455
+ let plain = parsePlain(synthText);
452
456
  // Ratify: everyone else on the council checks the contract once, in parallel.
453
457
  // The brain's own CLI doesn't ratify the brain's plan (it would grade itself).
454
458
  const ratify = [];
@@ -458,10 +462,10 @@ export async function runCouncil(goal, crew, members, repoRoot, opts) {
458
462
  if (extras.contract && ratifiers.length && !opts.signal?.aborted) {
459
463
  opts.onEvent?.({ type: "ratify", by: synth.id, agents: ratifiers.map((m) => m.id) });
460
464
  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);
465
+ const { res, ms } = await boxed(() => ro.ask(m, ratifyPrompt(goal, m, synth.id, tasks, extras.contract), repoRoot, { timeoutMs: ratifyMs, signal: opts.signal }), ratifyMs);
462
466
  const e = res.ok && res.text.trim()
463
467
  ? { agent: m.id, ...parseRatify(res.text), ms }
464
- : { agent: m.id, verdict: "skipped", objections: [], ms, skipped: res.late || ms >= timeoutMs ? "timed out" : "no answer" };
468
+ : { agent: m.id, verdict: "skipped", objections: [], ms, skipped: res.late || ms >= ratifyMs ? "timed out" : "no answer" };
465
469
  opts.onEvent?.({ type: "ratified", entry: e });
466
470
  return e;
467
471
  }));
@@ -489,6 +493,7 @@ export async function runCouncil(goal, crew, members, repoRoot, opts) {
489
493
  if (res.tokens)
490
494
  tokens = (tokens ?? 0) + res.tokens;
491
495
  extras = { ...next, contract: next.contract || extras.contract };
496
+ plain = parsePlain(res.text) ?? plain;
492
497
  }
493
498
  else
494
499
  for (const o of raised)
@@ -520,6 +525,7 @@ export async function runCouncil(goal, crew, members, repoRoot, opts) {
520
525
  ...(carried.check.missing.length ? { note: goalNote(carried.check) } : {}),
521
526
  ms: Date.now() - t0,
522
527
  tokens,
528
+ ...(plain ? { plain } : {}),
523
529
  council: result({
524
530
  proposals,
525
531
  critiques,
@@ -105,7 +105,9 @@ export function launchAgent(opts) {
105
105
  return handle;
106
106
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
107
107
  const log = fs.createWriteStream(logPath, { flags: "a" });
108
- handle.child = spawnTool(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env, ...plug.env } }, { prompt, stdinPrompt: adapter.stdinPrompt });
108
+ handle.child = spawnTool(cmd,
109
+ // MCP_TOOL_TIMEOUT: Claude's wait on one tool call; huddle_ask_user may wait 90s for the person.
110
+ { cwd, stdio: ["ignore", "pipe", "pipe"], env: { MCP_TOOL_TIMEOUT: "150000", ...process.env, ...env, ...plug.env } }, { prompt, stdinPrompt: adapter.stdinPrompt });
109
111
  // Everything still goes to the log; stdout is also scanned for token usage.
110
112
  let partial = "";
111
113
  handle.child.stdout.on("data", (chunk) => {
@@ -64,13 +64,9 @@ export function deriveLessons(r, o) {
64
64
  evidence: `qa.bug by ${e.p.by ?? e.agent ?? "?"}: ${one(e.p.repro, 120)}`,
65
65
  });
66
66
  }
67
- // A reviewer sent work back.
68
- for (const e of r.events.filter((e) => e.type === "reviewed" && !e.approve && !e.skipped).slice(0, MAX_PER_KIND))
69
- add({
70
- scope: repo,
71
- text: `A review sent "${one(e.title, 60)}" back: ${one(e.notes, 160)}`,
72
- evidence: `${e.agent} reviewed ${e.author}'s work`,
73
- });
67
+ // A reviewer sent work back: not a lesson. Its notes are about that one task (found live,
68
+ // 0.1.2: "src/main.js: io.stdout.write() is outside the try/catch…" stored as a rule).
69
+ // A general rule from it is the agent's to write: huddle_remember(kind="lesson").
74
70
  // A reviewer that couldn't answer in time.
75
71
  const slow = new Set(r.events.filter((e) => e.type === "reviewed" && e.skipped && /time/i.test(String(e.notes ?? ""))).map((e) => agentBase(String(e.agent))));
76
72
  for (const who of slow)
@@ -5,6 +5,29 @@
5
5
  import { brainFrom, repoContext } from "./brain.js";
6
6
  import { linkPlan, parseContextIds } from "../room/context.js";
7
7
  import { carryGoal } from "./goal.js";
8
+ /** The JSON fields the planner asks for, in both plan shapes. */
9
+ const PLAIN_FIELDS = `"plain":"one or two short sentences a non-programmer understands: what the finished thing is and what it lets them do (no jargon: no exit codes, modules, ESM)","example":"one concrete example of using it and what you'd see, e.g. todo add milk → added: milk (or \"\" if there's nothing to show)"`;
10
+ /** The plain summary from a plan reply, if the planner wrote one. */
11
+ export function parsePlain(text) {
12
+ for (const body of jsonCandidates(text)) {
13
+ let raw;
14
+ try {
15
+ raw = JSON.parse(body);
16
+ }
17
+ catch {
18
+ continue;
19
+ }
20
+ if (!Array.isArray(raw?.tasks))
21
+ continue;
22
+ const tidy = (x, n) => (typeof x === "string" ? x.replace(/\s+/g, " ").trim().slice(0, n) : "");
23
+ const what = tidy(raw.plain ?? raw.summary, 400);
24
+ if (!what)
25
+ return undefined;
26
+ const example = tidy(raw.example, 200);
27
+ return example ? { what, example } : { what };
28
+ }
29
+ return undefined;
30
+ }
8
31
  /**
9
32
  * Fallback when no brain can plan: the whole goal as ONE task. (It used to be
10
33
  * a scaffold → UI → logic → tests → review spine; seen live, a planner hiccup
@@ -67,9 +90,9 @@ ${council ? `- contract: the exact names tasks share — files/modules and the t
67
90
  You have no tools in this step and cannot read files: the snapshot above is everything. Do not try to call a tool — plan from what you see.
68
91
  Reply with ONLY this JSON, no prose, no code fences:
69
92
  ${council
70
- ? `{"tasks":[{"title":"short imperative title","spec":"...","files":["path"],"prefers":["tag"],"depends_on":[0],"from":"the ONE agent id whose idea this task mainly implements","with":["other agent ids whose ideas it also uses"]}],"decisions":["a key design decision every agent must follow (max 6)"],"risks":[{"risk":"a risk or flaw to watch while building (max 6)","by":"agent id that flagged it"}],"contract":"markdown"}
93
+ ? `{"tasks":[{"title":"short imperative title","spec":"...","files":["path"],"prefers":["tag"],"depends_on":[0],"from":"the ONE agent id whose idea this task mainly implements","with":["other agent ids whose ideas it also uses"]}],"decisions":["a key design decision every agent must follow (max 6)"],"risks":[{"risk":"a risk or flaw to watch while building (max 6)","by":"agent id that flagged it"}],"contract":"markdown",${PLAIN_FIELDS}}
71
94
  "from" and "with" use only these ids: ${council.members.join(", ")}.`
72
- : `{"tasks":[{"title":"short imperative title","spec":"...","files":["path"],"prefers":["tag"],"depends_on":[0]}]}`}`;
95
+ : `{"tasks":[{"title":"short imperative title","spec":"...","files":["path"],"prefers":["tag"],"depends_on":[0]}],${PLAIN_FIELDS}}`}`;
73
96
  }
74
97
  /** Candidate JSON bodies in a reply: fenced blocks first, then brace-to-brace. */
75
98
  export function jsonCandidates(text) {
@@ -124,7 +147,7 @@ export async function planGoal(goal, crew, repoRoot, opts = {}) {
124
147
  const brain = opts.brain ?? brainFrom("claude");
125
148
  if (!brain)
126
149
  return { tasks: templatePlan(goal), by: "template", note: "no brain available" };
127
- const prompt = plannerPrompt(goal, crew, repoContext(repoRoot), undefined, { project: opts.project });
150
+ const prompt = plannerPrompt(goal, crew, repoContext(repoRoot) + (opts.outside ? `\n\n${opts.outside}` : ""), undefined, { project: opts.project });
128
151
  let ms = 0;
129
152
  for (let attempt = 0; attempt < 2 && !opts.signal?.aborted; attempt++) {
130
153
  const res = await brain.think(prompt, repoRoot, {
@@ -143,13 +166,17 @@ export async function planGoal(goal, crew, repoRoot, opts = {}) {
143
166
  if (tasks && opts.project && tasks.length > 1) {
144
167
  // A project's specs carry the goal's exact requirements too, as the council's do (goal.ts).
145
168
  const carried = carryGoal(goal, tasks);
146
- return { tasks: carried.tasks, by: brain.label, ms, tokens: res.tokens, goalCheck: carried.check };
169
+ return { tasks: carried.tasks, by: brain.label, ms, tokens: res.tokens, goalCheck: carried.check, ...withPlain(res.text) };
147
170
  }
148
171
  if (tasks)
149
- return { tasks, by: brain.label, ms, tokens: res.tokens };
172
+ return { tasks, by: brain.label, ms, tokens: res.tokens, ...withPlain(res.text) };
150
173
  }
151
174
  return { tasks: templatePlan(goal), by: "template", note: `${brain.label} didn't return a usable plan`, ms };
152
175
  }
176
+ const withPlain = (text) => {
177
+ const plain = parsePlain(text);
178
+ return plain ? { plain } : {};
179
+ };
153
180
  /** Materialize a plan into the Room, wiring up dependency ids correctly. */
154
181
  export function seedBoard(db, goal, planned) {
155
182
  db.createSession(goal);