huddle-cli 0.0.0-stage → 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 (75) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/QUICKSTART.md +102 -0
  4. package/README.md +376 -2
  5. package/dist/adapters/amp.js +40 -0
  6. package/dist/adapters/antigravity.js +54 -0
  7. package/dist/adapters/claude.js +86 -0
  8. package/dist/adapters/codex.js +64 -0
  9. package/dist/adapters/copilot.js +69 -0
  10. package/dist/adapters/cursor.js +92 -0
  11. package/dist/adapters/custom.js +342 -0
  12. package/dist/adapters/droid.js +58 -0
  13. package/dist/adapters/gemini.js +61 -0
  14. package/dist/adapters/goose.js +57 -0
  15. package/dist/adapters/index.js +161 -0
  16. package/dist/adapters/kiro.js +41 -0
  17. package/dist/adapters/opencode.js +63 -0
  18. package/dist/adapters/qwen.js +67 -0
  19. package/dist/adapters/types.js +6 -0
  20. package/dist/adapters/usage.js +141 -0
  21. package/dist/attach/connect.js +412 -0
  22. package/dist/attach/guard.js +83 -0
  23. package/dist/attach/live.js +226 -0
  24. package/dist/attach/plug.js +185 -0
  25. package/dist/attach/screen.js +150 -0
  26. package/dist/attach/session.js +177 -0
  27. package/dist/cli.js +244 -0
  28. package/dist/mcp/context-tools.js +79 -0
  29. package/dist/mcp/server.js +267 -0
  30. package/dist/orchestrator/ask.js +54 -0
  31. package/dist/orchestrator/brain.js +174 -0
  32. package/dist/orchestrator/council.js +615 -0
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +117 -0
  35. package/dist/orchestrator/launcher.js +171 -0
  36. package/dist/orchestrator/learn.js +105 -0
  37. package/dist/orchestrator/planner.js +212 -0
  38. package/dist/orchestrator/qa.js +398 -0
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +1207 -0
  41. package/dist/orchestrator/takeover.js +240 -0
  42. package/dist/platform.js +273 -0
  43. package/dist/preflight.js +31 -0
  44. package/dist/relay/server.js +113 -0
  45. package/dist/room/context.js +1285 -0
  46. package/dist/room/contract.js +132 -0
  47. package/dist/room/db.js +680 -0
  48. package/dist/room/followup.js +233 -0
  49. package/dist/room/identity.js +122 -0
  50. package/dist/room/lessons.js +275 -0
  51. package/dist/room/live.js +348 -0
  52. package/dist/room/mail.js +212 -0
  53. package/dist/room/progress.js +149 -0
  54. package/dist/room/relaylink.js +85 -0
  55. package/dist/room/server.js +882 -0
  56. package/dist/room/sqlite.js +156 -0
  57. package/dist/room/transport.js +107 -0
  58. package/dist/room/tunnel.js +86 -0
  59. package/dist/sandbox/sync.js +104 -0
  60. package/dist/sandbox/worktree.js +392 -0
  61. package/dist/types.js +4 -0
  62. package/dist/ui/app.js +1955 -0
  63. package/dist/ui/catalog.js +113 -0
  64. package/dist/ui/dashboard.js +335 -0
  65. package/dist/ui/doctor.js +58 -0
  66. package/dist/ui/lessons.js +81 -0
  67. package/dist/ui/logo.js +40 -0
  68. package/dist/ui/paste.js +515 -0
  69. package/dist/ui/remote.js +338 -0
  70. package/dist/ui/setup.js +152 -0
  71. package/dist/ui/story.js +1529 -0
  72. package/dist/ui/term.js +249 -0
  73. package/dist/ui/watch.js +271 -0
  74. package/install.sh +44 -0
  75. package/package.json +63 -4
@@ -0,0 +1,212 @@
1
+ // The planner turns one goal into a task graph sized to the goal and to the
2
+ // crew. An agent (read-only, in the repo) writes the plan; if that fails for
3
+ // any reason we fall back to a small deterministic template so a run never
4
+ // blocks on planning.
5
+ import { brainFrom, repoContext } from "./brain.js";
6
+ import { linkPlan, parseContextIds } from "../room/context.js";
7
+ import { carryGoal } from "./goal.js";
8
+ /**
9
+ * Fallback when no brain can plan: the whole goal as ONE task. (It used to be
10
+ * a scaffold → UI → logic → tests → review spine; seen live, a planner hiccup
11
+ * turned "add two shape modules" into five tasks, and an agent built a UI
12
+ * nobody asked for. One honest task beats an invented plan.)
13
+ */
14
+ export function templatePlan(goal) {
15
+ const title = goal.length > 72 ? goal.slice(0, 71).replace(/\s+\S*$/, "") + "…" : goal;
16
+ return [{ title, spec: goal, prefers: [] }];
17
+ }
18
+ const TAGS = ["frontend", "design", "product", "refactor", "systems", "algorithms", "backend", "research", "breadth", "docs", "data", "quickfix"];
19
+ /**
20
+ * How big the plan should be. A change: as few tasks as it takes. A project
21
+ * (a big goal, see goalSize): enough tasks to keep every agent busy for the
22
+ * whole run. Found live on the capstone: with only "size the plan to the
23
+ * goal" and "up to <crew> tasks at once", the council's plan for a whole app
24
+ * was 5 big tasks, 2 of them startable, so one of three agents sat idle at the
25
+ * start and nobody could merge more than two.
26
+ */
27
+ export function sizeRules(crew, project) {
28
+ if (!project)
29
+ return `- Size the plan to the goal. A one-line change is ONE task. Never pad.
30
+ - Maximize parallelism: as many tasks as possible (up to ${crew}) should be startable immediately, with no depends_on.`;
31
+ const lo = Math.max(2, crew * 2);
32
+ const hi = Math.min(MAX_TASKS, Math.max(lo + 2, crew * 4));
33
+ return `- This is a whole project for ${crew} agent${crew === 1 ? "" : "s"}, not a change: plan ${lo}-${hi} tasks, so every agent keeps working for the whole run and finishes 2+ tasks. Each task is one coherent module or feature with its own tests, small enough for one agent in about 10 minutes. Don't bundle two modules into one task, and don't pad.
34
+ - At least ${crew} tasks must be startable immediately, with no depends_on: a module whose contract is written into the specs (exports, data shapes, exact messages) can be built and tested in parallel with its consumers.`;
35
+ }
36
+ export function plannerPrompt(goal, crew, context, council, opts = {}) {
37
+ return `You are the planner for Huddle, which runs several AI coding agents on one repo at the same time.
38
+ Each task runs in its own git worktree; finished tasks are tested and merged into main.
39
+
40
+ GOAL: ${goal}
41
+
42
+ CREW (${crew.length} agents, all available now): ${[...new Set(crew.map((c) => c.id.split("#")[0]))]
43
+ .map((id) => {
44
+ const n = crew.filter((c) => c.id.split("#")[0] === id).length;
45
+ const a = crew.find((c) => c.id.split("#")[0] === id);
46
+ return `${id}${n > 1 ? ` ×${n}` : ""} (good at: ${a.prefers.join(", ")})`;
47
+ })
48
+ .join("; ")}
49
+
50
+ REPO SNAPSHOT:
51
+ ${context}
52
+
53
+ ${council ? `${council.notes}
54
+
55
+ ` : ""}Plan from this snapshot${council ? " and the council's proposals and cross-reviews: build on the ideas teammates ADOPTed, fix every FLAW they found, and cover what they said was missing" : ""}. Split the goal into tasks. Rules:
56
+ ${sizeRules(crew.length, opts.project ?? !!council)}
57
+ - Avoid a "scaffold first" bottleneck: if tasks share an interface, define it in each task's spec (exact function names, file paths, data shapes) so they can be built in parallel against that contract.
58
+ - Parallel tasks should own disjoint files. List each task's files — every file it will edit, including shared ones it must touch (an index/registry/router that re-exports modules, package.json, a config). If two tasks must edit the same file, list it on both: Huddle then runs them one after the other, or merges both edits safely.
59
+ - Never write "do not edit/touch <file>" in a spec to keep tasks apart: if the repo's tests or structure need a shared file changed (e.g. a test that every module is re-exported from an index), the task needs it — an agent that obeys the ban leaves the tests red and the task fails. List the file on each task that needs it instead.
60
+ - Use depends_on only when a task truly needs another task's merged code.
61
+ - Each spec must be self-contained: what to build, where, the contract, and how to verify it.
62
+ - Carry the goal's exact requirements VERBATIM into the spec of every task that implements them: each exact output line, error message, format, number and edge-case rule (quoting, blank lines, bad rows, defaults, exit codes, stdout vs stderr), word for word — never paraphrased or shortened. Never invent a different message or output for a case the goal already gives one: a missing required input gets the goal's own error (e.g. "<field> is required"), not a generic usage error.
63
+ - Consistency: different agents write each task, so start EVERY spec with the same one-line "Conventions:" (language/module system, formatting such as semicolons and quotes, test runner and test file naming). Take them from the existing code; if the repo is empty, choose them once and repeat them verbatim.
64
+ - prefers: 1-3 tags from: ${TAGS.join(", ")}.
65
+ ${council ? `- contract: the exact names tasks share — files/modules and the task that owns each, exported functions with signatures, data types with field names, CLI flags, routes, error strings (the goal's exact strings, copied), test helpers. Markdown, under 3000 characters. Every spec uses these names verbatim. The contract adds names the goal leaves open; it never overrides what the goal states.
66
+ ` : ""}
67
+ 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
+ Reply with ONLY this JSON, no prose, no code fences:
69
+ ${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"}
71
+ "from" and "with" use only these ids: ${council.members.join(", ")}.`
72
+ : `{"tasks":[{"title":"short imperative title","spec":"...","files":["path"],"prefers":["tag"],"depends_on":[0]}]}`}`;
73
+ }
74
+ /** Candidate JSON bodies in a reply: fenced blocks first, then brace-to-brace. */
75
+ export function jsonCandidates(text) {
76
+ const out = [];
77
+ for (const m of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g))
78
+ out.push(m[1]);
79
+ const end = text.lastIndexOf("}");
80
+ for (let i = text.indexOf("{"); i >= 0 && i < end; i = text.indexOf("{", i + 1))
81
+ out.push(text.slice(i, end + 1));
82
+ return out;
83
+ }
84
+ /** The most tasks a plan may have; a longer reply is not a plan. */
85
+ export const MAX_TASKS = 20;
86
+ /** A council task's idea credit: "from" is one primary id (or, from older prompts, a list), "with" the contributors. */
87
+ function ideaFrom(t) {
88
+ const primary = typeof t.from === "string" ? [t.from] : Array.isArray(t.from) ? t.from.map(String) : [];
89
+ const from = [...new Set([...primary, ...(Array.isArray(t.with) ? t.with.map(String) : [])].map((x) => x.trim()).filter(Boolean))];
90
+ return from.length ? { from } : {};
91
+ }
92
+ /** Pull a {"tasks": [...]} plan out of a model's reply, however it was wrapped. */
93
+ export function parsePlan(text) {
94
+ for (const body of jsonCandidates(text)) {
95
+ let raw;
96
+ try {
97
+ raw = JSON.parse(body);
98
+ }
99
+ catch {
100
+ continue;
101
+ }
102
+ // A from-scratch project (the capstone's brief) honestly needs 10-14 tasks; 12 used to throw such a plan away.
103
+ if (!Array.isArray(raw?.tasks) || raw.tasks.length === 0 || raw.tasks.length > MAX_TASKS)
104
+ continue;
105
+ const titles = raw.tasks.map((t) => String(t?.title ?? "").trim());
106
+ if (titles.some((t) => !t))
107
+ continue;
108
+ // depends_on may come back as indices (0- or 1-based is ambiguous, we use 0) or as titles.
109
+ const toIndex = (d) => (typeof d === "number" ? d : titles.indexOf(String(d).trim()));
110
+ return raw.tasks.map((t, i) => ({
111
+ title: titles[i].slice(0, 120),
112
+ spec: String(t.spec ?? ""),
113
+ files: Array.isArray(t.files) ? t.files.map(String) : [],
114
+ prefers: Array.isArray(t.prefers) ? t.prefers.map(String) : [],
115
+ ...ideaFrom(t),
116
+ ...(parseContextIds(t.context).length ? { context: parseContextIds(t.context) } : {}),
117
+ // only backward edges → guaranteed acyclic
118
+ depends_on: (Array.isArray(t.depends_on) ? t.depends_on : []).map(toIndex).filter((d) => Number.isInteger(d) && d >= 0 && d < i),
119
+ }));
120
+ }
121
+ return null;
122
+ }
123
+ export async function planGoal(goal, crew, repoRoot, opts = {}) {
124
+ const brain = opts.brain ?? brainFrom("claude");
125
+ if (!brain)
126
+ return { tasks: templatePlan(goal), by: "template", note: "no brain available" };
127
+ const prompt = plannerPrompt(goal, crew, repoContext(repoRoot), undefined, { project: opts.project });
128
+ let ms = 0;
129
+ for (let attempt = 0; attempt < 2 && !opts.signal?.aborted; attempt++) {
130
+ const res = await brain.think(prompt, repoRoot, {
131
+ signal: opts.signal,
132
+ // A plan takes ~6–15s on a cloud model; a call that hangs is retried
133
+ // instead of eating the whole budget (seen live: 4m30s, then the
134
+ // fallback). Local models and Opus (the default brain) are slower.
135
+ timeoutMs: brain.spec.startsWith("ollama") || brain.spec === "claude" || brain.spec.endsWith(":opus") ? 4 * 60_000 : 2 * 60_000,
136
+ json: true,
137
+ // The snapshot is in the prompt; exploring just costs time. If that
138
+ // first answer isn't a plan, the retry may read the repo (read-only).
139
+ selfContained: attempt === 0,
140
+ });
141
+ ms += res.ms;
142
+ const tasks = res.ok ? parsePlan(res.text) : null;
143
+ if (tasks && opts.project && tasks.length > 1) {
144
+ // A project's specs carry the goal's exact requirements too, as the council's do (goal.ts).
145
+ const carried = carryGoal(goal, tasks);
146
+ return { tasks: carried.tasks, by: brain.label, ms, tokens: res.tokens, goalCheck: carried.check };
147
+ }
148
+ if (tasks)
149
+ return { tasks, by: brain.label, ms, tokens: res.tokens };
150
+ }
151
+ return { tasks: templatePlan(goal), by: "template", note: `${brain.label} didn't return a usable plan`, ms };
152
+ }
153
+ /** Materialize a plan into the Room, wiring up dependency ids correctly. */
154
+ export function seedBoard(db, goal, planned) {
155
+ db.createSession(goal);
156
+ const ids = [];
157
+ for (const p of planned) {
158
+ const deps = (p.depends_on ?? []).map((i) => ids[i]).filter(Boolean);
159
+ // Found live (registry eval): "Files you own" read as a fence — two agents
160
+ // left npm test red rather than add their line to an unlisted src/index.js.
161
+ const spec = p.files?.length
162
+ ? `${p.spec}\n\nPlanned files: ${p.files.join(", ")} (if the work needs another file too, announce it with huddle_announce_plan and edit it — a passing test suite beats any "don't touch" above)`
163
+ : p.spec;
164
+ // files also go to the scheduler: two tasks that share one never run at once.
165
+ const task = db.addTask({ title: p.title, spec, prefers: p.prefers, depends_on: deps, files: p.files });
166
+ ids.push(task.id);
167
+ }
168
+ // Shared-screen items the planner tied to each task ride in that task's prompt.
169
+ linkPlan(db, ids, planned);
170
+ return ids;
171
+ }
172
+ /** "(1) … (2) …", "1. … 2. …" or "three tasks": the user already split the work. */
173
+ const LISTS_TASKS = /\b(one|two|three|four|five|six|\d+) (separate |independent )?tasks?\b|\btask \d\b/i;
174
+ export function listsOwnTasks(goal) {
175
+ const numbered = (goal.match(/\(\d+\)|(^|\n)\s*\d+[.)]\s/g) ?? []).length;
176
+ return numbered >= 2 || LISTS_TASKS.test(goal);
177
+ }
178
+ const FROM_SCRATCH = /\bfrom (scratch|the ground up|zero)\b|\bgreenfield\b|\bnew (project|app|application|product)\b|\bend[- ]to[- ]end\b|\bfull[- ]stack\b|\b(an? )?mvp\b|\bwhole (app|project|product|system)\b/i;
179
+ const NOUNS = "app|application|game|website|web ?app|site|service|backend|frontend|api|cli|tool|platform|dashboard|project|system|clone|bot|server|compiler|interpreter|engine";
180
+ const BUILD_A = new RegExp(`\\b(?:(?:build|develop)\\s+(?:me\\s+)?(?:an?|the)|(?:create|make|write)\\s+(?:me\\s+)?an?)\\s+(?:[\\w-]+\\s+){0,4}?(?:${NOUNS})\\b`, "i");
181
+ /**
182
+ * Is this a big goal — a project, not a change? Big goals get the heavier team
183
+ * phases (a design council before, a QA round after); small goals keep the
184
+ * fast path. ONE heuristic so the phases agree. Big: "from scratch / end to
185
+ * end / full-stack / MVP", "build a <something> app/game/site/API/CLI…", 5+
186
+ * listed items, or a plan of 5+ tasks. Then, unless the goal already spells
187
+ * out its own few tasks (the eval goals: "Three tasks. (1) … (2) …"), a long
188
+ * goal (600+ chars) or a real goal (120+ chars) in a (nearly) empty repo.
189
+ */
190
+ export function goalSize(goal, opts = {}) {
191
+ if (FROM_SCRATCH.test(goal))
192
+ return { big: true, why: "a from-scratch project" };
193
+ if (BUILD_A.test(goal))
194
+ return { big: true, why: "a new project to build" };
195
+ const items = goal.split("\n").filter((l) => /^\s*([-*•]|\d+[.)])\s+\S/.test(l)).length;
196
+ const inline = (goal.match(/\(\d+\)/g) ?? []).length;
197
+ if (Math.max(items, inline) >= 5)
198
+ return { big: true, why: "a spec of 5+ items" };
199
+ if ((opts.tasks ?? 0) >= 5)
200
+ return { big: true, why: `a plan of ${opts.tasks} tasks` };
201
+ if (listsOwnTasks(goal))
202
+ return { big: false, why: "the goal already lists its tasks" };
203
+ if (goal.length >= 600)
204
+ return { big: true, why: "a long goal" };
205
+ if ((opts.repoFiles ?? Infinity) <= 3 && goal.length >= 120)
206
+ return { big: true, why: "a new project in an empty repo" };
207
+ return { big: false, why: "a small goal" };
208
+ }
209
+ export function isBigGoal(goal, opts = {}) {
210
+ return goalSize(goal, opts).big;
211
+ }
212
+ //# sourceMappingURL=planner.js.map
@@ -0,0 +1,398 @@
1
+ // QA: the team re-checks its own build. After every planned task has merged,
2
+ // an agent that did NOT write the code (rotating between rounds) checks the
3
+ // integrated main in a fresh worktree: runs the whole test suite, uses the
4
+ // thing the way the goal says a user would, and reads the diff for flaws.
5
+ // Every concrete bug it files with huddle_report_bug becomes a fix task on
6
+ // the board, routed to the agent that wrote those files ("@<agent>" in its
7
+ // prefers — the scheduler gives it to them first, else to anyone free), with
8
+ // the repro (and a regression test, when the QA agent wrote one) in the spec.
9
+ // Fixes go through the normal pipeline: worktree, tests, cross-review, merge.
10
+ // Then QA runs again — up to `rounds` times, or until a round comes back clean.
11
+ //
12
+ // QA agents never land anything themselves: their worktree is thrown away.
13
+ import { execFileSync } from "node:child_process";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { launchAgent, stopChild } from "./launcher.js";
17
+ import { createWorktree, removeWorktree } from "../sandbox/worktree.js";
18
+ import { onFinished } from "../platform.js";
19
+ import { isBigGoal } from "./planner.js";
20
+ import { acceptanceList } from "./goal.js";
21
+ export const QA_ROUNDS = 2;
22
+ /** Bugs one round may file — QA should report the serious ones, not a wishlist. */
23
+ export const QA_MAX_BUGS = 6;
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
+ }
36
+ /**
37
+ * QA on or off for this goal: an explicit --qa / --no-qa wins; otherwise on
38
+ * for big goals (the same heuristic as the design council), off for small
39
+ * ones, and never in plan-only mode.
40
+ */
41
+ export function shouldQa(goal, o = {}) {
42
+ if (o.planOnly)
43
+ return false;
44
+ if (o.flag !== undefined)
45
+ return o.flag;
46
+ return isBigGoal(goal, { tasks: o.tasks, repoFiles: o.repoFiles });
47
+ }
48
+ function git(cwd, args) {
49
+ try {
50
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 32 * 1024 * 1024 }).trim();
51
+ }
52
+ catch {
53
+ return "";
54
+ }
55
+ }
56
+ const cap = (s, n) => (s.length > n ? s.slice(0, n) + "…" : s);
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
+ };
66
+ /** Who wrote each file since `since`: the newest `huddle(<agent>): …` commit that touched it. */
67
+ export function authorsOf(repoRoot, since, files) {
68
+ const out = new Map();
69
+ for (const f of files) {
70
+ const subjects = git(repoRoot, ["log", "--format=%s", `${since}..HEAD`, "--", f]).split(/\r?\n/);
71
+ const who = subjects.map((s) => /^huddle\(([^)]+)\): /.exec(s)?.[1]).find(Boolean);
72
+ if (who)
73
+ out.set(f, who);
74
+ }
75
+ return out;
76
+ }
77
+ /** Every agent with a merged commit since `since`. */
78
+ function authorsSince(repoRoot, since) {
79
+ const n = new Map();
80
+ for (const s of git(repoRoot, ["log", "--format=%s", `${since}..HEAD`]).split(/\r?\n/)) {
81
+ const who = /^huddle\(([^)]+)\): /.exec(s)?.[1];
82
+ if (who)
83
+ n.set(who, (n.get(who) ?? 0) + 1);
84
+ }
85
+ return n;
86
+ }
87
+ const STOP = new Set(["the", "a", "an", "is", "are", "of", "in", "on", "to", "for", "and", "or", "with", "when", "it", "its", "not", "no", "does", "doesn't", "dont", "don't", "be", "by", "at", "fails", "fail", "bug", "broken", "wrong"]);
88
+ const words = (s) => new Set(s.toLowerCase().replace(/[^\w\s.-]/g, " ").split(/\s+/).filter((w) => w.length > 1 && !STOP.has(w)));
89
+ /** Same bug, reported twice? Close titles in the same place (or no place given). Same words in different files = two bugs. */
90
+ export function sameBug(a, b) {
91
+ const x = words(a.title);
92
+ const y = words(b.title);
93
+ const inter = [...x].filter((w) => y.has(w)).length;
94
+ const jac = inter / Math.max(1, new Set([...x, ...y]).size);
95
+ const sharedFiles = a.files.some((f) => b.files.includes(f));
96
+ const noPlace = !a.files.length || !b.files.length;
97
+ return jac >= 0.4 && (sharedFiles || noPlace);
98
+ }
99
+ /**
100
+ * Task id -> who cross-reviewed it (a review that ran, not one skipped on a
101
+ * time-out), from the scheduler's run.reviewed events. Found live (capstone
102
+ * round 3): 4 of 10 tasks merged unreviewed because no agent was free at the
103
+ * time, and QA's brief didn't say which — so QA read all of it equally.
104
+ */
105
+ function reviewersOf(db) {
106
+ const out = new Map();
107
+ for (const ev of db.eventsOfType(["run.reviewed"], 0, 1000)) {
108
+ try {
109
+ const p = JSON.parse(ev.payload || "{}");
110
+ const task = p.task ?? ev.task_id;
111
+ if (task && p.agent && !p.skipped)
112
+ out.set(task, p.agent);
113
+ }
114
+ catch {
115
+ /* a malformed event never breaks QA */
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+ function qaPrompt(o) {
121
+ return `QA CHECK — round ${o.round}. This task is a check, not a change: nothing you edit here lands.
122
+ The team just built this goal, and every planned task has merged into main:
123
+ GOAL: ${o.goal}
124
+
125
+ Your current directory is a fresh checkout of the integrated main.${o.author ? "" : " You did NOT write this code — look at it with fresh eyes."}
126
+
127
+ WHAT WAS MERGED (task · agent):
128
+ ${o.tasks || "(none)"}
129
+
130
+ FILES CHANGED:
131
+ ${o.stat || "(none)"}
132
+
133
+ THE DIFF (capped):
134
+ ${o.diff || "(empty)"}
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
+
138
+ DO, in order (huddle_progress after each step):
139
+ 1. Run the full test suite: ${o.testCmd ? `\`${o.testCmd}\`` : "(no test command detected — look for one, or skip)"}.
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
144
+ 3. Read the diff for flaws: bugs, modules from different agents that don't fit together, missing requirements, crashes.
145
+ Start with the tasks marked NOT reviewed above: nobody but their author has read that code.
146
+ 4. For EACH concrete, reproducible defect: huddle_report_bug(title, repro, expected, actual, files).
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),
148
+ then pass its path as test_file — the fix task gets it. Don't fix the bug yourself.
149
+ - Only real defects, at most ${o.maxBugs}, most serious first. No style nits, no "nice to have".
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.`;
152
+ }
153
+ export async function runQa(o) {
154
+ const { db, room, repoRoot, signal, onEvent } = o;
155
+ const rounds = o.rounds ?? QA_ROUNDS;
156
+ const maxBugs = o.maxBugs ?? QA_MAX_BUGS;
157
+ const result = { rounds: 0, bugs: 0, fixed: 0, failed: 0, clean: false, stopped: false };
158
+ const filed = [];
159
+ const used = new Map(); // QA agent -> rounds it checked (rotation)
160
+ const qaTasks = new Set(); // QA's own board tasks, never fix tasks
161
+ const local = o.workers.filter((w) => w.adapter);
162
+ if (!local.length) {
163
+ onEvent({ type: "skipped", reason: "no agent on this machine can run QA" });
164
+ return result;
165
+ }
166
+ // Who checks this round: someone who didn't write the code, rotating; an
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();
170
+ const pick = () => {
171
+ const wrote = authorsSince(repoRoot, o.startHead);
172
+ const others = local.filter((w) => !secondOpinion.has(w.id));
173
+ return (others.length ? others : local)
174
+ .map((w, i) => ({ w, i, author: wrote.has(w.id), n: wrote.get(w.id) ?? 0, u: used.get(w.id) ?? 0 }))
175
+ .sort((a, b) => Number(a.author) - Number(b.author) || a.u - b.u || a.n - b.n || a.i - b.i)
176
+ .slice(0, Math.max(1, o.perRound ?? 1))
177
+ .map(({ w, author }) => ({ w, author }));
178
+ };
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
184
+ let round = 0;
185
+ let roundBugs = 0;
186
+ let finished = 0; // checks in this round that ran to the end
187
+ /** huddle_report_bug while a round runs: validate, dedupe, cap, file a routed fix task. */
188
+ const onBug = (raw, from) => {
189
+ const title = capWords(String(raw.title ?? "").trim().replace(/\s+/g, " "), 120);
190
+ const repro = str(raw.repro, 2000);
191
+ if (!title || !repro)
192
+ return { ok: false, error: "title and repro are required — exact steps or a command that shows the bug" };
193
+ const files = (Array.isArray(raw.files) ? raw.files : []).map((f) => str(f, 300).replace(/^\.\//, "")).filter(Boolean).slice(0, 20);
194
+ const me = { title, files };
195
+ // Same as one already filed: in this round, or an earlier one whose fix hasn't landed.
196
+ const dup = filed.find((b) => sameBug(b, me) && (b.round === round || db.getTask(b.task)?.status !== "merged"));
197
+ if (dup) {
198
+ db.publish("qa.duplicate", { agent_id: from, task_id: dup.task, payload: { round, by: from, title, of: dup.task } });
199
+ onEvent({ type: "duplicate", agent: from, round, title, of: dup.title });
200
+ return { ok: true, duplicate_of: dup.task, note: `Already filed as "${dup.title}" — not filed again.` };
201
+ }
202
+ if (roundBugs >= maxBugs)
203
+ return { ok: false, error: `this round already filed ${maxBugs} bugs (the cap) — finish with huddle_done` };
204
+ const reopened = filed.find((b) => sameBug(b, me));
205
+ // Regression test: the code, or the file the QA agent wrote in its worktree.
206
+ let testFile = raw.test_file ? str(raw.test_file, 300).replace(/^\.\//, "") : "";
207
+ let testCode = raw.test_code ? String(raw.test_code).slice(0, 20_000) : "";
208
+ const wt = worktrees.get(from);
209
+ if (testFile && !testCode && wt) {
210
+ const abs = path.resolve(wt, testFile);
211
+ if (abs.startsWith(wt + path.sep) && fs.existsSync(abs))
212
+ testCode = fs.readFileSync(abs, "utf8").slice(0, 20_000);
213
+ }
214
+ if (testCode && !testFile)
215
+ testFile = "(pick a path in this repo's test style)";
216
+ // Route to the author of most of these files; nobody known = any free agent.
217
+ const counts = new Map();
218
+ for (const a of authorsOf(repoRoot, o.startHead, files).values())
219
+ counts.set(a, (counts.get(a) ?? 0) + 1);
220
+ const assignee = [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
221
+ const spec = [
222
+ `Bug found by ${from} in QA round ${round} of the goal: ${cap(o.goal.replace(/\s+/g, " "), 300)}`,
223
+ reopened ? `(Reported again after the earlier fix "${reopened.title}" merged — it is still broken.)` : "",
224
+ `REPRO: ${repro}`,
225
+ `EXPECTED: ${str(raw.expected, 1000)}`,
226
+ `ACTUAL: ${str(raw.actual, 1500)}`,
227
+ files.length ? `WHERE: ${files.join(", ")}` : "",
228
+ testCode
229
+ ? `REGRESSION TEST (from QA — it fails on main today): add it as ${testFile} first, then fix the code so it and the whole suite pass:\n${testCode}`
230
+ : "Add a regression test that fails before your fix and passes after it, in this repo's test style.",
231
+ "Fix the root cause, not the symptom. If the report is wrong (it already works), show that with a passing test and say so in huddle_done.",
232
+ ]
233
+ .filter(Boolean)
234
+ .join("\n");
235
+ const task = db.addTask({
236
+ title: capWords(`Fix: ${title}`, 120),
237
+ spec,
238
+ prefers: ["quickfix", ...(assignee ? [`@${assignee}`] : [])],
239
+ files: [...files, ...(testCode && !testFile.startsWith("(") ? [testFile] : [])],
240
+ });
241
+ filed.push({ task: task.id, title, files, round, by: from });
242
+ roundBugs++;
243
+ result.bugs++;
244
+ db.remember({ kind: "gotcha", body: `QA (${from}) found: ${title} — repro: ${cap(repro, 300)}`, why: "filed as a fix task", author: from });
245
+ db.publish("qa.bug", {
246
+ agent_id: from,
247
+ task_id: task.id,
248
+ payload: { round, by: from, title, repro: cap(repro, 500), files, assigned_to: assignee, test: !!testCode, reopened: !!reopened },
249
+ });
250
+ onEvent({ type: "bug", agent: from, round, title, task: task.id, assignedTo: assignee, files, test: !!testCode });
251
+ return {
252
+ ok: true,
253
+ task_id: task.id,
254
+ assigned_to: assignee ?? "any free agent",
255
+ note: `Filed as fix task ${task.id}${assignee ? `, routed to ${assignee} (wrote ${files.join(", ")})` : ""}. Keep checking; huddle_done when finished.`,
256
+ };
257
+ };
258
+ /** One QA agent checks main in a throwaway worktree. */
259
+ const check = (w, author, prompt) => new Promise((resolve) => {
260
+ const task = db.addTask({ title: `QA round ${round}: check the build`, spec: prompt, prefers: ["qa"] });
261
+ qaTasks.add(task.id);
262
+ db.claimTask(task.id, w.id); // claimed at once, so the fix board never picks it up
263
+ let wt;
264
+ let h;
265
+ try {
266
+ wt = createWorktree(repoRoot, w.id, task.id); // fresh off the integrated main
267
+ h = launchAgent({
268
+ repoRoot,
269
+ adapter: w.adapter,
270
+ agentId: w.id,
271
+ role: "worker", // it runs the suite and the app, not just reads
272
+ taskId: task.id,
273
+ taskSpec: `${task.title}\n\n${prompt}`,
274
+ roomUrl: room.url,
275
+ roomSecret: room.secret,
276
+ approvals: o.approvals,
277
+ worktree: wt,
278
+ });
279
+ }
280
+ catch (e) {
281
+ db.setTaskStatus(task.id, "failed");
282
+ db.setAgentStatus(w.id, "idle");
283
+ onEvent({ type: "checked", agent: w.id, round, ok: false, bugs: 0, ms: 0, tokens: 0, reason: `couldn't start: ${String(e?.message ?? e).slice(0, 200)}` });
284
+ return resolve();
285
+ }
286
+ worktrees.set(w.id, wt.path);
287
+ const before = roundBugsBy(w.id);
288
+ db.setTaskStatus(task.id, "in_progress");
289
+ db.setAgentStatus(w.id, "working", wt.path);
290
+ onEvent({ type: "start", agent: w.id, round, task: task.id, log: h.logPath, author });
291
+ let over = false;
292
+ const kill = () => stopChild(h.child);
293
+ const timer = setTimeout(kill, o.timeoutMs ?? QA_TIMEOUT_MS);
294
+ signal?.addEventListener("abort", kill);
295
+ const end = (code) => {
296
+ if (over)
297
+ return;
298
+ over = true;
299
+ clearTimeout(timer);
300
+ signal?.removeEventListener("abort", kill);
301
+ const ms = Date.now() - h.startedAt;
302
+ db.releaseClaims(w.id);
303
+ db.setAgentStatus(w.id, "idle");
304
+ // "merged" = the check finished (a QA task lands nothing); "failed" = it didn't.
305
+ db.setTaskStatus(task.id, code === 0 ? "merged" : "failed");
306
+ removeWorktree(repoRoot, wt.path);
307
+ git(repoRoot, ["branch", "-D", wt.branch]);
308
+ worktrees.delete(w.id);
309
+ const bugs = roundBugsBy(w.id) - before;
310
+ const reason = code === 0 ? undefined : signal?.aborted ? "stopped by you" : `exited with code ${code}`;
311
+ if (code === 0) {
312
+ finished++;
313
+ slowest = Math.max(slowest, ms);
314
+ }
315
+ db.publish("qa.done", { agent_id: w.id, task_id: task.id, payload: { round, ok: code === 0, bugs, ms } });
316
+ onEvent({ type: "checked", agent: w.id, round, ok: code === 0, bugs, ms, tokens: h.meter.tokens, reason, task: task.id });
317
+ resolve();
318
+ };
319
+ // Its last stdout lines (token usage) can still be in the pipe at 'exit'.
320
+ onFinished(h.child, end);
321
+ });
322
+ const roundBugsBy = (id) => filed.filter((b) => b.round === round && b.by === id).length;
323
+ const prev = room.bugs;
324
+ try {
325
+ for (round = 1; round <= rounds && !signal?.aborted; round++) {
326
+ roundBugs = 0;
327
+ finished = 0;
328
+ slowest = 0;
329
+ result.rounds = round;
330
+ const who = pick();
331
+ secondOpinion = new Set();
332
+ who.forEach(({ w }) => used.set(w.id, (used.get(w.id) ?? 0) + 1));
333
+ // authors: checkers who also wrote some of it (everyone did), so the story doesn't claim fresh eyes.
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) } });
335
+ onEvent({ type: "round", round, agents: who.map((x) => x.w.id) });
336
+ const reviewers = reviewersOf(db);
337
+ const tasks = db
338
+ .listTasks()
339
+ .filter((t) => t.status === "merged" && !qaTasks.has(t.id))
340
+ .map((t) => {
341
+ const by = reviewers.get(t.id);
342
+ return `- ${t.title} · ${t.claimed_by ?? "?"} · ${by ? `reviewed by ${by}` : "NOT reviewed (merged on tests alone)"}`;
343
+ })
344
+ .join("\n");
345
+ const stat = git(repoRoot, ["diff", "--stat", `${o.startHead}..HEAD`]);
346
+ const diff = cap(git(repoRoot, ["diff", "--no-color", `${o.startHead}..HEAD`]), 20_000);
347
+ room.bugs = onBug;
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 }))));
349
+ room.bugs = prev;
350
+ if (signal?.aborted)
351
+ break;
352
+ // Found live: a QA agent that hung until it was killed filed no bugs, and
353
+ // the round was reported "clean". No finished check = not checked; the
354
+ // next round (another agent, by rotation) checks again.
355
+ if (!roundBugs && !finished) {
356
+ const reason = "no QA check finished";
357
+ db.publish("qa.unchecked", { payload: { round, reason } });
358
+ onEvent({ type: "unchecked", round, reason });
359
+ continue;
360
+ }
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
+ }
373
+ db.publish("qa.clean", { payload: { round } });
374
+ onEvent({ type: "clean", round });
375
+ result.clean = true;
376
+ break;
377
+ }
378
+ const fx = await o.runFixes();
379
+ result.fixed += fx.merged;
380
+ result.failed += fx.failed;
381
+ db.publish("qa.fixes", { payload: { round, merged: fx.merged, failed: fx.failed } });
382
+ onEvent({ type: "fixes", round, merged: fx.merged, failed: fx.failed });
383
+ if (fx.stopped)
384
+ break;
385
+ if (round === rounds) {
386
+ const open = filed.filter((b) => db.getTask(b.task)?.status !== "merged").length;
387
+ db.publish("qa.capped", { payload: { round, open } });
388
+ onEvent({ type: "capped", round, open });
389
+ }
390
+ }
391
+ }
392
+ finally {
393
+ room.bugs = prev;
394
+ }
395
+ result.stopped = !!signal?.aborted;
396
+ return result;
397
+ }
398
+ //# sourceMappingURL=qa.js.map