huddle-cli 0.1.1 → 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.
@@ -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,6 +368,9 @@ 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
375
  const context = repoContext(repoRoot, 150);
373
376
  const ids = members.map((m) => m.id);
@@ -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) {
@@ -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);
@@ -276,6 +276,7 @@ export async function runQa(o) {
276
276
  approvals: o.approvals,
277
277
  worktree: wt,
278
278
  });
279
+ o.budget?.track(h.meter, h.agentId);
279
280
  }
280
281
  catch (e) {
281
282
  db.setTaskStatus(task.id, "failed");
@@ -312,7 +313,7 @@ export async function runQa(o) {
312
313
  finished++;
313
314
  slowest = Math.max(slowest, ms);
314
315
  }
315
- db.publish("qa.done", { agent_id: w.id, task_id: task.id, payload: { round, ok: code === 0, bugs, ms } });
316
+ db.publish("qa.done", { agent_id: w.id, task_id: task.id, payload: { round, ok: code === 0, bugs, ms, tokens: h.meter.tokens } });
316
317
  onEvent({ type: "checked", agent: w.id, round, ok: code === 0, bugs, ms, tokens: h.meter.tokens, reason, task: task.id });
317
318
  resolve();
318
319
  };
@@ -323,6 +324,13 @@ export async function runQa(o) {
323
324
  const prev = room.bugs;
324
325
  try {
325
326
  for (round = 1; round <= rounds && !signal?.aborted; round++) {
327
+ if (o.budget?.stopping) {
328
+ result.budget = true;
329
+ const reason = `the token budget is nearly spent — QA round ${round} not started`;
330
+ db.publish("qa.skipped", { payload: { round, reason } });
331
+ onEvent({ type: "skipped", reason });
332
+ break;
333
+ }
326
334
  roundBugs = 0;
327
335
  finished = 0;
328
336
  slowest = 0;