huddle-cli 0.1.0 → 0.1.1

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 (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/QUICKSTART.md +102 -0
  3. package/README.md +23 -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 +6 -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/attach/connect.js +4 -1
  16. package/dist/attach/guard.js +21 -2
  17. package/dist/attach/live.js +5 -1
  18. package/dist/attach/plug.js +14 -3
  19. package/dist/attach/screen.js +150 -0
  20. package/dist/attach/session.js +7 -2
  21. package/dist/cli.js +108 -40
  22. package/dist/mcp/context-tools.js +79 -0
  23. package/dist/mcp/server.js +38 -11
  24. package/dist/orchestrator/ask.js +13 -10
  25. package/dist/orchestrator/brain.js +13 -3
  26. package/dist/orchestrator/council.js +427 -86
  27. package/dist/orchestrator/goal.js +196 -0
  28. package/dist/orchestrator/handoff.js +2 -2
  29. package/dist/orchestrator/launcher.js +29 -13
  30. package/dist/orchestrator/learn.js +105 -0
  31. package/dist/orchestrator/planner.js +25 -7
  32. package/dist/orchestrator/qa.js +62 -14
  33. package/dist/orchestrator/radar.js +719 -0
  34. package/dist/orchestrator/scheduler.js +498 -39
  35. package/dist/orchestrator/takeover.js +155 -0
  36. package/dist/platform.js +273 -0
  37. package/dist/room/context.js +1285 -0
  38. package/dist/room/contract.js +132 -0
  39. package/dist/room/db.js +79 -16
  40. package/dist/room/followup.js +233 -0
  41. package/dist/room/identity.js +2 -2
  42. package/dist/room/lessons.js +275 -0
  43. package/dist/room/live.js +75 -20
  44. package/dist/room/mail.js +212 -0
  45. package/dist/room/progress.js +40 -1
  46. package/dist/room/server.js +169 -39
  47. package/dist/room/tunnel.js +6 -5
  48. package/dist/sandbox/sync.js +4 -4
  49. package/dist/sandbox/worktree.js +145 -14
  50. package/dist/ui/app.js +554 -133
  51. package/dist/ui/catalog.js +62 -24
  52. package/dist/ui/dashboard.js +194 -51
  53. package/dist/ui/doctor.js +15 -11
  54. package/dist/ui/lessons.js +81 -0
  55. package/dist/ui/paste.js +515 -0
  56. package/dist/ui/remote.js +11 -5
  57. package/dist/ui/setup.js +152 -0
  58. package/dist/ui/story.js +666 -69
  59. package/dist/ui/term.js +151 -16
  60. package/dist/ui/watch.js +145 -48
  61. 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] : [])],
@@ -275,14 +308,16 @@ export async function runQa(o) {
275
308
  worktrees.delete(w.id);
276
309
  const bugs = roundBugsBy(w.id) - before;
277
310
  const reason = code === 0 ? undefined : signal?.aborted ? "stopped by you" : `exited with code ${code}`;
278
- if (code === 0)
311
+ if (code === 0) {
279
312
  finished++;
313
+ slowest = Math.max(slowest, ms);
314
+ }
280
315
  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 });
316
+ onEvent({ type: "checked", agent: w.id, round, ok: code === 0, bugs, ms, tokens: h.meter.tokens, reason, task: task.id });
282
317
  resolve();
283
318
  };
284
- h.child.on("exit", end);
285
- h.child.on("error", () => end(-1));
319
+ // Its last stdout lines (token usage) can still be in the pipe at 'exit'.
320
+ onFinished(h.child, end);
286
321
  });
287
322
  const roundBugsBy = (id) => filed.filter((b) => b.round === round && b.by === id).length;
288
323
  const prev = room.bugs;
@@ -290,8 +325,10 @@ export async function runQa(o) {
290
325
  for (round = 1; round <= rounds && !signal?.aborted; round++) {
291
326
  roundBugs = 0;
292
327
  finished = 0;
328
+ slowest = 0;
293
329
  result.rounds = round;
294
330
  const who = pick();
331
+ secondOpinion = new Set();
295
332
  who.forEach(({ w }) => used.set(w.id, (used.get(w.id) ?? 0) + 1));
296
333
  // authors: checkers who also wrote some of it (everyone did), so the story doesn't claim fresh eyes.
297
334
  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 +345,7 @@ export async function runQa(o) {
308
345
  const stat = git(repoRoot, ["diff", "--stat", `${o.startHead}..HEAD`]);
309
346
  const diff = cap(git(repoRoot, ["diff", "--no-color", `${o.startHead}..HEAD`]), 20_000);
310
347
  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 }))));
348
+ 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
349
  room.bugs = prev;
313
350
  if (signal?.aborted)
314
351
  break;
@@ -322,6 +359,17 @@ export async function runQa(o) {
322
359
  continue;
323
360
  }
324
361
  if (!roundBugs) {
362
+ // A first clean round, quick, with nothing ever filed: one agent's word isn't enough.
363
+ // Trusted: after fixes (it re-checked them), a second clean, the last round, or a long check.
364
+ const trusted = result.bugs > 0 || cleanRounds > 0 || round === rounds || slowest >= minCleanMs;
365
+ cleanRounds++;
366
+ if (!trusted) {
367
+ secondOpinion = new Set(who.map(({ w }) => w.id));
368
+ const reason = `clean in ${Math.round(slowest / 1000)}s on the first look — another agent checks again`;
369
+ db.publish("qa.suspect", { payload: { round, ms: slowest, reason } });
370
+ onEvent({ type: "suspect", round, ms: slowest, reason });
371
+ continue;
372
+ }
325
373
  db.publish("qa.clean", { payload: { round } });
326
374
  onEvent({ type: "clean", round });
327
375
  result.clean = true;