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.
- package/CHANGELOG.md +31 -0
- package/QUICKSTART.md +102 -0
- package/README.md +23 -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 +6 -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/attach/connect.js +4 -1
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +14 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +108 -40
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +38 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +13 -3
- package/dist/orchestrator/council.js +427 -86
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +29 -13
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +25 -7
- package/dist/orchestrator/qa.js +62 -14
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +498 -39
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +273 -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 +212 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +169 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +554 -133
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +194 -51
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/lessons.js +81 -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 +666 -69
- package/dist/ui/term.js +151 -16
- package/dist/ui/watch.js +145 -48
- package/package.json +6 -3
package/dist/ui/app.js
CHANGED
|
@@ -15,7 +15,8 @@ import os from "node:os";
|
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import readline from "node:readline/promises";
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
|
-
import { ADAPTERS, probeAsync } from "../adapters/index.js";
|
|
18
|
+
import { ADAPTERS, applySkips, installCommand, noInstallHint, probeAsync } from "../adapters/index.js";
|
|
19
|
+
import { gitFix, runShell, spawnToolSync } from "../platform.js";
|
|
19
20
|
import { RoomDB } from "../room/db.js";
|
|
20
21
|
import { startRoom } from "../room/server.js";
|
|
21
22
|
import { openFunnel, openTunnel, tailscaleIp } from "../room/tunnel.js";
|
|
@@ -23,17 +24,21 @@ import { openRelayLink } from "../room/relaylink.js";
|
|
|
23
24
|
import { canonicalRemote, pullLatest, pushMerged, readRepoConfig, remoteInfo, writeRepoConfig } from "../sandbox/sync.js";
|
|
24
25
|
import { githubRepo, githubUser, isCollaborator } from "../room/identity.js";
|
|
25
26
|
import { isBigGoal, listsOwnTasks, planGoal, seedBoard } from "../orchestrator/planner.js";
|
|
26
|
-
import { councilMembers, councilWanted, headline, recordCouncil, runCouncil } from "../orchestrator/council.js";
|
|
27
|
+
import { councilMembers, councilWanted, headline, pastSyntheses, recordCouncil, runCouncil } from "../orchestrator/council.js";
|
|
28
|
+
import { contractFile } from "../room/contract.js";
|
|
27
29
|
import { pickAsker } from "../orchestrator/ask.js";
|
|
28
30
|
import { brainFrom, brainOptions, ollamaModels } from "../orchestrator/brain.js";
|
|
29
31
|
import { fmtDuration, fmtTokens } from "../adapters/usage.js";
|
|
30
32
|
import { runBoard } from "../orchestrator/scheduler.js";
|
|
31
33
|
import { runQa, shouldQa } from "../orchestrator/qa.js";
|
|
32
|
-
import { isGitRepo, listWorktrees, removeWorktree, worktreeFor } from "../sandbox/worktree.js";
|
|
33
|
-
import { C, LiveLines, box, dot, pad, truncate } from "./term.js";
|
|
34
|
+
import { commitFile, isGitRepo, listWorktrees, removeWorktree, worktreeFor } from "../sandbox/worktree.js";
|
|
35
|
+
import { C, Dock, LiveLines, box, dot, fit, lineWidth, pad, truncate, width, wrap } from "./term.js";
|
|
34
36
|
import { AGENTS_FILE_ERRORS, LEGACY } from "../adapters/index.js";
|
|
35
37
|
import { logoLines } from "./logo.js";
|
|
36
38
|
import { Dashboard } from "./dashboard.js";
|
|
39
|
+
import { learnFromRun } from "../orchestrator/learn.js";
|
|
40
|
+
import { PasteCatcher, addPaths, asDrop, bracketedPaste, ctxCommand, flatten, interceptStdin, isLongPaste, looksLikePaths, pasteToScreen, screenEvent } from "./paste.js";
|
|
41
|
+
import { splitPathTokens } from "../room/context.js";
|
|
37
42
|
// 0 = pick a free port per run, so sessions never collide.
|
|
38
43
|
export const ROOM_PORT = Number(process.env.HUDDLE_ROOM_PORT ?? 0);
|
|
39
44
|
// ---- paths & repo ----------------------------------------------------------
|
|
@@ -80,8 +85,8 @@ export function detectTestCmd(root) {
|
|
|
80
85
|
return null;
|
|
81
86
|
}
|
|
82
87
|
/**
|
|
83
|
-
* The brain to use: your pick if it's usable, else
|
|
84
|
-
*
|
|
88
|
+
* The brain to use: your pick if it's usable, else Claude Opus (the strongest
|
|
89
|
+
* plans โ the brain writes every task spec), else whichever agent can answer read-only.
|
|
85
90
|
*/
|
|
86
91
|
export function resolveBrain(results, probed = results) {
|
|
87
92
|
const ready = results.filter((r) => r.state === "ready").map((r) => r.adapter.id);
|
|
@@ -95,23 +100,46 @@ export function resolveBrain(results, probed = results) {
|
|
|
95
100
|
return b;
|
|
96
101
|
}
|
|
97
102
|
if (ready.includes("claude"))
|
|
98
|
-
return brainFrom("claude:
|
|
103
|
+
return brainFrom("claude:opus");
|
|
99
104
|
const asker = pickAsker(results.filter((r) => r.state === "ready").map((r) => r.adapter));
|
|
100
105
|
return asker ? brainFrom(asker.id) : null;
|
|
101
106
|
}
|
|
102
107
|
// HUDDLE_HOME lets two Huddle identities share one machine (tests, demos).
|
|
103
|
-
|
|
108
|
+
// (Read per call, so a test can point HUDDLE_HOME somewhere else.)
|
|
109
|
+
const prefsFile = () => path.join(process.env.HUDDLE_HOME ?? path.join(os.homedir(), ".huddle"), "prefs.json");
|
|
104
110
|
export function readPrefs() {
|
|
105
111
|
try {
|
|
106
|
-
return JSON.parse(fs.readFileSync(
|
|
112
|
+
return JSON.parse(fs.readFileSync(prefsFile(), "utf8"));
|
|
107
113
|
}
|
|
108
114
|
catch {
|
|
109
115
|
return {};
|
|
110
116
|
}
|
|
111
117
|
}
|
|
112
118
|
function writePrefs(p) {
|
|
113
|
-
fs.mkdirSync(path.dirname(
|
|
114
|
-
fs.writeFileSync(
|
|
119
|
+
fs.mkdirSync(path.dirname(prefsFile()), { recursive: true });
|
|
120
|
+
fs.writeFileSync(prefsFile(), JSON.stringify(p, null, 2));
|
|
121
|
+
}
|
|
122
|
+
/** The agents you skipped (`huddle agents skip <id>`). */
|
|
123
|
+
export function skippedAgents() {
|
|
124
|
+
return new Set(readPrefs().skip ?? []);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Use or skip agents, remembered in prefs. Using one also forgets an old
|
|
128
|
+
* "don't install" answer, so Huddle offers it again.
|
|
129
|
+
*/
|
|
130
|
+
export function setAgentChoice(ids, use) {
|
|
131
|
+
const p = readPrefs();
|
|
132
|
+
const skip = new Set(p.skip ?? []);
|
|
133
|
+
const declined = new Set(p.declinedInstall ?? []);
|
|
134
|
+
for (const id of ids) {
|
|
135
|
+
if (use) {
|
|
136
|
+
skip.delete(id);
|
|
137
|
+
declined.delete(id);
|
|
138
|
+
}
|
|
139
|
+
else
|
|
140
|
+
skip.add(id);
|
|
141
|
+
}
|
|
142
|
+
writePrefs({ ...p, skip: [...skip], declinedInstall: [...declined] });
|
|
115
143
|
}
|
|
116
144
|
// ---- welcome ---------------------------------------------------------------
|
|
117
145
|
function tildify(p) {
|
|
@@ -167,16 +195,24 @@ export async function connectAgents() {
|
|
|
167
195
|
return ` ${C.green("โ")} ${head} ready`;
|
|
168
196
|
if (r.state === "not_installed")
|
|
169
197
|
return ` ${C.faint("โ")} ${head} ${C.faint("not installed")}`;
|
|
198
|
+
if (r.state === "skipped")
|
|
199
|
+
return ` ${C.faint("โ")} ${head} ${C.faint(r.detail)}`;
|
|
170
200
|
return ` ${C.yellow("!")} ${head} ${C.yellow("not signed in")} ${C.faint("โ " + r.detail)}`;
|
|
171
201
|
};
|
|
202
|
+
const skip = skippedAgents();
|
|
203
|
+
const probeOne = async (a) => applySkips([await probeAsync(a)], skip)[0];
|
|
172
204
|
// Probe legacy CLIs first (silently) so they only appear when installed.
|
|
173
205
|
for (const a of ADAPTERS.filter((a) => LEGACY.has(a.id)))
|
|
174
|
-
results.set(a.id, await
|
|
175
|
-
const
|
|
206
|
+
results.set(a.id, await probeOne(a));
|
|
207
|
+
const shownLegacy = (a) => {
|
|
208
|
+
const r = results.get(a.id);
|
|
209
|
+
return r?.state === "ready" || (r?.state === "skipped" && r.was === "ready");
|
|
210
|
+
};
|
|
211
|
+
const visible = ADAPTERS.filter((a) => shown(a) || (LEGACY.has(a.id) && shownLegacy(a)));
|
|
176
212
|
const live = new LiveLines((spin) => visible.map((a) => row(a, spin))).start();
|
|
177
213
|
await Promise.all(visible.map(async (a) => {
|
|
178
214
|
if (!results.has(a.id))
|
|
179
|
-
results.set(a.id, await
|
|
215
|
+
results.set(a.id, await probeOne(a));
|
|
180
216
|
live.draw();
|
|
181
217
|
}));
|
|
182
218
|
live.stop();
|
|
@@ -199,21 +235,52 @@ async function yes(rl, q, def = true) {
|
|
|
199
235
|
questionHooks.after();
|
|
200
236
|
}
|
|
201
237
|
}
|
|
202
|
-
/**
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
rl
|
|
238
|
+
/**
|
|
239
|
+
* Run a command with the terminal handed over (installers, logins). A string
|
|
240
|
+
* is a shell line โ sh, or PowerShell on Windows (`irm โฆ | iex`); an array is
|
|
241
|
+
* a CLI, spawned without a shell.
|
|
242
|
+
*/
|
|
243
|
+
export function runInteractive(rl, cmd) {
|
|
244
|
+
rl?.pause();
|
|
245
|
+
bracketedPaste(false); // the other program gets a plain terminal
|
|
246
|
+
const r = typeof cmd === "string" ? runShell(cmd) : spawnToolSync(cmd, { stdio: "inherit" });
|
|
247
|
+
bracketedPaste(true);
|
|
248
|
+
rl?.resume();
|
|
209
249
|
return r.status === 0;
|
|
210
250
|
}
|
|
251
|
+
function rlIO(rl) {
|
|
252
|
+
return { ask: (q, def) => yes(rl, q, def), run: (cmd) => runInteractive(rl, cmd), print: (l = "") => console.log(l) };
|
|
253
|
+
}
|
|
254
|
+
/** Install one agent's CLI with this platform's command. False if it failed or there's none. */
|
|
255
|
+
export function installAgent(io, a) {
|
|
256
|
+
const cmd = installCommand(a);
|
|
257
|
+
if (!cmd) {
|
|
258
|
+
io.print(C.yellow(` ${a.id}: `) + C.faint(noInstallHint(a)));
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
io.print(C.violet(`\nโ installing ${a.id}โฆ`) + C.faint(` $ ${cmd}`));
|
|
262
|
+
const ok = io.run(cmd);
|
|
263
|
+
io.print(ok ? C.green(`โ ${a.id} installed`) : C.red(`โ ${a.id} install failed โ see output above`));
|
|
264
|
+
return ok;
|
|
265
|
+
}
|
|
266
|
+
/** Hand the terminal to an agent's own sign-in. False if it has no login command. */
|
|
267
|
+
export function signIn(io, a) {
|
|
268
|
+
if (!a.install.login)
|
|
269
|
+
return false;
|
|
270
|
+
if (a.install.loginNote)
|
|
271
|
+
io.print(C.faint(` ${a.install.loginNote}`));
|
|
272
|
+
io.run(a.install.login);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
/** The install line to show for an agent on this platform. */
|
|
276
|
+
export const installLine = (a) => installCommand(a) ?? noInstallHint(a);
|
|
211
277
|
/**
|
|
212
278
|
* Offer to install missing agents and sign in to logged-out ones โ always with
|
|
213
279
|
* the user's OK. A "no" is remembered so we don't nag on every launch; the
|
|
214
280
|
* `agents` command asks again.
|
|
215
281
|
*/
|
|
216
282
|
export async function offerSetup(rl, results, force = false) {
|
|
283
|
+
const io = rlIO(rl);
|
|
217
284
|
const prefs = readPrefs();
|
|
218
285
|
const declined = new Set(force ? [] : (prefs.declinedInstall ?? []));
|
|
219
286
|
const missing = results.filter((r) => r.state === "not_installed" && !declined.has(r.adapter.id) && !LEGACY.has(r.adapter.id));
|
|
@@ -222,19 +289,14 @@ export async function offerSetup(rl, results, force = false) {
|
|
|
222
289
|
if (missing.length) {
|
|
223
290
|
console.log();
|
|
224
291
|
for (const r of missing)
|
|
225
|
-
console.log(` ${dot(r.adapter.id)} ${C.bold(r.adapter.id)} ${C.faint("$ " + r.adapter.
|
|
292
|
+
console.log(` ${dot(r.adapter.id)} ${C.bold(r.adapter.id)} ${C.faint(installCommand(r.adapter) ? "$ " + installLine(r.adapter) : installLine(r.adapter))}`);
|
|
226
293
|
const names = missing.map((r) => r.adapter.id).join(", ");
|
|
227
294
|
if (await yes(rl, `\nInstall ${names}?`)) {
|
|
228
295
|
for (const r of missing) {
|
|
229
|
-
|
|
230
|
-
const ok = runInteractive(rl, r.adapter.install.cmd);
|
|
231
|
-
console.log(ok ? C.green(`โ ${r.adapter.id} installed`) : C.red(`โ ${r.adapter.id} install failed โ see output above`));
|
|
296
|
+
const ok = installAgent(io, r.adapter);
|
|
232
297
|
changed ||= ok;
|
|
233
|
-
if (ok && r.adapter.install.login && (await yes(rl, `Sign in to ${r.adapter.id} now?`)))
|
|
234
|
-
|
|
235
|
-
console.log(C.faint(` ${r.adapter.install.loginNote}`));
|
|
236
|
-
runInteractive(rl, r.adapter.install.login);
|
|
237
|
-
}
|
|
298
|
+
if (ok && r.adapter.install.login && (await yes(rl, `Sign in to ${r.adapter.id} now?`)))
|
|
299
|
+
signIn(io, r.adapter);
|
|
238
300
|
}
|
|
239
301
|
}
|
|
240
302
|
else {
|
|
@@ -247,9 +309,7 @@ export async function offerSetup(rl, results, force = false) {
|
|
|
247
309
|
if (!login)
|
|
248
310
|
continue;
|
|
249
311
|
if (await yes(rl, `\n${r.adapter.id} isn't signed in. Sign in now?`)) {
|
|
250
|
-
|
|
251
|
-
console.log(C.faint(` ${r.adapter.install.loginNote}`));
|
|
252
|
-
runInteractive(rl, login);
|
|
312
|
+
signIn(io, r.adapter);
|
|
253
313
|
changed = true;
|
|
254
314
|
}
|
|
255
315
|
else {
|
|
@@ -259,6 +319,29 @@ export async function offerSetup(rl, results, force = false) {
|
|
|
259
319
|
}
|
|
260
320
|
return changed;
|
|
261
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* The live status block under the run's output, one line per agent:
|
|
324
|
+
* โ claude 60% Build the CLI adapter ยท note 20s ago ยท 1m12s
|
|
325
|
+
* โ cursor idle ยท 2 done
|
|
326
|
+
* Every line fits `W` cells: the title gives way, never the numbers.
|
|
327
|
+
*/
|
|
328
|
+
export function statusLines(stats, W, now = Date.now()) {
|
|
329
|
+
const idW = Math.min(12, Math.max(6, ...[...stats.agents.keys()].map((id) => id.length)));
|
|
330
|
+
return [...stats.agents].map(([id, a]) => {
|
|
331
|
+
const head = ` ${a.busySince ? dot(id) : C.faint("โ")} ${pad(id, idW)} `;
|
|
332
|
+
if (!a.busySince) {
|
|
333
|
+
const done = a.tasks + a.reviews + (a.qa ?? 0);
|
|
334
|
+
return head + C.faint(done ? `idle ยท ${a.tasks} merged${a.reviews ? `, ${a.reviews} reviewed` : ""}` : "idle");
|
|
335
|
+
}
|
|
336
|
+
const pct = a.pct === undefined ? C.faint(" ยท ") : C.violet(`${String(a.pct).padStart(3)}%`);
|
|
337
|
+
const quiet = a.noteAt === undefined ? now - a.busySince : now - a.noteAt;
|
|
338
|
+
const said = a.noteAt === undefined ? "no note yet" : `note ${fmtDuration(now - a.noteAt)} ago`;
|
|
339
|
+
const tail = (quiet > 120_000 ? C.yellow(` ยท ${said}`) : C.faint(` ยท ${said}`)) +
|
|
340
|
+
C.faint(` ยท ${fmtDuration(now - a.busySince)}${a.liveTokens > 0 ? ` ยท ${fmtTokens(a.liveTokens)} tok` : ""}`);
|
|
341
|
+
const room = Math.max(8, W - width(head) - 5 - width(tail));
|
|
342
|
+
return `${head}${pct} ${pad(truncate(a.doing ?? "working", room), Math.min(room, 34))}${tail}`;
|
|
343
|
+
});
|
|
344
|
+
}
|
|
262
345
|
export function newStats(workers) {
|
|
263
346
|
return {
|
|
264
347
|
startedAt: Date.now(),
|
|
@@ -340,7 +423,7 @@ async function prepareRepo(print, rl, goal = "") {
|
|
|
340
423
|
if (!(await yes(rl, " Set it up? (git init + commit your current files)")))
|
|
341
424
|
return false;
|
|
342
425
|
if (spawnSync("git", ["--version"]).status !== 0) {
|
|
343
|
-
print(C.red(" โ git isn't installed.") + C.faint("
|
|
426
|
+
print(C.red(" โ git isn't installed.") + C.faint(" " + gitFix()));
|
|
344
427
|
return false;
|
|
345
428
|
}
|
|
346
429
|
const gi = path.join(process.cwd(), ".gitignore");
|
|
@@ -358,7 +441,7 @@ async function prepareRepo(print, rl, goal = "") {
|
|
|
358
441
|
print(` ${C.green("โ")} git repo ready`);
|
|
359
442
|
}
|
|
360
443
|
ensureInit();
|
|
361
|
-
const dirty = git(["status", "--porcelain"]).out.split(
|
|
444
|
+
const dirty = git(["status", "--porcelain"]).out.split(/\r?\n/).filter((l) => l && !l.includes(".huddle/"));
|
|
362
445
|
if (dirty.length) {
|
|
363
446
|
if (!rl) {
|
|
364
447
|
print(C.yellow(`! ${dirty.length} uncommitted change(s) โ agents only see committed files.`));
|
|
@@ -423,23 +506,41 @@ export function likelyAgents(tasks, workers) {
|
|
|
423
506
|
}
|
|
424
507
|
return out;
|
|
425
508
|
}
|
|
509
|
+
/** Room left on a line once its fixed parts are drawn: the terminal's width, or 120 in a log. */
|
|
510
|
+
const rawSpace = (...used) => (lineWidth() ?? 120) - used.reduce((n, u) => n + width(u), 0);
|
|
511
|
+
const space = (...used) => Math.max(24, rawSpace(...used));
|
|
512
|
+
/**
|
|
513
|
+
* A phase header. Every run reads the same way, top to bottom:
|
|
514
|
+
* โโ COUNCIL โโ โ โโ PLAN โโ โ โโ BUILD โโ โ โโ QA โโ โ โโ RESULT โโ
|
|
515
|
+
*/
|
|
516
|
+
function phase(print, name, sub = "") {
|
|
517
|
+
const head = `${C.violet("โโ")} ${C.bold(C.violet(name))} ${C.violet("โโ")}`;
|
|
518
|
+
// Cut to the terminal; a log keeps the whole line.
|
|
519
|
+
const rest = sub ? " " + C.faint(lineWidth() ? oneLine(sub, space(head, " ")) : sub) : "";
|
|
520
|
+
print(`\n${head}${rest}`);
|
|
521
|
+
}
|
|
426
522
|
function printPlan(print, goal, plan, workers) {
|
|
427
523
|
const took = plan.ms ? ` in ${fmtDuration(plan.ms)}` : "";
|
|
428
|
-
print
|
|
524
|
+
phase(print, "PLAN", `${plan.tasks.length} task(s) ยท by ${plan.by}${took}${plan.note ? " โ " + plan.note : ""}`);
|
|
525
|
+
print(` ${C.faint("goal:")} ${oneLine(goal, space(" goal: "))}`);
|
|
429
526
|
const startable = plan.tasks.filter((t) => !t.depends_on?.length).length;
|
|
430
527
|
const likely = likelyAgents(plan.tasks, workers);
|
|
431
528
|
plan.tasks.forEach((t, i) => {
|
|
432
529
|
const after = t.depends_on?.length ? C.faint(` after ${t.depends_on.map((d) => d + 1).join(", ")}`) : "";
|
|
433
530
|
const fit = likely[i];
|
|
434
531
|
const idea = t.from?.length ? C.faint(` ยท idea: ${t.from.join(", ")}`) : "";
|
|
435
|
-
|
|
532
|
+
const who = fit ? ` ${C.faint("โ likely")} ${dot(fit)} ${fit}` : "";
|
|
533
|
+
const num = ` ${C.faint(String(i + 1).padStart(2) + ".")} `;
|
|
534
|
+
print(`${num}${oneLine(t.title, space(num, after, who, idea))}${after}${who}${idea}`);
|
|
436
535
|
});
|
|
437
536
|
print(C.faint(` ${plan.tasks.length} task(s), ${Math.min(startable, workers.length)} of ${workers.length} agents can start right away`));
|
|
438
537
|
// The council's agreements, which every agent can huddle_recall mid-task.
|
|
439
538
|
for (const d of plan.council?.decisions ?? [])
|
|
440
|
-
print(` ${C.violet("โ")} ${C.faint("decided:")} ${oneLine(d,
|
|
441
|
-
for (const r of plan.council?.risks ?? [])
|
|
442
|
-
|
|
539
|
+
print(` ${C.violet("โ")} ${C.faint("decided:")} ${oneLine(d, space(" โ decided: "))}`);
|
|
540
|
+
for (const r of plan.council?.risks ?? []) {
|
|
541
|
+
const label = ` ${C.yellow("!")} ${C.faint("risk" + (r.by ? ` (${r.by})` : "") + ":")} `;
|
|
542
|
+
print(`${label}${oneLine(r.body, space(label))}`);
|
|
543
|
+
}
|
|
443
544
|
}
|
|
444
545
|
/** Show a spinner line while a promise settles (TTY only). */
|
|
445
546
|
async function withSpinner(label, p) {
|
|
@@ -456,7 +557,11 @@ async function withSpinner(label, p) {
|
|
|
456
557
|
}
|
|
457
558
|
}
|
|
458
559
|
export async function runGoal(goal, opts) {
|
|
459
|
-
|
|
560
|
+
// Every line fits the terminal (logs keep everything). In a one-shot run on a
|
|
561
|
+
// terminal, the per-agent status lines sit under the output (the Dock).
|
|
562
|
+
let dock = null;
|
|
563
|
+
const sink = opts.print ?? ((l = "") => (dock ? dock.log(l) : console.log(l)));
|
|
564
|
+
const print = (l = "") => sink(fit(l));
|
|
460
565
|
// Typed at the prompt too: `build a todo app from scratch --no-council`.
|
|
461
566
|
const flagIn = goal.match(/(^|\s)--(no-)?council(?=\s|$)/);
|
|
462
567
|
if (flagIn)
|
|
@@ -485,37 +590,77 @@ export async function runGoal(goal, opts) {
|
|
|
485
590
|
const crew = [...workers, ...remoteNow()].map((w) => ({ id: w.id, prefers: prefersOf(w) })); // one entry per agent, teammates' included
|
|
486
591
|
const members = councilMembers(workers);
|
|
487
592
|
// Files in the repo before the run: an (almost) empty repo makes a goal a project (goalSize in planner.ts).
|
|
488
|
-
const repoFiles = git(["ls-files"], root).out.split(
|
|
593
|
+
const repoFiles = git(["ls-files"], root).out.split(/\r?\n/).filter(Boolean).length;
|
|
489
594
|
const wantCouncil = councilWanted(goal, { flag: councilFlag, members: members.length, repoFiles });
|
|
490
595
|
if (councilFlag === true && !wantCouncil.yes)
|
|
491
596
|
print(C.yellow(` ! no design council: ${wantCouncil.why}`));
|
|
492
597
|
let plan;
|
|
493
598
|
if (wantCouncil.yes) {
|
|
494
|
-
print
|
|
495
|
-
|
|
599
|
+
phase(print, "COUNCIL", `${members.map((m) => m.id).join(", ")} brainstorm before anyone codes ยท ${wantCouncil.why} (--no-council skips)`);
|
|
600
|
+
/** " โ codex adopts claude's: <body, cut to fit> ยท 14s" */
|
|
601
|
+
const say = (agent, label, body = "", took = "") => {
|
|
602
|
+
const head = ` ${dot(agent)} ${pad(agent, 12)} ${label}${body ? " " : ""}`;
|
|
603
|
+
const text = `${label}${body ? " " + oneLine(body, space(head, took)) : ""}${took}`;
|
|
496
604
|
print(` ${dot(agent)} ${pad(agent, 12)} ${text}`);
|
|
497
605
|
opts.onActivity?.({ agent, text });
|
|
498
606
|
};
|
|
499
607
|
const onCouncil = (e) => {
|
|
500
608
|
if (e.type === "round")
|
|
501
|
-
print(C.faint(` ${e.round === "proposal" ? "1/
|
|
502
|
-
if (e.type === "synthesizing")
|
|
503
|
-
|
|
609
|
+
print(C.faint(` ${e.round === "proposal" ? "1/4 each agent proposes a designโฆ" : "2/4 each agent reviews the others: what to adopt, what's flawedโฆ"}`));
|
|
610
|
+
if (e.type === "synthesizing") {
|
|
611
|
+
const head = ` 3/4 ${e.brain} writes the plan and its contract`;
|
|
612
|
+
print(C.faint(`${head}${e.why ? ` ยท ${oneLine(e.why, space(head, " ยท "))}` : "โฆ"}`));
|
|
613
|
+
}
|
|
614
|
+
if (e.type === "ratify")
|
|
615
|
+
print(C.faint(` 4/4 ${e.agents.join(", ")} check ${e.by}'s contractโฆ`));
|
|
616
|
+
if (e.type === "ratified") {
|
|
617
|
+
const r = e.entry;
|
|
618
|
+
const took = C.faint(` ยท ${fmtDuration(r.ms)}`);
|
|
619
|
+
if (r.verdict === "skipped")
|
|
620
|
+
return say(r.agent, C.faint(`ratify skipped (${r.skipped})`), "", took);
|
|
621
|
+
if (r.verdict === "object")
|
|
622
|
+
return say(r.agent, C.yellow(`objects (${r.objections.length}):`), r.objections.join(" ยท "), took);
|
|
623
|
+
return say(r.agent, r.verdict === "ok" ? C.green("โ ok") : C.faint("no clear verdict ยท taken as ok"), "", took);
|
|
624
|
+
}
|
|
625
|
+
if (e.type === "revised")
|
|
626
|
+
print(e.ok ? ` ${C.green("โ")} ${e.by} revised the plan ${C.faint(`ยท ${e.taken} objection(s) taken, ${e.rejected} rejected`)}` : C.faint(` ${e.by} couldn't revise โ the plan stands as ratified`));
|
|
504
627
|
if (e.type !== "entry")
|
|
505
628
|
return;
|
|
506
629
|
const x = e.entry;
|
|
507
630
|
const took = C.faint(` ยท ${fmtDuration(x.ms)}`);
|
|
508
631
|
if (!x.ok)
|
|
509
|
-
return say(x.agent, C.faint(`${x.round} skipped (${x.skipped})`)
|
|
632
|
+
return say(x.agent, C.faint(`${x.round} skipped (${x.skipped})`), "", took);
|
|
510
633
|
if (x.round === "proposal")
|
|
511
|
-
return say(x.agent,
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
if (
|
|
516
|
-
|
|
634
|
+
return say(x.agent, C.faint("proposes:"), headline(x.text, "ARCHITECTURE"), took);
|
|
635
|
+
// A cross-review: what it took from whom, and what it found wrong in whose.
|
|
636
|
+
const adopt = x.adopts?.[0];
|
|
637
|
+
const flawPt = x.flaws?.[0];
|
|
638
|
+
if (!adopt && !flawPt)
|
|
639
|
+
return say(x.agent, C.faint("flags:"), headline(x.text, "FLAWS"), took);
|
|
640
|
+
if (adopt)
|
|
641
|
+
say(x.agent, `${C.green("+ adopts")} ${adopt.of}'s idea:`, adopt.text, took);
|
|
642
|
+
else
|
|
643
|
+
say(x.agent, `${C.yellow("! flaw in")} ${flawPt.of}'s idea:`, flawPt.text, took);
|
|
644
|
+
if (adopt && flawPt) {
|
|
645
|
+
const head = ` ${" ".repeat(15)}${C.yellow("! flaw in")} ${flawPt.of}'s idea: `;
|
|
646
|
+
print(`${head}${oneLine(flawPt.text, space(head))}`);
|
|
647
|
+
}
|
|
517
648
|
};
|
|
518
|
-
|
|
649
|
+
// Who synthesized before in this repo's Room: ties rotate the job.
|
|
650
|
+
let past = {};
|
|
651
|
+
try {
|
|
652
|
+
if (opts.room?.db)
|
|
653
|
+
past = pastSyntheses(opts.room.db);
|
|
654
|
+
else if (fs.existsSync(dbPath())) {
|
|
655
|
+
const d = new RoomDB(dbPath());
|
|
656
|
+
past = pastSyntheses(d);
|
|
657
|
+
d.close();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
/* no history: roster order */
|
|
662
|
+
}
|
|
663
|
+
plan = await runCouncil(goal, crew, members, root, { brain, signal: opts.signal, onEvent: onCouncil, pastSyntheses: past });
|
|
519
664
|
}
|
|
520
665
|
else {
|
|
521
666
|
// A big goal without a council (--no-council, or one agent) still gets a project-sized plan.
|
|
@@ -551,6 +696,9 @@ export async function runGoal(goal, opts) {
|
|
|
551
696
|
const testCmd = detectTestCmd(root);
|
|
552
697
|
const ownRoom = !opts.room;
|
|
553
698
|
const db = opts.room?.db ?? new RoomDB(dbPath());
|
|
699
|
+
// What this run teaches the next one is learned from its own records (orchestrator/learn.ts).
|
|
700
|
+
const runStart = Date.now();
|
|
701
|
+
const seen = [];
|
|
554
702
|
db.clearBoard();
|
|
555
703
|
const room = opts.room?.server ?? (await startRoom(db, ROOM_PORT));
|
|
556
704
|
const taskIds = seedBoard(db, goal, plan.tasks);
|
|
@@ -559,37 +707,86 @@ export async function runGoal(goal, opts) {
|
|
|
559
707
|
// Before the live listener below, so the council's memories aren't printed twice.
|
|
560
708
|
if (plan.council)
|
|
561
709
|
recordCouncil(db, plan.council, plan.tasks, taskIds);
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
"
|
|
710
|
+
// The ratified contract goes on main before any task branches off it, so every worktree has it.
|
|
711
|
+
if (plan.council?.contract) {
|
|
712
|
+
const by = plan.council.synthesizer?.id ?? plan.council.synthesizedBy;
|
|
713
|
+
const ok = commitFile(root, "HUDDLE_CONTRACT.md", contractFile(plan.council.contract, by, plan.council.members), "huddle: the council's contract");
|
|
714
|
+
const objected = plan.council.objections?.length ?? 0;
|
|
715
|
+
print(` ${C.violet("โ")} ${C.faint("contract:")} ${ok ? "HUDDLE_CONTRACT.md on main" : "in the Room (couldn't commit HUDDLE_CONTRACT.md)"} ${C.faint(`ยท by ${by}` + (objected ? ` ยท ${plan.council.objections.filter((o) => o.taken).length}/${objected} objection(s) taken` : ""))}`);
|
|
716
|
+
}
|
|
717
|
+
const t0 = Date.now();
|
|
718
|
+
const stats = newStats([...workers, ...remoteNow()].map((w) => w.id));
|
|
719
|
+
opts.onStats?.(stats);
|
|
720
|
+
phase(print, "BUILD", `${plan.tasks.length} task(s) ยท ${workers.length + remoteNow().length} agent(s) ยท each in its own worktree, merged when tests pass`);
|
|
721
|
+
print(C.faint(` room ${room.url} ยท tests: ${testCmd ?? "none yet"} ยท approvals: ${opts.approvals}`) +
|
|
722
|
+
(rl ? C.faint(" ยท ctrl-t live view ยท ctrl-c stops") : ""));
|
|
723
|
+
print();
|
|
724
|
+
// A one-shot run on a terminal (huddle join): who's on what, pinned under the output.
|
|
725
|
+
// (The prompt app draws the same lines in its footer.)
|
|
726
|
+
if (!opts.print && process.stdout.isTTY)
|
|
727
|
+
dock = new Dock(() => [
|
|
728
|
+
C.faint(` โฑ ${fmtDuration(Date.now() - t0)} ยท ${[...stats.agents.values()].reduce((n, a) => n + a.tasks, 0)} merged so far`),
|
|
729
|
+
...statusLines(stats, (process.stdout.columns || 80) - 1),
|
|
730
|
+
]).start();
|
|
566
731
|
const tag = (agent) => `${dot(agent)} ${pad(agent, 12)}`;
|
|
732
|
+
/** " โ codex <label> <body, cut to fit><suffix>" */
|
|
733
|
+
const line = (agent, label, body = "", suffix = "") => {
|
|
734
|
+
const head = ` ${tag(agent)} ${label}${body ? " " : ""}`;
|
|
735
|
+
// A quoted title keeps its closing quote when it's cut.
|
|
736
|
+
const q = /^โ([\s\S]*)โ$/.exec(body);
|
|
737
|
+
// On a narrow screen a quoted title gives way entirely before the verdict after it does.
|
|
738
|
+
if (q && rawSpace(head, suffix) < 14)
|
|
739
|
+
return ` ${tag(agent)} ${label}${suffix}`;
|
|
740
|
+
const text = q ? `โ${oneLine(q[1], rawSpace(head, suffix) - 2)}โ` : body ? oneLine(body, space(head, suffix)) : "";
|
|
741
|
+
return `${head}${text}${suffix}`;
|
|
742
|
+
};
|
|
743
|
+
/** The parts after a title, longest first: the first that leaves the whole title (or 32 cells of it) wins. */
|
|
744
|
+
const fitting = (agent, label, title, ...suffixes) => suffixes.find((x) => rawSpace(` ${tag(agent)} ${label} `, x) >= Math.min(32, width(title))) ?? suffixes[suffixes.length - 1];
|
|
745
|
+
// Handoffs land just before a task starts: one "ยท read 3 handoffs" on the โธ line, not a line each.
|
|
746
|
+
const handoffs = new Map();
|
|
747
|
+
const sayOne = (agent, label, body = "", suffix = "") => print(line(agent, label, body, suffix));
|
|
748
|
+
/** Up to `n` lines of a teammate's notes, indented under the line that sent them. */
|
|
749
|
+
const notesUnder = (notes, n = 3) => {
|
|
750
|
+
for (const l of notes.split("\n").map((x) => x.replace(/^\s*[-*โข]\s*/, "").trim()).filter(Boolean).slice(0, n))
|
|
751
|
+
print(C.faint(` ${oneLine(l, space(" "))}`));
|
|
752
|
+
};
|
|
753
|
+
let talk = 0; // agent-to-agent messages this run, for the teamwork summary
|
|
567
754
|
const act = (agent, text) => opts.onActivity?.({ agent, text });
|
|
568
755
|
const unsub = db.onEvent((e) => {
|
|
569
756
|
const p = JSON.parse(e.payload || "{}");
|
|
570
757
|
if (e.type === "task.progress" && p.note) {
|
|
571
758
|
const agent = (e.task_id ? db.getTask(e.task_id)?.claimed_by : "") ?? "";
|
|
572
|
-
|
|
759
|
+
const st = stats.agents.get(agent);
|
|
760
|
+
if (st)
|
|
761
|
+
Object.assign(st, { pct: Number(p.pct) || 0, noteAt: Date.now() });
|
|
762
|
+
sayOne(agent, C.faint(String(p.pct).padStart(3) + "%"), p.note);
|
|
573
763
|
act(agent, `${C.faint(String(p.pct).padStart(3) + "%")} ${p.note}`);
|
|
574
764
|
}
|
|
575
765
|
else if (e.type === "memory.added" && p.author !== "huddle") {
|
|
576
|
-
print(` ${C.violet("โ")} ${pad(C.faint("memory"), 12)} ${oneLine(p.body, 100)}`);
|
|
577
766
|
act(undefined, `${C.violet("โ")} ${C.faint(p.author + " remembered:")} ${oneLine(p.body, 200)}`);
|
|
767
|
+
// Already on screen as its own line: a task's done note (โ merged), a QA bug (โ found), a message (a โ b).
|
|
768
|
+
if (/^Task \S+ done:|^QA \(\S+\) found:|^\S+ โ \S+:/.test(String(p.body)))
|
|
769
|
+
return;
|
|
770
|
+
const head = ` ${C.violet("โ")} ${pad(String(p.author ?? "memory"), 12)} ${C.faint("noted:")} `;
|
|
771
|
+
print(`${head}${oneLine(p.body, space(head))}`);
|
|
578
772
|
}
|
|
579
773
|
else if (e.type === "plan.announced" && e.agent_id) {
|
|
580
|
-
|
|
774
|
+
sayOne(e.agent_id, C.faint("plans:"), p.intent ?? "");
|
|
581
775
|
act(e.agent_id, `${C.faint("plans:")} ${p.intent ?? ""}`);
|
|
582
776
|
}
|
|
583
777
|
else if (e.type === "agent.message" && p.kind !== "question") {
|
|
584
778
|
// Agent to agent, live: "codex โ claude: renamed greet() to hello()". (Questions show as asks.)
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
779
|
+
// Huddle's own relay of a buddy check is the โ buddy check line itself.
|
|
780
|
+
if (p.from === "huddle" && /^buddy check from/i.test(String(p.body)))
|
|
781
|
+
return;
|
|
782
|
+
if (p.from !== "huddle")
|
|
783
|
+
talk++;
|
|
784
|
+
const arrow = `${dot(p.from)} ${C.bold(p.from)} ${C.faint("โ")} ${dot(p.to)} ${C.bold(p.to)}${C.faint(":")} `;
|
|
785
|
+
const kind = p.kind === "answer" ? C.faint(" (answer)") : "";
|
|
786
|
+
print(` ${arrow}${oneLine(p.body, space(" ", arrow, kind))}${kind}`);
|
|
787
|
+
act(undefined, `${arrow}${oneLine(p.body, 200)}`);
|
|
588
788
|
}
|
|
589
789
|
});
|
|
590
|
-
const t0 = Date.now();
|
|
591
|
-
const stats = newStats([...workers, ...remoteNow()].map((w) => w.id));
|
|
592
|
-
opts.onStats?.(stats);
|
|
593
790
|
const tok = (tokens, cached) => `${fmtTokens(tokens)} tok` + (cached > tokens / 2 ? ` (${fmtTokens(cached)} cached)` : "");
|
|
594
791
|
const cost = (ms, tokens, cached) => C.faint(` ยท ${fmtDuration(ms)} ยท ${tok(tokens, cached)}`);
|
|
595
792
|
if (remoteNow().length && !(remote && sync))
|
|
@@ -621,6 +818,7 @@ export async function runGoal(goal, opts) {
|
|
|
621
818
|
}
|
|
622
819
|
: undefined,
|
|
623
820
|
onEvent: (e) => {
|
|
821
|
+
seen.push(e);
|
|
624
822
|
// HUDDLE_EVENTS_FILE=<path>: every scheduler event as a JSON line (evals, post-mortems).
|
|
625
823
|
if (process.env.HUDDLE_EVENTS_FILE)
|
|
626
824
|
fs.appendFileSync(process.env.HUDDLE_EVENTS_FILE, JSON.stringify({ ts: Date.now(), ...e }) + "\n");
|
|
@@ -628,11 +826,9 @@ export async function runGoal(goal, opts) {
|
|
|
628
826
|
stats.agents.set(e.agent, { busySince: null, liveTokens: 0, tokens: 0, cached: 0, ms: 0, tasks: 0, reviews: 0 });
|
|
629
827
|
const a = "agent" in e ? stats.agents.get(e.agent) : undefined;
|
|
630
828
|
if (e.type === "start" && a)
|
|
631
|
-
Object.assign(a, { busySince: Date.now(), liveTokens:
|
|
829
|
+
Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: e.title, pct: undefined, noteAt: undefined }); // -1 = not reported yet
|
|
632
830
|
if (e.type === "tokens" && a)
|
|
633
831
|
a.liveTokens = e.tokens;
|
|
634
|
-
if (e.type === "start" && a)
|
|
635
|
-
a.liveTokens = -1; // -1 = not reported yet
|
|
636
832
|
if ((e.type === "merged" || e.type === "failed") && a)
|
|
637
833
|
Object.assign(a, {
|
|
638
834
|
busySince: null,
|
|
@@ -641,19 +837,20 @@ export async function runGoal(goal, opts) {
|
|
|
641
837
|
cached: a.cached + e.cached,
|
|
642
838
|
ms: a.ms + e.ms,
|
|
643
839
|
tasks: a.tasks + 1,
|
|
840
|
+
doing: undefined,
|
|
644
841
|
});
|
|
645
842
|
if (e.type === "review" && a)
|
|
646
|
-
Object.assign(a, { busySince: Date.now(), liveTokens: -1 });
|
|
843
|
+
Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: `review ${e.author}'s โ${e.title}โ`, pct: undefined, noteAt: undefined });
|
|
647
844
|
if (e.type === "reviewed" && a)
|
|
648
|
-
Object.assign(a, { busySince: null, ms: a.ms + e.ms, reviews: a.reviews + 1 });
|
|
845
|
+
Object.assign(a, { busySince: null, ms: a.ms + e.ms, reviews: a.reviews + 1, doing: undefined });
|
|
649
846
|
if (e.type === "takeover") {
|
|
650
847
|
// The author's rounds end here (no merged/failed event for it): count them.
|
|
651
848
|
const f = e.kind === "rescue" ? stats.agents.get(e.from) : undefined;
|
|
652
849
|
if (f)
|
|
653
|
-
Object.assign(f, { busySince: null, liveTokens: 0, tokens: f.tokens + e.tokens, cached: f.cached + e.cached, ms: f.ms + e.ms });
|
|
850
|
+
Object.assign(f, { busySince: null, liveTokens: 0, tokens: f.tokens + e.tokens, cached: f.cached + e.cached, ms: f.ms + e.ms, doing: undefined });
|
|
654
851
|
const what = e.kind === "rescue" ? `took over ${e.from}'s` : `took part of ${e.from}'s task:`;
|
|
655
852
|
act(e.agent, `${C.yellow("โคณ")} ${what} ${e.title} ${C.faint("ยท " + e.reason)}`);
|
|
656
|
-
|
|
853
|
+
sayOne(e.agent, C.yellow("โคณ " + what), `โ${e.title}โ`, C.faint(" ยท " + oneLine(e.reason, 60)));
|
|
657
854
|
}
|
|
658
855
|
if (e.type === "start")
|
|
659
856
|
act(e.agent, `${C.violet("โธ")} ${e.takeover ? `continuing ${e.takeover}'s work: ` : e.fixing ? "fixing after review: " : ""}${e.title}`);
|
|
@@ -665,41 +862,75 @@ export async function runGoal(goal, opts) {
|
|
|
665
862
|
act(e.agent, `${C.violet("โ")} reviewing ${e.author}'s work`);
|
|
666
863
|
if (e.type === "reviewed")
|
|
667
864
|
act(e.agent, e.approve ? C.green("โ approved " + e.author) : C.yellow("โ asked " + e.author + " for changes"));
|
|
668
|
-
if (e.type === "start")
|
|
669
|
-
|
|
670
|
-
|
|
865
|
+
if (e.type === "start") {
|
|
866
|
+
const from = handoffs.get(e.agent) ?? [];
|
|
867
|
+
handoffs.delete(e.agent);
|
|
868
|
+
const label = `${C.violet("โธ")}${e.takeover ? C.yellow(` continuing ${e.takeover}'s work:`) : e.fixing ? C.yellow(" fixing after review:") : ""}`;
|
|
869
|
+
const read = from.length ? ` ยท read ${from.length} handoff${from.length === 1 ? "" : "s"} (${[...new Set(from)].join(", ")})` : "";
|
|
870
|
+
const remote = e.remote ? ` ยท offered to ${e.remote}` : "";
|
|
871
|
+
sayOne(e.agent, label, e.title, C.faint(fitting(e.agent, label, e.title, read + remote, from.length ? ` ยท read ${from.length} handoff(s)${remote}` : remote, "")));
|
|
872
|
+
}
|
|
873
|
+
if (e.type === "contract") {
|
|
874
|
+
act(e.agent, `${C.violet("โ")} amended the contract (v${e.version}): ${e.section}: ${e.change}`);
|
|
875
|
+
sayOne(e.agent, `${C.violet("โ contract v" + e.version)} ${C.faint(e.section + ":")}`, e.change);
|
|
876
|
+
}
|
|
671
877
|
if (e.type === "handoff")
|
|
672
|
-
|
|
878
|
+
handoffs.set(e.agent, [...(handoffs.get(e.agent) ?? []), e.from]);
|
|
673
879
|
if (e.type === "declined")
|
|
674
|
-
|
|
880
|
+
sayOne(e.agent, C.faint("โ declined ยท back on the board"), e.title);
|
|
675
881
|
if (e.type === "review")
|
|
676
|
-
|
|
882
|
+
sayOne(e.agent, `${C.violet("โ reviews")} ${e.author}'s`, `โ${e.title}โ`);
|
|
677
883
|
if (e.type === "reviewed") {
|
|
678
884
|
if (e.skipped)
|
|
679
|
-
|
|
885
|
+
sayOne(e.agent, C.faint("โ review skipped ยท"), e.notes, C.faint(` ยท ${fmtDuration(e.ms)} ยท merging on tests alone`));
|
|
680
886
|
else if (e.approve)
|
|
681
887
|
print(` ${tag(e.agent)} ${C.green("โ approved")} ${e.author}'s work ${C.faint("ยท " + fmtDuration(e.ms))}`);
|
|
682
888
|
else {
|
|
683
889
|
print(` ${tag(e.agent)} ${C.yellow("โ asked for changes")} ${C.faint("ยท " + fmtDuration(e.ms) + " ยท back to " + e.author)}`);
|
|
684
|
-
|
|
685
|
-
print(C.faint(` ${oneLine(l, 110)}`));
|
|
890
|
+
notesUnder(e.notes, 4);
|
|
686
891
|
}
|
|
687
892
|
}
|
|
688
|
-
if (e.type === "merged")
|
|
689
|
-
|
|
893
|
+
if (e.type === "merged") {
|
|
894
|
+
const short = e.reason.replace(/\s*\([^)]*\)/g, "");
|
|
895
|
+
const c = cost(e.ms, e.tokens, e.cached);
|
|
896
|
+
sayOne(e.agent, C.green("โ merged"), e.title, fitting(e.agent, "โ merged", e.title, C.faint(" ยท " + e.reason) + c, C.faint(" ยท " + short) + c, C.faint(" ยท " + short.split(" ยท ")[0]) + c, c));
|
|
897
|
+
}
|
|
690
898
|
if (e.type === "failed")
|
|
691
|
-
|
|
899
|
+
sayOne(e.agent, e.needsYou ? C.yellow("! needs you") : C.red("โ failed"), e.title, C.faint(" ยท " + oneLine(e.reason, 50)) + cost(e.ms, e.tokens, e.cached) + C.faint(" ยท log: " + tildify(e.log)));
|
|
692
900
|
if (e.type === "conflict" && a)
|
|
693
|
-
Object.assign(a, { busySince: Date.now(), liveTokens: -1 });
|
|
901
|
+
Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: `fix a merge conflict in โ${e.title}โ`, pct: undefined, noteAt: undefined });
|
|
694
902
|
if (e.type === "resolved" && a)
|
|
695
|
-
Object.assign(a, { busySince: null, ms: a.ms + e.ms });
|
|
903
|
+
Object.assign(a, { busySince: null, ms: a.ms + e.ms, doing: undefined });
|
|
696
904
|
if (e.type === "conflict") {
|
|
697
905
|
act(e.agent, `${C.yellow("โ")} resolving a conflict in ${e.author}'s work`);
|
|
698
|
-
|
|
699
|
-
C.faint(` ยท ${e.agent === e.author ? "its own work" : e.author + "'s work"} vs main ยท try ${e.attempt}`));
|
|
906
|
+
sayOne(e.agent, C.yellow("โ resolving conflict"), `in ${e.files.join(", ")}: ${e.title}`, C.faint(` ยท ${e.agent === e.author ? "its own work" : e.author + "'s work"} vs main ยท try ${e.attempt}`));
|
|
700
907
|
}
|
|
701
908
|
if (e.type === "resolved")
|
|
702
|
-
|
|
909
|
+
sayOne(e.agent, e.ok ? C.green("โ resolved") : C.yellow("โ resolution rejected"), "", C.faint(" ยท " + oneLine(e.reason, 60) + " ยท " + fmtDuration(e.ms)));
|
|
910
|
+
// The integration radar told both agents mid-task (their inbox has the details).
|
|
911
|
+
if (e.type === "radar") {
|
|
912
|
+
act(e.agent, `${C.yellow("โ radar")} ${e.kind === "overlap" ? "overlap" : "name mismatch"} with ${e.with}: ${e.detail}`);
|
|
913
|
+
sayOne(e.agent, `${C.yellow(e.kind === "overlap" ? "โ radar: overlap" : "โ radar: name mismatch")} with ${e.with}:`, e.detail);
|
|
914
|
+
}
|
|
915
|
+
// A buddy check: another agent read the author's work in progress; notes went to its inbox.
|
|
916
|
+
if (e.type === "checkpoint") {
|
|
917
|
+
const ok = /^LOOKS GOOD/i.test(e.notes.trim());
|
|
918
|
+
act(e.agent, `${C.violet("โ")} buddy check on ${e.author}'s work ${e.pct ? `at ${e.pct}%` : "mid-task"}: ${ok ? "looks good" : oneLine(e.notes, 80)}`);
|
|
919
|
+
const verdict = ok ? C.green(" ยท looks good") : e.late ? C.yellow(" ยท came back after it landed (kept in shared memory)") : C.yellow(" ยท notes sent");
|
|
920
|
+
sayOne(e.agent, `${C.violet("โ buddy check")} ${e.author}'s`, `โ${e.title}โ`, ` ${e.pct ? `at ${e.pct}%` : "mid-task"}${verdict}`);
|
|
921
|
+
if (!ok)
|
|
922
|
+
notesUnder(e.notes);
|
|
923
|
+
}
|
|
924
|
+
// A note that needed an answer, settled: addressed (fixed or replied) or ignored.
|
|
925
|
+
if (e.type === "followup") {
|
|
926
|
+
const what = e.kind === "radar" ? "radar finding" : `${e.by}'s buddy note`;
|
|
927
|
+
const how = e.how === "ignored" ? C.yellow("ignored") : C.green(e.how === "replied" ? "answered" : "addressed in code");
|
|
928
|
+
sayOne(e.agent, `${C.violet("โ")} ${what} ${how} ${C.faint("ยท")}`, e.note.replace(/^buddy check from [^:]*:\s*/i, ""), C.faint(` ยท โ${oneLine(e.title, 40)}โ`));
|
|
929
|
+
}
|
|
930
|
+
if (e.type === "pair") {
|
|
931
|
+
act(e.agent, `${C.violet("โ")} pairs with ${e.with}: ${e.part}`);
|
|
932
|
+
sayOne(e.agent, `${C.violet("โ pairs with " + e.with)} on โ${oneLine(e.title, 40)}โ: takes`, e.part, C.faint(" ยท " + oneLine(e.reason, 50)));
|
|
933
|
+
}
|
|
703
934
|
if (e.type === "stuck")
|
|
704
935
|
print(C.yellow(` ! ${e.blocked.length} task(s) blocked by failures: `) + C.faint(e.blocked.join(" ยท ")));
|
|
705
936
|
},
|
|
@@ -708,11 +939,12 @@ export async function runGoal(goal, opts) {
|
|
|
708
939
|
// QA: agents that didn't write the code re-check the integrated main; bugs
|
|
709
940
|
// they file become routed fix tasks, run through this same board.
|
|
710
941
|
let qaNote = "";
|
|
942
|
+
let qaSum = ""; // the RESULT's checks line
|
|
711
943
|
if (!res.stopped && res.merged && shouldQa(goal, { flag: opts.qa, tasks: plan.tasks.length, repoFiles })) {
|
|
712
944
|
if (res.failed)
|
|
713
945
|
print(C.faint(`\n QA skipped โ ${res.failed} task(s) didn't merge; it checks finished builds only`));
|
|
714
946
|
else {
|
|
715
|
-
print
|
|
947
|
+
phase(print, "QA", "agents that didn't write it check the build ยท --no-qa skips");
|
|
716
948
|
const qa = await runQa({
|
|
717
949
|
db,
|
|
718
950
|
room,
|
|
@@ -725,6 +957,7 @@ export async function runGoal(goal, opts) {
|
|
|
725
957
|
signal: opts.signal,
|
|
726
958
|
runFixes: () => runBoard(boardOpts),
|
|
727
959
|
onEvent: (e) => {
|
|
960
|
+
seen.push(e);
|
|
728
961
|
if (process.env.HUDDLE_EVENTS_FILE)
|
|
729
962
|
fs.appendFileSync(process.env.HUDDLE_EVENTS_FILE, JSON.stringify({ ts: Date.now(), qa: true, ...e }) + "\n");
|
|
730
963
|
qaLine(e, tag, print, act, stats);
|
|
@@ -733,6 +966,7 @@ export async function runGoal(goal, opts) {
|
|
|
733
966
|
res.merged += qa.fixed;
|
|
734
967
|
res.failed += qa.failed;
|
|
735
968
|
res.stopped ||= qa.stopped;
|
|
969
|
+
qaSum = `${qa.rounds} round(s) ยท ${qa.bugs} bug(s) found, ${qa.fixed} fixed ยท ` + (qa.clean ? C.green("last round clean") : C.yellow("last round's fixes not re-checked"));
|
|
736
970
|
qaNote = qa.clean
|
|
737
971
|
? `QA: ${qa.rounds} round(s) by agents re-checking the integrated build; ${qa.bugs} bug(s) filed, ${qa.fixed} fix(es) merged, the last round came back clean.`
|
|
738
972
|
: `QA: ${qa.rounds} round(s); ${qa.bugs} bug(s) filed, ${qa.fixed} fix(es) merged, but the last round did NOT come back clean.`;
|
|
@@ -747,15 +981,43 @@ export async function runGoal(goal, opts) {
|
|
|
747
981
|
if (ev.task_id && p.kind === "rescue" && p.branch)
|
|
748
982
|
takeovers.set(ev.task_id, String(p.branch));
|
|
749
983
|
}
|
|
984
|
+
const learned = learnFromRun({ db, root, since: runStart, events: seen });
|
|
750
985
|
if (ownRoom) {
|
|
751
986
|
room.close();
|
|
752
987
|
db.close();
|
|
753
988
|
}
|
|
754
989
|
opts.onStats?.(stats);
|
|
990
|
+
dock?.stop();
|
|
991
|
+
dock = null;
|
|
992
|
+
// RESULT: what was built and how it was checked, then how the agents worked together, then what it cost.
|
|
993
|
+
phase(print, "RESULT", "what got built, how it was checked, who did what");
|
|
755
994
|
const head = res.stopped ? C.yellow("โ stopped") : res.failed ? C.yellow("โ") : C.green("โ");
|
|
756
995
|
const total = [...stats.agents.values()].reduce((n, a) => n + a.tokens, 0);
|
|
757
996
|
const totalCached = [...stats.agents.values()].reduce((n, a) => n + a.cached, 0);
|
|
758
|
-
print(
|
|
997
|
+
print(`${head} ${res.merged} merged, ${res.failed} failed ${C.faint(`ยท ${fmtDuration(Date.now() - t0)} ยท ${tok(total, totalCached)}`)}`);
|
|
998
|
+
const testsNow = detectTestCmd(root);
|
|
999
|
+
print(` ${C.faint(pad("tests", 9))} ${testsNow ? `${C.green("โ")} ${testsNow} passed before every merge` : C.yellow("no test command โ merges weren't gated on tests")}`);
|
|
1000
|
+
if (qaSum)
|
|
1001
|
+
print(` ${C.faint(pad("QA", 9))} ${qaSum}`);
|
|
1002
|
+
for (const t of parked) {
|
|
1003
|
+
// Never resolved by force: the branch holds the agent's work exactly as it left it.
|
|
1004
|
+
// A taken-over task still lives on its first agent's branch.
|
|
1005
|
+
const tk = takeovers.get(t.id);
|
|
1006
|
+
const wt = tk ? { branch: tk } : worktreeFor(root, t.claimed_by ?? "", t.id);
|
|
1007
|
+
print(C.yellow(` ! needs you: ${t.title}`) + C.faint(` โ ${tk ? "two agents couldn't land it" : "conflicts with main"}; work kept on ${wt.branch}`));
|
|
1008
|
+
print(C.faint(` to take it yourself: git merge ${wt.branch} # resolve the markers, then commit`));
|
|
1009
|
+
}
|
|
1010
|
+
if (res.merged)
|
|
1011
|
+
await deliver(print, goal, root, startHead, doneNotes, brain, reportFacts(root, startHead, { ...res, testCmd: testsNow, qaNote }));
|
|
1012
|
+
const team = teamworkLines(plan.council, seen, talk);
|
|
1013
|
+
if (team.length) {
|
|
1014
|
+
print(`\n${C.violet("TEAMWORK")} ${C.faint("ยท how the agents worked together ยท huddle story for all of it")}`);
|
|
1015
|
+
for (const t of team) {
|
|
1016
|
+
const label = ` ${C.faint(pad(t.label, 9))} `;
|
|
1017
|
+
print(`${label}${oneLine(t.text, space(label))}`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
print(`\n${C.violet("COST")} ${C.faint(`ยท ${fmtDuration(Date.now() - t0)} ยท ${tok(total, totalCached)}`)}`);
|
|
759
1021
|
for (const [id, a] of stats.agents) {
|
|
760
1022
|
if (!a.tasks && !a.reviews && !a.qa) {
|
|
761
1023
|
print(` ${tag(id)} ${C.faint("idle this run")}`);
|
|
@@ -768,22 +1030,79 @@ export async function runGoal(goal, opts) {
|
|
|
768
1030
|
]
|
|
769
1031
|
.filter(Boolean)
|
|
770
1032
|
.join(" + ");
|
|
771
|
-
print(` ${tag(id)} ${pad(did,
|
|
772
|
-
}
|
|
773
|
-
for (const t of parked) {
|
|
774
|
-
// Never resolved by force: the branch holds the agent's work exactly as it left it.
|
|
775
|
-
// A taken-over task still lives on its first agent's branch.
|
|
776
|
-
const tk = takeovers.get(t.id);
|
|
777
|
-
const wt = tk ? { branch: tk } : worktreeFor(root, t.claimed_by ?? "", t.id);
|
|
778
|
-
print(C.yellow(` ! needs you: ${t.title}`) + C.faint(` โ ${tk ? "two agents couldn't land it" : "conflicts with main"}; work kept on ${wt.branch}`));
|
|
779
|
-
print(C.faint(` to take it yourself: git merge ${wt.branch} # resolve the markers, then commit`));
|
|
1033
|
+
print(` ${tag(id)} ${pad(did, 27)} ${pad(fmtDuration(a.ms), 7)} ${a.tasks ? tok(a.tokens, a.cached) : ""}`);
|
|
780
1034
|
}
|
|
1035
|
+
if (learned.length)
|
|
1036
|
+
print(C.faint(` โ learned ${learned.length} lesson(s) for next time ยท huddle lessons to see them`));
|
|
781
1037
|
if (res.merged && remote && sync) {
|
|
782
1038
|
const p = pushMerged(root);
|
|
783
1039
|
print(p.ok ? ` ${C.green("โ")} git push ยท all in one go ${C.faint("โ " + p.note)}` : C.yellow(` ! not pushed: ${p.note}`));
|
|
784
1040
|
}
|
|
785
|
-
|
|
786
|
-
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* The run's teamwork, one line each, from what the council and the scheduler
|
|
1044
|
+
* recorded โ counts, not judgments. [] for a run where nobody worked with anybody.
|
|
1045
|
+
*/
|
|
1046
|
+
export function teamworkLines(council, events, messages = 0) {
|
|
1047
|
+
const out = [];
|
|
1048
|
+
const n = (k, one, many = one + "s") => `${k} ${k === 1 ? one : many}`;
|
|
1049
|
+
const of = (t) => events.filter((e) => e.type === t);
|
|
1050
|
+
if (council) {
|
|
1051
|
+
const adopts = council.critiques.flatMap((c) => c.adopts ?? []);
|
|
1052
|
+
const flaws = council.critiques.flatMap((c) => c.flaws ?? []);
|
|
1053
|
+
if (adopts.length || flaws.length) {
|
|
1054
|
+
// "claude's taken by codex, cursor": whose ideas the others built on.
|
|
1055
|
+
const takers = new Map();
|
|
1056
|
+
for (const a of adopts)
|
|
1057
|
+
takers.set(a.of, (takers.get(a.of) ?? new Set()).add(a.by));
|
|
1058
|
+
const whose = [...takers].sort((a, b) => b[1].size - a[1].size).map(([of, by]) => `${of}'s taken by ${[...by].join(", ")}`);
|
|
1059
|
+
out.push({ label: "ideas", text: `${n(adopts.length, "idea")} adopted, ${n(flaws.length, "flaw")} flagged${whose.length ? ` ยท ${whose.join(" ยท ")}` : ""}` });
|
|
1060
|
+
}
|
|
1061
|
+
const by = council.synthesizer?.id ?? council.synthesizedBy;
|
|
1062
|
+
const obj = council.objections ?? [];
|
|
1063
|
+
const okd = (council.ratify ?? []).filter((r) => r.verdict === "ok").length;
|
|
1064
|
+
if (council.contract)
|
|
1065
|
+
out.push({
|
|
1066
|
+
label: "contract",
|
|
1067
|
+
text: `written by ${by}${obj.length ? ` ยท ${obj.filter((o) => o.taken).length} of ${n(obj.length, "objection")} taken` : okd ? ` ยท ${okd} ratified it as is` : ""}`,
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
const amends = of("contract");
|
|
1071
|
+
if (amends.length)
|
|
1072
|
+
out.push({ label: "contract", text: `${n(amends.length, "amendment")} mid-run, now v${Math.max(...amends.map((e) => Number(e.version) || 1))} ยท ${[...new Set(amends.map((e) => e.agent))].join(", ")}` });
|
|
1073
|
+
const checks = of("checkpoint");
|
|
1074
|
+
if (checks.length) {
|
|
1075
|
+
const sent = checks.filter((e) => !/^LOOKS GOOD/i.test(String(e.notes ?? "").trim()));
|
|
1076
|
+
const ups = of("followup").filter((e) => e.kind !== "radar");
|
|
1077
|
+
const k = (h) => ups.filter((e) => e.how === h).length;
|
|
1078
|
+
out.push({
|
|
1079
|
+
label: "buddy",
|
|
1080
|
+
text: sent.length
|
|
1081
|
+
? `${n(checks.length, "mid-task check")} ยท ${n(sent.length, "note")} sent` + (ups.length ? ` โ ${k("fixed")} fixed, ${k("replied")} answered, ${k("ignored")} ignored` : "")
|
|
1082
|
+
: `${n(checks.length, "mid-task check")} ยท all looked good`,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
const radar = of("radar");
|
|
1086
|
+
if (radar.length)
|
|
1087
|
+
out.push({ label: "radar", text: `${n(radar.length, "catch", "catches")} while agents worked: ${radar.slice(0, 3).map((e) => `${e.agent} โ ${e.with} (${e.kind})`).join(", ")}` });
|
|
1088
|
+
for (const p of of("pair").slice(0, 3))
|
|
1089
|
+
out.push({ label: "pair", text: `${p.agent} helped ${p.with} on โ${p.title}โ: ${p.part}` });
|
|
1090
|
+
const takes = of("takeover");
|
|
1091
|
+
if (takes.length)
|
|
1092
|
+
out.push({ label: "takeover", text: takes.slice(0, 3).map((e) => `${e.agent} ${e.kind === "rescue" ? "took over" : "took part of"} ${e.from}'s โ${e.title}โ`).join(" ยท ") });
|
|
1093
|
+
const reviews = of("reviewed").filter((e) => !e.skipped);
|
|
1094
|
+
if (reviews.length) {
|
|
1095
|
+
const asked = reviews.filter((e) => !e.approve).length;
|
|
1096
|
+
out.push({ label: "reviews", text: `${n(reviews.length, "cross-review")} ยท ${reviews.length - asked} approved${asked ? `, ${asked} sent back for changes` : ""}` });
|
|
1097
|
+
}
|
|
1098
|
+
const bugs = events.filter((e) => e.type === "bug" && e.round !== undefined);
|
|
1099
|
+
if (bugs.length) {
|
|
1100
|
+
const fixers = [...new Set(bugs.map((e) => e.assignedTo).filter(Boolean))];
|
|
1101
|
+
out.push({ label: "QA", text: `${n(bugs.length, "bug")} found by ${[...new Set(bugs.map((e) => e.agent))].join(", ")} โ fixed by ${fixers.join(", ") || "any free agent"}` });
|
|
1102
|
+
}
|
|
1103
|
+
if (messages)
|
|
1104
|
+
out.push({ label: "talk", text: `${n(messages, "message")} between agents` });
|
|
1105
|
+
return out;
|
|
787
1106
|
}
|
|
788
1107
|
/** QA's readable lines: "cursor (QA) found: โฆ", "โ fix assigned to codex". */
|
|
789
1108
|
function qaLine(e, tag, print, act, stats) {
|
|
@@ -792,26 +1111,32 @@ function qaLine(e, tag, print, act, stats) {
|
|
|
792
1111
|
print(` ${C.violet("โ")} ${C.bold(`QA round ${e.round}`)} ${C.faint("ยท checked by " + e.agents.join(", "))}`);
|
|
793
1112
|
if (e.type === "start") {
|
|
794
1113
|
if (a)
|
|
795
|
-
Object.assign(a, { busySince: Date.now(), liveTokens: -1 });
|
|
796
|
-
print(` ${tag(e.agent)} ${C.violet("โ")}
|
|
1114
|
+
Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: `QA check, round ${e.round}`, pct: undefined, noteAt: undefined });
|
|
1115
|
+
print(` ${tag(e.agent)} ${C.violet("โ QA check:")} runs the tests, uses it, reads the diff${e.author ? C.faint(" ยท (wrote some of it too)") : ""}`);
|
|
797
1116
|
act(e.agent, `${C.violet("โ")} QA round ${e.round}`);
|
|
798
1117
|
}
|
|
799
1118
|
if (e.type === "bug") {
|
|
800
|
-
|
|
801
|
-
|
|
1119
|
+
const head = ` ${tag(e.agent)} ${C.yellow("โ QA bug:")} `;
|
|
1120
|
+
// The bug's title first; where it is only if there's room.
|
|
1121
|
+
const full = (e.files.length ? C.faint(" ยท " + e.files.slice(0, 2).join(", ")) : "") + (e.test ? C.faint(" ยท + test") : "");
|
|
1122
|
+
const where = space(head, full) >= 40 ? full : "";
|
|
1123
|
+
print(`${head}${oneLine(e.title, space(head, where))}${where}`);
|
|
1124
|
+
print(` ${pad("", 14)} ${C.faint("โ fix goes to")} ${e.assignedTo ? `${dot(e.assignedTo)} ${e.assignedTo}` : C.faint("any free agent")}`);
|
|
802
1125
|
act(e.agent, `${C.yellow("โ found:")} ${e.title} ${C.faint("โ " + (e.assignedTo ?? "anyone"))}`);
|
|
803
1126
|
}
|
|
804
1127
|
if (e.type === "duplicate")
|
|
805
|
-
print(` ${tag(e.agent)} ${C.faint(`= duplicate of โ${oneLine(e.of,
|
|
1128
|
+
print(` ${tag(e.agent)} ${C.faint(`= duplicate of โ${oneLine(e.of, space(" ", tag(e.agent), " = duplicate of โโ ยท not filed again"))}โ ยท not filed again`)}`);
|
|
806
1129
|
if (e.type === "checked") {
|
|
807
1130
|
if (a)
|
|
808
|
-
Object.assign(a, { busySince: null, ms: a.ms + e.ms, tokens: a.tokens + e.tokens, qa: (a.qa ?? 0) + 1 });
|
|
1131
|
+
Object.assign(a, { busySince: null, ms: a.ms + e.ms, tokens: a.tokens + e.tokens, qa: (a.qa ?? 0) + 1, doing: undefined });
|
|
809
1132
|
print(` ${tag(e.agent)} ${e.ok ? C.green("โ QA done") : C.yellow("! QA didn't finish")} ${C.faint(`ยท ${e.bugs} bug(s) filed ยท ${fmtDuration(e.ms)}${e.reason ? " ยท " + e.reason : ""}`)}`);
|
|
810
1133
|
}
|
|
811
1134
|
if (e.type === "fixes")
|
|
812
1135
|
print(` ${C.violet("โ")} ${C.faint(`QA round ${e.round} fixes: ${e.merged} merged, ${e.failed} failed`)}`);
|
|
813
1136
|
if (e.type === "clean")
|
|
814
1137
|
print(` ${C.green("โ")} QA round ${e.round} clean ${C.faint("ยท nothing found")}`);
|
|
1138
|
+
if (e.type === "suspect")
|
|
1139
|
+
print(C.yellow(` ? QA round ${e.round} clean, but quick`) + C.faint(` ยท ${e.reason}`));
|
|
815
1140
|
if (e.type === "unchecked")
|
|
816
1141
|
print(C.yellow(` ! QA round ${e.round}: the build was NOT checked`) + C.faint(` ยท ${e.reason}`));
|
|
817
1142
|
if (e.type === "capped")
|
|
@@ -829,7 +1154,7 @@ function qaLine(e, tag, print, act, stats) {
|
|
|
829
1154
|
* that's Huddle's choice, not the run's.
|
|
830
1155
|
*/
|
|
831
1156
|
export function reportFacts(root, startHead, o) {
|
|
832
|
-
const subjects = git(["log", "--format=%s", `${startHead}..HEAD`], root).out.split(
|
|
1157
|
+
const subjects = git(["log", "--format=%s", `${startHead}..HEAD`], root).out.split(/\r?\n/).filter(Boolean);
|
|
833
1158
|
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], root).out.trim() || "the current branch";
|
|
834
1159
|
const facts = [
|
|
835
1160
|
`${o.merged} task(s) merged and ${o.failed} failed. The merged work is already committed: ${subjects.length} commit(s) on ${branch} since the run started (git log ${startHead.slice(0, 7)}..HEAD).`,
|
|
@@ -869,13 +1194,22 @@ async function deliver(print, goal, root, startHead, notes, brain, facts = []) {
|
|
|
869
1194
|
if (r.ok)
|
|
870
1195
|
summary = r.text;
|
|
871
1196
|
}
|
|
872
|
-
print(`\n${C.violet("WHAT YOU GOT")} ${C.faint("ยท " + shortGoal(goal))}`);
|
|
1197
|
+
print(`\n${C.violet("WHAT YOU GOT")} ${C.faint("ยท " + shortGoal(goal, space("WHAT YOU GOT ยท ")))}`);
|
|
1198
|
+
// Wrapped, not cut: this is the part people read. (Command lines keep their indent.)
|
|
1199
|
+
const w = lineWidth();
|
|
1200
|
+
const say = (l) => {
|
|
1201
|
+
if (!w || width(l) <= w - 2)
|
|
1202
|
+
return print(" " + l);
|
|
1203
|
+
const indent = (/^\s*(?:[-*โข]\s+|\d+[.)]\s+)?/.exec(l)?.[0] ?? "").replace(/\S/g, " ");
|
|
1204
|
+
for (const part of wrap(l, w - 2, indent))
|
|
1205
|
+
print(" " + part);
|
|
1206
|
+
};
|
|
873
1207
|
if (summary)
|
|
874
1208
|
for (const l of summary.split("\n"))
|
|
875
|
-
|
|
1209
|
+
say(l);
|
|
876
1210
|
else
|
|
877
1211
|
for (const n of notes)
|
|
878
|
-
|
|
1212
|
+
say(n);
|
|
879
1213
|
if (stat)
|
|
880
1214
|
print(C.faint("\n " + stat.split("\n").pop().trim() + C.faint(" ยท see it all: git log")));
|
|
881
1215
|
}
|
|
@@ -886,7 +1220,7 @@ async function answer(print, question, results, signal) {
|
|
|
886
1220
|
let brain = resolveBrain(results);
|
|
887
1221
|
if (brain && !brain.readsRepo) {
|
|
888
1222
|
const asker = pickAsker(workersFrom(results).map((w) => w.adapter));
|
|
889
|
-
brain = asker ? brainFrom(asker.id === "claude" ? "claude:
|
|
1223
|
+
brain = asker ? brainFrom(asker.id === "claude" ? "claude:opus" : asker.id) : null;
|
|
890
1224
|
}
|
|
891
1225
|
if (!brain) {
|
|
892
1226
|
print(C.red(" โ no agent ready to answer.") + " Type `agents`.");
|
|
@@ -962,7 +1296,10 @@ const HELP = `
|
|
|
962
1296
|
${C.bold("funnel")} ${C.faint("[on|off]")} public https invites through Tailscale Funnel
|
|
963
1297
|
${C.bold("ctrl-t")} ${C.faint("or")} ${C.bold("view")} the live view โ 1 agents ยท 2 plan ยท 3 board ยท q to come back
|
|
964
1298
|
${C.bold("board")} the task board and shared memory
|
|
1299
|
+
${C.faint("paste")} ${C.faint("/")} ${C.faint("drag a file in")} long pastes and files go on the shared screen: one copy every agent reads
|
|
1300
|
+
${C.bold("add")} ${C.faint("<file|dir|glob>")} put files on the screen ${C.faint("ยท")} ${C.bold("ctx")} ${C.faint("[show|rm|note|clear]")} what's on it
|
|
965
1301
|
${C.bold("agents")} reconnect agents; install or sign in to missing ones
|
|
1302
|
+
${C.bold("agents use|skip")} ${C.faint("<id>")} pick who works with you: agents skip cursor ยท agents use cursor
|
|
966
1303
|
${C.bold("cd <dir>")} move to another project ${C.faint("ยท")} ${C.bold("!<cmd>")} run a shell command
|
|
967
1304
|
${C.bold("ctrl-c")} stop a run ${C.faint("(or clear the line)")}
|
|
968
1305
|
${C.bold("exit")} leave ${C.faint("(or ctrl-d)")}
|
|
@@ -999,34 +1336,47 @@ export async function runApp() {
|
|
|
999
1336
|
const queue = [];
|
|
1000
1337
|
let stats = null;
|
|
1001
1338
|
let ticker = null;
|
|
1002
|
-
/** "โฑ 1m12s ยท
|
|
1339
|
+
/** "โฑ 1m12s ยท 3 merged so far" โ the per-agent lines (statusLines) go under it. */
|
|
1003
1340
|
const liveStats = () => {
|
|
1004
1341
|
if (!stats)
|
|
1005
1342
|
return C.violet("โ planningโฆ");
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
? `${dot(id)} ${id} ${fmtDuration(now - a.busySince)} ${C.faint(a.liveTokens < 0 ? "โฆ" : fmtTokens(a.liveTokens) + " tok")}`
|
|
1009
|
-
: C.faint(`โ ${id}`));
|
|
1010
|
-
return `${C.faint("โฑ")} ${fmtDuration(now - stats.startedAt)} ${parts.join(" ")}`;
|
|
1343
|
+
const merged = [...stats.agents.values()].reduce((n, a) => n + a.tasks, 0);
|
|
1344
|
+
return `${C.faint("โฑ")} ${fmtDuration(Date.now() - stats.startedAt)} ${C.faint(`ยท ${merged} merged so far`)}`;
|
|
1011
1345
|
};
|
|
1012
|
-
// Footer under the prompt, like Claude Code's mode line.
|
|
1346
|
+
// Footer under the prompt, like Claude Code's mode line; during a run, one more line per agent.
|
|
1013
1347
|
const footer = () => run
|
|
1014
|
-
?
|
|
1015
|
-
|
|
1016
|
-
|
|
1348
|
+
? [
|
|
1349
|
+
` ${MODE_INFO[state.mode].chip} ${liveStats()}${queue.length ? C.faint(` ยท ${queue.length} queued`) : ""}`,
|
|
1350
|
+
...(stats ? statusLines(stats, (out.columns || 80) - 1) : []),
|
|
1351
|
+
]
|
|
1352
|
+
: [` ${MODE_INFO[state.mode].chip} ${C.faint("ยท " + MODE_INFO[state.mode].desc)} ` + `${C.faint("approvals:")} ${state.approvals} ${C.faint("shift+tab to switch")}`];
|
|
1353
|
+
let reserved = 1; // footer lines reserved under the prompt
|
|
1017
1354
|
const drawFooter = () => {
|
|
1018
1355
|
if (!promptShown || asking || !out.isTTY)
|
|
1019
1356
|
return;
|
|
1020
|
-
// Never wrap: a wrapped footer would break the cursor save/restore.
|
|
1021
|
-
|
|
1357
|
+
// Never wrap: a wrapped footer would break the cursor save/restore. Never more lines than were reserved.
|
|
1358
|
+
let ls = footer();
|
|
1359
|
+
if (ls.length > reserved)
|
|
1360
|
+
ls = [...ls.slice(0, reserved - 1), C.faint(` โฆ ${ls.length - reserved + 1} more ยท ctrl-t for the live view`)];
|
|
1361
|
+
const w = (out.columns || 80) - 1;
|
|
1362
|
+
out.write("\x1b7" + ls.map((l) => "\x1b[1B\r\x1b[2K" + truncate(l, w)).join("") + "\x1b[J\x1b8");
|
|
1022
1363
|
};
|
|
1023
1364
|
const showPrompt = () => {
|
|
1024
|
-
|
|
1365
|
+
// Reserve the footer lines below the prompt (up to a third of the screen).
|
|
1366
|
+
reserved = Math.max(1, Math.min(footer().length, Math.floor((out.rows || 24) / 3)));
|
|
1367
|
+
out.write("\n".repeat(reserved) + `\x1b[${reserved}A`);
|
|
1025
1368
|
rl.setPrompt(prompt);
|
|
1026
1369
|
rl.prompt(true);
|
|
1027
1370
|
promptShown = true;
|
|
1028
1371
|
drawFooter();
|
|
1029
1372
|
};
|
|
1373
|
+
/** The footer grew (a run started, its roster is known): reserve the new lines. */
|
|
1374
|
+
const refitFooter = () => {
|
|
1375
|
+
if (!promptShown || asking || !out.isTTY || footer().length === reserved)
|
|
1376
|
+
return;
|
|
1377
|
+
out.write("\r\x1b[J");
|
|
1378
|
+
showPrompt();
|
|
1379
|
+
};
|
|
1030
1380
|
// The live view (ctrl-t / `view`). While it's up, prompt output waits.
|
|
1031
1381
|
const dash = new Dashboard({
|
|
1032
1382
|
db: () => live?.db ?? null,
|
|
@@ -1101,6 +1451,7 @@ export async function runApp() {
|
|
|
1101
1451
|
print(C.faint(" (type exit or press ctrl-d to leave)"));
|
|
1102
1452
|
});
|
|
1103
1453
|
rl.on("close", () => {
|
|
1454
|
+
bracketedPaste(false);
|
|
1104
1455
|
out.write("\r\x1b[J");
|
|
1105
1456
|
console.log(C.faint("bye ๐"));
|
|
1106
1457
|
process.exit(0); // the scheduler's exit hook stops any running agents
|
|
@@ -1159,6 +1510,11 @@ export async function runApp() {
|
|
|
1159
1510
|
};
|
|
1160
1511
|
const unsub = db.onEvent((e) => {
|
|
1161
1512
|
const p = JSON.parse(e.payload || "{}");
|
|
1513
|
+
const se = screenEvent(e); // notes, reads and teammates' items on the shared screen
|
|
1514
|
+
if (se?.print)
|
|
1515
|
+
print(se.print);
|
|
1516
|
+
if (se?.dash)
|
|
1517
|
+
dash.push(se.dash);
|
|
1162
1518
|
if (e.type === "member.joined")
|
|
1163
1519
|
print(` ${C.green("โ")} ${C.bold(p.name)} joined the room ${C.faint("ยท " + p.role)}`);
|
|
1164
1520
|
if (e.type === "member.left")
|
|
@@ -1219,7 +1575,7 @@ export async function runApp() {
|
|
|
1219
1575
|
const add = spawnSync("gh", ["api", "-X", "PUT", `repos/${repo}/collaborators/${handle}`, "-f", "permission=push"], {
|
|
1220
1576
|
encoding: "utf8",
|
|
1221
1577
|
});
|
|
1222
|
-
print(add.status === 0 ? ` ${C.green("โ")} GitHub invite sent to @${handle}` : C.red(` โ ${add.stderr.trim().split(
|
|
1578
|
+
print(add.status === 0 ? ` ${C.green("โ")} GitHub invite sent to @${handle}` : C.red(` โ ${add.stderr.trim().split(/\r?\n/)[0]}`));
|
|
1223
1579
|
}
|
|
1224
1580
|
}
|
|
1225
1581
|
else if (collab)
|
|
@@ -1339,9 +1695,58 @@ export async function runApp() {
|
|
|
1339
1695
|
promptShown = false;
|
|
1340
1696
|
out.write("\r\x1b[J");
|
|
1341
1697
|
rl.pause();
|
|
1342
|
-
|
|
1698
|
+
bracketedPaste(false);
|
|
1699
|
+
runShell(cmd);
|
|
1700
|
+
bracketedPaste(true);
|
|
1343
1701
|
rl.resume();
|
|
1344
1702
|
};
|
|
1703
|
+
// ---- the shared screen: pastes, dropped files, `add`, `ctx` (src/ui/paste.ts) ----
|
|
1704
|
+
const screenUI = {
|
|
1705
|
+
print,
|
|
1706
|
+
// A question mid-line would swallow what's typed: park it, ask, put it back.
|
|
1707
|
+
yes: async (q, def) => {
|
|
1708
|
+
const keep = rl.line;
|
|
1709
|
+
if (keep)
|
|
1710
|
+
rl.write(null, { ctrl: true, name: "u" });
|
|
1711
|
+
try {
|
|
1712
|
+
return await yes(rl, q, def);
|
|
1713
|
+
}
|
|
1714
|
+
finally {
|
|
1715
|
+
if (keep)
|
|
1716
|
+
rl.write(keep);
|
|
1717
|
+
}
|
|
1718
|
+
},
|
|
1719
|
+
db: async () => (await openRoom())?.db ?? null,
|
|
1720
|
+
audience: () => {
|
|
1721
|
+
const ready = results.filter((r) => r.state === "ready");
|
|
1722
|
+
return { agents: ready.map((r) => r.adapter.id), companies: new Set(ready.map((r) => r.adapter.vendor)).size };
|
|
1723
|
+
},
|
|
1724
|
+
brain: () => resolveBrain(results),
|
|
1725
|
+
root: repoRoot,
|
|
1726
|
+
interactive: true,
|
|
1727
|
+
};
|
|
1728
|
+
if (out.isTTY && process.stdin.isTTY) {
|
|
1729
|
+
bracketedPaste(true);
|
|
1730
|
+
process.once("exit", () => bracketedPaste(false));
|
|
1731
|
+
interceptStdin(process.stdin, (pass) => new PasteCatcher({
|
|
1732
|
+
pass,
|
|
1733
|
+
enabled: () => !asking && !dash.isOpen(),
|
|
1734
|
+
onPaste: (text) => {
|
|
1735
|
+
const drop = asDrop(text);
|
|
1736
|
+
// Short pastes stay on the line, as one line.
|
|
1737
|
+
if (!drop && !isLongPaste(text))
|
|
1738
|
+
return pass(flatten(text));
|
|
1739
|
+
void (async () => {
|
|
1740
|
+
if (drop)
|
|
1741
|
+
await addPaths(drop, screenUI);
|
|
1742
|
+
else
|
|
1743
|
+
await pasteToScreen(text, screenUI);
|
|
1744
|
+
if (!promptShown)
|
|
1745
|
+
showPrompt();
|
|
1746
|
+
})().catch((e) => print(C.red(" error: ") + (e?.message ?? e)));
|
|
1747
|
+
},
|
|
1748
|
+
}));
|
|
1749
|
+
}
|
|
1345
1750
|
const startGoal = async (typed) => {
|
|
1346
1751
|
// "--qa" / "--no-qa" anywhere in a typed goal, like on `huddle join`.
|
|
1347
1752
|
const qa = /(^|\s)--no-qa(\s|$)/.test(typed) ? false : /(^|\s)--qa(\s|$)/.test(typed) ? true : undefined;
|
|
@@ -1359,7 +1764,10 @@ export async function runApp() {
|
|
|
1359
1764
|
rl,
|
|
1360
1765
|
signal: run.signal,
|
|
1361
1766
|
print,
|
|
1362
|
-
onStats: (s) =>
|
|
1767
|
+
onStats: (s) => {
|
|
1768
|
+
stats = s;
|
|
1769
|
+
refitFooter();
|
|
1770
|
+
},
|
|
1363
1771
|
room: r ?? undefined,
|
|
1364
1772
|
onActivity: (a) => dash.push(a),
|
|
1365
1773
|
qa,
|
|
@@ -1397,7 +1805,14 @@ export async function runApp() {
|
|
|
1397
1805
|
try {
|
|
1398
1806
|
if (cmd === "exit" || cmd === "quit")
|
|
1399
1807
|
return rl.close();
|
|
1400
|
-
|
|
1808
|
+
const drop = asDrop(line); // a file dragged in by a terminal that doesn't mark pastes
|
|
1809
|
+
if (drop)
|
|
1810
|
+
await addPaths(drop, screenUI);
|
|
1811
|
+
else if (cmd === "add" && looksLikePaths(line.slice(3)))
|
|
1812
|
+
await addPaths(splitPathTokens(line.slice(3)), screenUI);
|
|
1813
|
+
else if (cmd === "ctx" || cmd === "context" || cmd === "screen")
|
|
1814
|
+
await ctxCommand(line.slice(cmd.length), screenUI);
|
|
1815
|
+
else if (cmd === "help" || cmd === "?")
|
|
1401
1816
|
print(HELP);
|
|
1402
1817
|
else if (cmd === "clear" || cmd === "cls") {
|
|
1403
1818
|
out.write("\x1b[2J\x1b[3J\x1b[H");
|
|
@@ -1467,8 +1882,14 @@ export async function runApp() {
|
|
|
1467
1882
|
print(C.faint(" nothing running"));
|
|
1468
1883
|
}
|
|
1469
1884
|
else if (cmd === "agents") {
|
|
1885
|
+
const [verb, ...ids] = arg.split(/[\s,]+/).filter(Boolean);
|
|
1470
1886
|
if (run)
|
|
1471
1887
|
print(C.faint(" finish or stop the current run first"));
|
|
1888
|
+
else if ((verb === "use" || verb === "skip") && ids.length) {
|
|
1889
|
+
setAgentChoice(ids, verb === "use");
|
|
1890
|
+
print(C.faint(` ${verb === "use" ? "using" : "skipping"} ${ids.join(", ")} from now on`));
|
|
1891
|
+
results = await connectAgents();
|
|
1892
|
+
}
|
|
1472
1893
|
else {
|
|
1473
1894
|
results = await connectAgents();
|
|
1474
1895
|
if (await offerSetup(rl, results, true))
|