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
@@ -15,11 +15,24 @@ import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  import { launchAgent, stopChild } from "./launcher.js";
17
17
  import { createWorktree, removeWorktree } from "../sandbox/worktree.js";
18
+ import { onFinished } from "../platform.js";
18
19
  import { isBigGoal } from "./planner.js";
20
+ import { acceptanceList } from "./goal.js";
19
21
  export const QA_ROUNDS = 2;
20
22
  /** Bugs one round may file — QA should report the serious ones, not a wishlist. */
21
23
  export const QA_MAX_BUGS = 6;
22
24
  const QA_TIMEOUT_MS = 15 * 60_000;
25
+ /**
26
+ * A first clean round ends QA only if its check took at least this long
27
+ * (HUDDLE_QA_MIN_CLEAN_S overrides); a quicker one gets a second opinion from
28
+ * another agent. Found live (capstone round 5): cursor's round-1 check came
29
+ * back clean in 90s, QA stopped there, and 2 of the hidden checks failed
30
+ * on bugs a goal-by-goal check would have hit at once.
31
+ */
32
+ export function qaMinCleanMs() {
33
+ const s = Number(process.env.HUDDLE_QA_MIN_CLEAN_S);
34
+ return Number.isFinite(s) && s >= 0 ? s * 1000 : 5 * 60_000;
35
+ }
23
36
  /**
24
37
  * QA on or off for this goal: an explicit --qa / --no-qa wins; otherwise on
25
38
  * for big goals (the same heuristic as the design council), off for small
@@ -42,11 +55,19 @@ function git(cwd, args) {
42
55
  }
43
56
  const cap = (s, n) => (s.length > n ? s.slice(0, n) + "…" : s);
44
57
  const str = (v, n) => cap(String(v ?? "").trim(), n);
58
+ /** A title cut at a word, not mid-word (found live: "… and counts i…"). */
59
+ const capWords = (s, n) => {
60
+ if (s.length <= n)
61
+ return s;
62
+ const cut = s.slice(0, n);
63
+ const sp = cut.lastIndexOf(" ");
64
+ return (sp > n * 0.6 ? cut.slice(0, sp) : cut).replace(/[\s,;:—-]+$/, "") + "…";
65
+ };
45
66
  /** Who wrote each file since `since`: the newest `huddle(<agent>): …` commit that touched it. */
46
67
  export function authorsOf(repoRoot, since, files) {
47
68
  const out = new Map();
48
69
  for (const f of files) {
49
- const subjects = git(repoRoot, ["log", "--format=%s", `${since}..HEAD`, "--", f]).split("\n");
70
+ const subjects = git(repoRoot, ["log", "--format=%s", `${since}..HEAD`, "--", f]).split(/\r?\n/);
50
71
  const who = subjects.map((s) => /^huddle\(([^)]+)\): /.exec(s)?.[1]).find(Boolean);
51
72
  if (who)
52
73
  out.set(f, who);
@@ -56,7 +77,7 @@ export function authorsOf(repoRoot, since, files) {
56
77
  /** Every agent with a merged commit since `since`. */
57
78
  function authorsSince(repoRoot, since) {
58
79
  const n = new Map();
59
- for (const s of git(repoRoot, ["log", "--format=%s", `${since}..HEAD`]).split("\n")) {
80
+ for (const s of git(repoRoot, ["log", "--format=%s", `${since}..HEAD`]).split(/\r?\n/)) {
60
81
  const who = /^huddle\(([^)]+)\): /.exec(s)?.[1];
61
82
  if (who)
62
83
  n.set(who, (n.get(who) ?? 0) + 1);
@@ -111,18 +132,23 @@ ${o.stat || "(none)"}
111
132
 
112
133
  THE DIFF (capped):
113
134
  ${o.diff || "(empty)"}
114
- ${o.earlier.length ? `\nALREADY REPORTED in earlier rounds (their fixes merged or are in flight) — report one again only if it is STILL broken:\n${o.earlier.map((b) => `- ${b.title}${b.files.length ? ` (${b.files.join(", ")})` : ""}`).join("\n")}\n` : ""}
135
+ ${o.checklist ? `\nACCEPTANCE CHECKLIST — the GOAL's requirements, one by one:\n${o.checklist}\n` : ""}${o.earlier.length ? `\nALREADY REPORTED in earlier rounds (their fixes merged or are in flight) — report one again only if it is STILL broken:\n${o.earlier.map((b) => `- ${b.title}${b.files.length ? ` (${b.files.join(", ")})` : ""}`).join("\n")}\n` : ""}
136
+ The GOAL is the spec you check against. The team's contract (HUDDLE_CONTRACT.md), README, task specs and tests are NOT: where any of them says something the goal doesn't, the goal wins, and code that follows them instead is a bug (e.g. a generic "invalid arguments" error where the goal gives an exact message for that case). Passing tests prove only what their authors thought of.
137
+
115
138
  DO, in order (huddle_progress after each step):
116
139
  1. Run the full test suite: ${o.testCmd ? `\`${o.testCmd}\`` : "(no test command detected — look for one, or skip)"}.
117
- 2. Use it the way a user would, per the goal: run the app / CLI / functions with real inputs, edge cases, bad input.
140
+ 2. Go through the ACCEPTANCE CHECKLIST item by item: run the app / CLI / API the way a user would and compare the output with the goal byte for byte. For each item use the smallest input that triggers it, and its edge cases:
141
+ - every exact error message: trigger it with the input left out entirely (no argument, no flag, no field) AND given but empty or invalid; check the stream (stdout vs stderr), the exit code and the HTTP status
142
+ - every parser / import rule: special characters inside a quoted field AND inside an unquoted one, escapes, blank lines, extra or missing fields, rows that must be skipped, and the exact counts
143
+ - every exact output format: spacing, ordering, the empty case, the final newline
118
144
  3. Read the diff for flaws: bugs, modules from different agents that don't fit together, missing requirements, crashes.
119
145
  Start with the tasks marked NOT reviewed above: nobody but their author has read that code.
120
146
  4. For EACH concrete, reproducible defect: huddle_report_bug(title, repro, expected, actual, files).
121
147
  - When a test can show it, write a regression test in this repo's test style that FAILS now (and will pass once fixed),
122
148
  then pass its path as test_file — the fix task gets it. Don't fix the bug yourself.
123
149
  - Only real defects, at most ${o.maxBugs}, most serious first. No style nits, no "nice to have".
124
- 5. huddle_done(task_id, summary): what you checked, and "N bugs filed" or "clean".
125
- Don't commit. If everything works, say so — a clean round ends QA.`;
150
+ 5. huddle_done(task_id, summary): which checklist items you ran, and "N bugs filed" or "clean".
151
+ Don't commit. If everything works, say so — but only after every checklist item was run.`;
126
152
  }
127
153
  export async function runQa(o) {
128
154
  const { db, room, repoRoot, signal, onEvent } = o;
@@ -139,21 +165,28 @@ export async function runQa(o) {
139
165
  }
140
166
  // Who checks this round: someone who didn't write the code, rotating; an
141
167
  // author only when everyone wrote some (fewest commits first).
168
+ // After a quick clean round, the second opinion comes from someone else (when there is anyone).
169
+ let secondOpinion = new Set();
142
170
  const pick = () => {
143
171
  const wrote = authorsSince(repoRoot, o.startHead);
144
- return local
172
+ const others = local.filter((w) => !secondOpinion.has(w.id));
173
+ return (others.length ? others : local)
145
174
  .map((w, i) => ({ w, i, author: wrote.has(w.id), n: wrote.get(w.id) ?? 0, u: used.get(w.id) ?? 0 }))
146
175
  .sort((a, b) => Number(a.author) - Number(b.author) || a.u - b.u || a.n - b.n || a.i - b.i)
147
176
  .slice(0, Math.max(1, o.perRound ?? 1))
148
177
  .map(({ w, author }) => ({ w, author }));
149
178
  };
150
179
  const worktrees = new Map(); // QA agent -> its worktree (to read a test_file)
180
+ const checklist = acceptanceList(o.goal);
181
+ const minCleanMs = o.minCleanMs ?? qaMinCleanMs();
182
+ let cleanRounds = 0;
183
+ let slowest = 0; // the longest finished check in this round
151
184
  let round = 0;
152
185
  let roundBugs = 0;
153
186
  let finished = 0; // checks in this round that ran to the end
154
187
  /** huddle_report_bug while a round runs: validate, dedupe, cap, file a routed fix task. */
155
188
  const onBug = (raw, from) => {
156
- const title = str(raw.title, 120);
189
+ const title = capWords(String(raw.title ?? "").trim().replace(/\s+/g, " "), 120);
157
190
  const repro = str(raw.repro, 2000);
158
191
  if (!title || !repro)
159
192
  return { ok: false, error: "title and repro are required — exact steps or a command that shows the bug" };
@@ -200,7 +233,7 @@ export async function runQa(o) {
200
233
  .filter(Boolean)
201
234
  .join("\n");
202
235
  const task = db.addTask({
203
- title: cap(`Fix: ${title}`, 120),
236
+ title: capWords(`Fix: ${title}`, 120),
204
237
  spec,
205
238
  prefers: ["quickfix", ...(assignee ? [`@${assignee}`] : [])],
206
239
  files: [...files, ...(testCode && !testFile.startsWith("(") ? [testFile] : [])],
@@ -243,6 +276,7 @@ export async function runQa(o) {
243
276
  approvals: o.approvals,
244
277
  worktree: wt,
245
278
  });
279
+ o.budget?.track(h.meter, h.agentId);
246
280
  }
247
281
  catch (e) {
248
282
  db.setTaskStatus(task.id, "failed");
@@ -275,23 +309,34 @@ export async function runQa(o) {
275
309
  worktrees.delete(w.id);
276
310
  const bugs = roundBugsBy(w.id) - before;
277
311
  const reason = code === 0 ? undefined : signal?.aborted ? "stopped by you" : `exited with code ${code}`;
278
- if (code === 0)
312
+ if (code === 0) {
279
313
  finished++;
280
- db.publish("qa.done", { agent_id: w.id, task_id: task.id, payload: { round, ok: code === 0, bugs, ms } });
281
- onEvent({ type: "checked", agent: w.id, round, ok: code === 0, bugs, ms, tokens: h.meter.tokens, reason });
314
+ slowest = Math.max(slowest, ms);
315
+ }
316
+ db.publish("qa.done", { agent_id: w.id, task_id: task.id, payload: { round, ok: code === 0, bugs, ms, tokens: h.meter.tokens } });
317
+ onEvent({ type: "checked", agent: w.id, round, ok: code === 0, bugs, ms, tokens: h.meter.tokens, reason, task: task.id });
282
318
  resolve();
283
319
  };
284
- h.child.on("exit", end);
285
- h.child.on("error", () => end(-1));
320
+ // Its last stdout lines (token usage) can still be in the pipe at 'exit'.
321
+ onFinished(h.child, end);
286
322
  });
287
323
  const roundBugsBy = (id) => filed.filter((b) => b.round === round && b.by === id).length;
288
324
  const prev = room.bugs;
289
325
  try {
290
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
+ }
291
334
  roundBugs = 0;
292
335
  finished = 0;
336
+ slowest = 0;
293
337
  result.rounds = round;
294
338
  const who = pick();
339
+ secondOpinion = new Set();
295
340
  who.forEach(({ w }) => used.set(w.id, (used.get(w.id) ?? 0) + 1));
296
341
  // authors: checkers who also wrote some of it (everyone did), so the story doesn't claim fresh eyes.
297
342
  db.publish("qa.round", { payload: { round, agents: who.map((x) => x.w.id), authors: who.filter((x) => x.author).map((x) => x.w.id) } });
@@ -308,7 +353,7 @@ export async function runQa(o) {
308
353
  const stat = git(repoRoot, ["diff", "--stat", `${o.startHead}..HEAD`]);
309
354
  const diff = cap(git(repoRoot, ["diff", "--no-color", `${o.startHead}..HEAD`]), 20_000);
310
355
  room.bugs = onBug;
311
- await Promise.all(who.map(({ w, author }) => check(w, author, qaPrompt({ goal: o.goal, round, testCmd: o.testCmd(repoRoot), tasks, stat, diff, earlier: filed.filter((b) => b.round < round), author, maxBugs }))));
356
+ await Promise.all(who.map(({ w, author }) => check(w, author, qaPrompt({ goal: o.goal, round, testCmd: o.testCmd(repoRoot), tasks, stat, diff, earlier: filed.filter((b) => b.round < round), author, maxBugs, checklist }))));
312
357
  room.bugs = prev;
313
358
  if (signal?.aborted)
314
359
  break;
@@ -322,6 +367,17 @@ export async function runQa(o) {
322
367
  continue;
323
368
  }
324
369
  if (!roundBugs) {
370
+ // A first clean round, quick, with nothing ever filed: one agent's word isn't enough.
371
+ // Trusted: after fixes (it re-checked them), a second clean, the last round, or a long check.
372
+ const trusted = result.bugs > 0 || cleanRounds > 0 || round === rounds || slowest >= minCleanMs;
373
+ cleanRounds++;
374
+ if (!trusted) {
375
+ secondOpinion = new Set(who.map(({ w }) => w.id));
376
+ const reason = `clean in ${Math.round(slowest / 1000)}s on the first look — another agent checks again`;
377
+ db.publish("qa.suspect", { payload: { round, ms: slowest, reason } });
378
+ onEvent({ type: "suspect", round, ms: slowest, reason });
379
+ continue;
380
+ }
325
381
  db.publish("qa.clean", { payload: { round } });
326
382
  onEvent({ type: "clean", round });
327
383
  result.clean = true;