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.
- package/CHANGELOG.md +69 -0
- package/QUICKSTART.md +135 -0
- package/README.md +26 -4
- package/dist/adapters/amp.js +1 -1
- package/dist/adapters/antigravity.js +2 -0
- package/dist/adapters/claude.js +19 -3
- package/dist/adapters/codex.js +9 -1
- package/dist/adapters/cursor.js +12 -5
- package/dist/adapters/custom.js +4 -2
- package/dist/adapters/droid.js +1 -0
- package/dist/adapters/goose.js +2 -0
- package/dist/adapters/index.js +44 -18
- package/dist/adapters/kiro.js +1 -1
- package/dist/adapters/opencode.js +1 -1
- package/dist/app/live.js +142 -0
- package/dist/app/page.js +701 -0
- package/dist/app/server.js +262 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +7 -4
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +21 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +134 -42
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +57 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +16 -4
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +434 -87
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +31 -13
- package/dist/orchestrator/learn.js +101 -0
- package/dist/orchestrator/planner.js +54 -9
- package/dist/orchestrator/qa.js +71 -15
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +575 -50
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +377 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +79 -16
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +2 -2
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +75 -20
- package/dist/room/mail.js +225 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +220 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/room/user.js +54 -0
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +963 -159
- package/dist/ui/brief.js +494 -0
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +269 -55
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/export.js +1194 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +11 -5
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +716 -94
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +182 -16
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/watch.js +149 -48
- package/package.json +6 -3
package/dist/orchestrator/qa.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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.
|
|
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):
|
|
125
|
-
Don't commit. If everything works, say so —
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
285
|
-
h.child
|
|
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;
|