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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// The goal's exact requirements, kept exact on their way to the agents.
|
|
2
|
+
//
|
|
3
|
+
// Found live (capstone round 5): the synthesizer paraphrased the brief. The
|
|
4
|
+
// CSV rule "any field may be wrapped in double quotes" became "optional
|
|
5
|
+
// double-quote wrapping", and the CSV task quoted fields mid-word, so
|
|
6
|
+
// `The "Real" Thing` imported as `The Real Thing`. The CLI spec added
|
|
7
|
+
// "invalid command syntax gives 'invalid arguments'", and `add --author X`
|
|
8
|
+
// printed that instead of the brief's "title is required". The workers only
|
|
9
|
+
// ever see their spec and the contract, never the goal, so nothing told them
|
|
10
|
+
// the brief said otherwise; QA then judged the build against the contract.
|
|
11
|
+
//
|
|
12
|
+
// So, after any project plan is made (deterministic, no model call):
|
|
13
|
+
// - every task's spec carries the goal verbatim (or, for a very long goal,
|
|
14
|
+
// the goal lines relevant to it), with "the goal wins" over spec and contract
|
|
15
|
+
// - every exact string the goal quotes ("title is required", `no books`)
|
|
16
|
+
// that no spec and not the contract carries is assigned, with its whole
|
|
17
|
+
// goal line, to the task it fits best, so someone owns it
|
|
18
|
+
// - QA gets the same lines as a numbered acceptance checklist
|
|
19
|
+
/** A bare heading line: "DATA", "TEXT FORMATS (CLI output and the markdown export)". */
|
|
20
|
+
const HEADING = /^[A-Z][A-Z0-9 /&+-]{1,40}(\s*\([^)]*\))?:?$/;
|
|
21
|
+
/** A heading with its body on the same line: "CSV IMPORT: the first line is…". */
|
|
22
|
+
const HEAD_BODY = /^([A-Z][A-Z0-9 /&+-]{1,40}):\s+(.+)$/;
|
|
23
|
+
const BULLET = /^(?:[-*•]|\d+[.)])\s+(.+)$/;
|
|
24
|
+
/** The exact strings a line quotes. Very short ones ("id") say nothing about coverage. */
|
|
25
|
+
export function goalAtoms(text) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const m of text.matchAll(/`([^`\n]+)`|"([^"\n]+)"/g)) {
|
|
28
|
+
const a = (m[1] ?? m[2]).trim();
|
|
29
|
+
if (a.length >= 4 && /[\s<>#:,\[\]./-]/.test(a))
|
|
30
|
+
out.push(a);
|
|
31
|
+
}
|
|
32
|
+
return [...new Set(out)];
|
|
33
|
+
}
|
|
34
|
+
/** Split a paragraph into sentences without breaking "e.g." or a quoted span. */
|
|
35
|
+
function sentences(p) {
|
|
36
|
+
const out = [];
|
|
37
|
+
let cur = "";
|
|
38
|
+
let quote = null;
|
|
39
|
+
for (let i = 0; i < p.length; i++) {
|
|
40
|
+
const c = p[i];
|
|
41
|
+
cur += c;
|
|
42
|
+
if (c === "`" || c === '"')
|
|
43
|
+
quote = quote === c ? null : quote ?? c;
|
|
44
|
+
if (!quote && /[.;!?]/.test(c) && /\s/.test(p[i + 1] ?? "") && /[A-Z`"]/.test(p.slice(i + 1).trimStart()[0] ?? "") && !/\b(e\.g|i\.e|etc|vs)\.$/i.test(cur)) {
|
|
45
|
+
out.push(cur.trim());
|
|
46
|
+
cur = "";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (cur.trim())
|
|
50
|
+
out.push(cur.trim());
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** The goal as requirement lines: bullets, heading bodies, and the sentences of plain paragraphs. */
|
|
54
|
+
export function goalItems(goal) {
|
|
55
|
+
const out = [];
|
|
56
|
+
let section = "";
|
|
57
|
+
const push = (text) => {
|
|
58
|
+
const t = text.trim();
|
|
59
|
+
if (t)
|
|
60
|
+
out.push({ text: t, section, atoms: goalAtoms(t) });
|
|
61
|
+
};
|
|
62
|
+
for (const raw of goal.split(/\r?\n/)) {
|
|
63
|
+
const line = raw.trim();
|
|
64
|
+
if (!line)
|
|
65
|
+
continue;
|
|
66
|
+
if (HEADING.test(line) && !BULLET.test(line)) {
|
|
67
|
+
section = line.replace(/:$/, "").trim();
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const b = BULLET.exec(line);
|
|
71
|
+
if (b) {
|
|
72
|
+
push(b[1]);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const h = HEAD_BODY.exec(line);
|
|
76
|
+
if (h) {
|
|
77
|
+
section = h[1].trim();
|
|
78
|
+
// A heading's body is one rule set ("CSV IMPORT: …; …; …"): keep it whole.
|
|
79
|
+
push(line);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
for (const s of sentences(line))
|
|
83
|
+
push(s);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** Lowercased, whitespace and quote style folded: what "the spec carries this string" compares. */
|
|
88
|
+
const fold = (s) => s.toLowerCase().replace(/[`'"“”‘’\s]/g, "");
|
|
89
|
+
const STOP = new Set("a an the and or of to in on at by for with from as is are be it its that this these those any every each one all no not only when then than into per via has have has use uses used must may can will shall should exactly e.g".split(" "));
|
|
90
|
+
/** Words, plus the parts of a path ("src/cli.js" is also "src", "cli", "js"). */
|
|
91
|
+
const wordsOf = (s) => new Set(s
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^\w/.#-]+/g, " ")
|
|
94
|
+
.split(/\s+/)
|
|
95
|
+
.map((w) => w.replace(/^[.#-]+|[.#-]+$/g, ""))
|
|
96
|
+
.flatMap((w) => (/[/.]/.test(w) ? [w, ...w.split(/[/.]+/)] : [w]))
|
|
97
|
+
.filter((w) => w.length > 1 && !STOP.has(w)));
|
|
98
|
+
/**
|
|
99
|
+
* How much each task is about this goal line: shared words, weighted so a word
|
|
100
|
+
* every task uses ("book") counts little and a rare one ("csv", "stderr")
|
|
101
|
+
* counts a lot; a word of the line's heading counts triple, one in the task's
|
|
102
|
+
* title or file names double, and a file path
|
|
103
|
+
* the line names and the task owns counts most.
|
|
104
|
+
*/
|
|
105
|
+
function relevance(item, tasks) {
|
|
106
|
+
const docs = tasks.map((t) => wordsOf(`${t.title} ${t.spec} ${(t.files ?? []).join(" ")}`));
|
|
107
|
+
const titles = tasks.map((t) => wordsOf(`${t.title} ${(t.files ?? []).join(" ")}`));
|
|
108
|
+
const words = wordsOf(item.text);
|
|
109
|
+
// The goal's own heading ("CLI", "CSV IMPORT") says best which part a line is about.
|
|
110
|
+
const heading = wordsOf(item.section);
|
|
111
|
+
const paths = item.text.match(/\b[\w./-]+\.\w{1,5}\b/g) ?? [];
|
|
112
|
+
return tasks.map((t, i) => {
|
|
113
|
+
let s = 0;
|
|
114
|
+
for (const w of new Set([...words, ...heading])) {
|
|
115
|
+
if (!docs[i].has(w))
|
|
116
|
+
continue;
|
|
117
|
+
const df = docs.filter((d) => d.has(w)).length;
|
|
118
|
+
s += Math.log((tasks.length + 1) / df) * (heading.has(w) ? 3 : 1) * (titles[i].has(w) ? 2 : 1);
|
|
119
|
+
}
|
|
120
|
+
for (const p of paths)
|
|
121
|
+
if ((t.files ?? []).some((f) => f === p || f.endsWith("/" + p)))
|
|
122
|
+
s += 5;
|
|
123
|
+
return s;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** Above this, a spec carries the goal lines relevant to it instead of the whole goal. */
|
|
127
|
+
export const GOAL_VERBATIM_CAP = 8000;
|
|
128
|
+
const RELEVANT_CAP = 4000;
|
|
129
|
+
/**
|
|
130
|
+
* Make sure the goal reaches every worker exactly as written (see the top of
|
|
131
|
+
* this file). Pure: returns new tasks and what the check found.
|
|
132
|
+
*/
|
|
133
|
+
export function carryGoal(goal, tasks, contract = "") {
|
|
134
|
+
const items = goalItems(goal);
|
|
135
|
+
const atoms = [...new Set(items.flatMap((i) => i.atoms))];
|
|
136
|
+
const hay = fold([...tasks.map((t) => t.spec), contract].join("\n"));
|
|
137
|
+
const missing = atoms.filter((a) => !hay.includes(fold(a)));
|
|
138
|
+
const check = { atoms: atoms.length, missing, carried: {} };
|
|
139
|
+
if (!tasks.length || !goal.trim())
|
|
140
|
+
return { tasks, check };
|
|
141
|
+
const scores = items.map((it) => relevance(it, tasks));
|
|
142
|
+
// Each line with a missing string goes to the task it fits best (ties: the earlier task).
|
|
143
|
+
const carry = tasks.map(() => []);
|
|
144
|
+
items.forEach((it, k) => {
|
|
145
|
+
if (!it.atoms.some((a) => missing.includes(a)))
|
|
146
|
+
return;
|
|
147
|
+
const best = scores[k].reduce((bi, s, i, arr) => (s > arr[bi] ? i : bi), 0);
|
|
148
|
+
carry[best].push(it.text);
|
|
149
|
+
});
|
|
150
|
+
const whole = goal.trim().length <= GOAL_VERBATIM_CAP;
|
|
151
|
+
const out = tasks.map((t, i) => {
|
|
152
|
+
const parts = [t.spec];
|
|
153
|
+
if (carry[i].length) {
|
|
154
|
+
check.carried[t.title] = carry[i];
|
|
155
|
+
parts.push(`EXACT REQUIREMENTS no task spec or the contract carried — this task owns them; meet each exactly as written:\n${carry[i].map((l) => `- ${l}`).join("\n")}`);
|
|
156
|
+
}
|
|
157
|
+
if (whole && !fold(t.spec).includes(fold(goal).slice(0, 200))) {
|
|
158
|
+
parts.push(`THE GOAL, verbatim (the whole team's; your task is one part of it). Where your spec or the contract differs from an exact output, error message, format or rule here, the goal wins — code to the goal and tell the team (huddle_amend_contract):\n${goal.trim()}`);
|
|
159
|
+
}
|
|
160
|
+
else if (!whole) {
|
|
161
|
+
// Lines that matter to this task: its best-fitting ones, capped.
|
|
162
|
+
const mine = items
|
|
163
|
+
.map((it, k) => ({ it, s: scores[k][i], top: Math.max(...scores[k]) }))
|
|
164
|
+
.filter((x) => x.s > 0 && x.s >= x.top * 0.5)
|
|
165
|
+
.sort((a, b) => b.it.atoms.length - a.it.atoms.length || b.s - a.s);
|
|
166
|
+
const lines = [];
|
|
167
|
+
let n = 0;
|
|
168
|
+
for (const x of mine) {
|
|
169
|
+
if (n + x.it.text.length > RELEVANT_CAP)
|
|
170
|
+
continue;
|
|
171
|
+
lines.push(`- ${x.it.section ? `${x.it.section}: ` : ""}${x.it.text}`);
|
|
172
|
+
n += x.it.text.length;
|
|
173
|
+
}
|
|
174
|
+
if (lines.length)
|
|
175
|
+
parts.push(`THE GOAL's lines for this task, verbatim. Where your spec or the contract differs from them, the goal wins:\n${lines.join("\n")}`);
|
|
176
|
+
}
|
|
177
|
+
return { ...t, spec: parts.join("\n\n") };
|
|
178
|
+
});
|
|
179
|
+
return { tasks: out, check };
|
|
180
|
+
}
|
|
181
|
+
/** The goal as QA's numbered acceptance checklist (capped). */
|
|
182
|
+
export function acceptanceList(goal, cap = 8000) {
|
|
183
|
+
const lines = [];
|
|
184
|
+
let n = 0;
|
|
185
|
+
for (const [i, it] of goalItems(goal).entries()) {
|
|
186
|
+
const l = `${i + 1}. ${it.section ? `[${it.section}] ` : ""}${it.text}`;
|
|
187
|
+
if (n + l.length > cap) {
|
|
188
|
+
lines.push(`… (${goalItems(goal).length - i} more in the GOAL above)`);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
lines.push(l);
|
|
192
|
+
n += l.length;
|
|
193
|
+
}
|
|
194
|
+
return lines.join("\n");
|
|
195
|
+
}
|
|
196
|
+
//# sourceMappingURL=goal.js.map
|
|
@@ -33,7 +33,7 @@ const DECL = [
|
|
|
33
33
|
export function exportedSymbols(diff) {
|
|
34
34
|
const out = [];
|
|
35
35
|
let file = "";
|
|
36
|
-
for (const line of diff.split(
|
|
36
|
+
for (const line of diff.split(/\r?\n/)) {
|
|
37
37
|
if (line.startsWith("+++ ")) {
|
|
38
38
|
file = line.replace(/^\+\+\+ (b\/)?/, "");
|
|
39
39
|
continue;
|
|
@@ -56,7 +56,7 @@ export function exportedSymbols(diff) {
|
|
|
56
56
|
export function captureChanges(repoRoot, base) {
|
|
57
57
|
try {
|
|
58
58
|
const files = git(repoRoot, ["diff", "--name-status", "--no-renames", base, "HEAD"])
|
|
59
|
-
.split(
|
|
59
|
+
.split(/\r?\n/)
|
|
60
60
|
.filter(Boolean)
|
|
61
61
|
.map((l) => l.replace(/\t/, " "))
|
|
62
62
|
.slice(0, MAX_FILES);
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
// (3) spawns the agent CLI non-interactively with a prompt that tells it to use
|
|
4
4
|
// the Room. Output goes to .huddle/logs/, not the user's terminal — the Huddle
|
|
5
5
|
// screen shows progress from Room events instead.
|
|
6
|
-
import { spawn } from "node:child_process";
|
|
7
6
|
import fs from "node:fs";
|
|
8
7
|
import path from "node:path";
|
|
9
8
|
import { fileURLToPath } from "node:url";
|
|
10
9
|
import { createWorktree, worktreeFor } from "../sandbox/worktree.js";
|
|
11
10
|
import { UsageMeter } from "../adapters/usage.js";
|
|
11
|
+
import { hookCommand, killTree, spawnTool } from "../platform.js";
|
|
12
|
+
import { lessonsBrief } from "../room/lessons.js";
|
|
13
|
+
import { screenForTask } from "../room/context.js";
|
|
12
14
|
const CHECKIN = `
|
|
13
15
|
You are collaborating with other AI agents in a shared repo via the "huddle" MCP tools.
|
|
14
16
|
RULES:
|
|
@@ -18,11 +20,12 @@ RULES:
|
|
|
18
20
|
Your task's file list is a plan, not a fence: if the work needs another file (e.g. a test wants your module registered in an index),
|
|
19
21
|
announce it and make the edit — Huddle lands same-file edits from different agents safely. Never finish with failing tests
|
|
20
22
|
because a file wasn't on your list; Huddle's merge gate runs the full test suite and rejects red work.
|
|
21
|
-
4. Call huddle_progress() after
|
|
23
|
+
4. Call huddle_progress() at about 25%, 50% and 75% done (and after big steps): its result is how teammates' contracts
|
|
24
|
+
and notes reach you mid-task. Read any new_messages in a result and adjust your work before going on.
|
|
22
25
|
5. Record decisions with huddle_remember(kind="decision", ..., why=...) so teammates learn WHY.
|
|
23
26
|
Unsure about a shape or contract another task owns? huddle_ask("…", to="<agent>") before guessing — teammates may be on other machines.
|
|
24
27
|
Changing something a teammate relies on? Tell them: huddle_send(to="<agent>" or "all", message="…").
|
|
25
|
-
|
|
28
|
+
Answer a teammate's question with huddle_send(reply_to=…).
|
|
26
29
|
A teammate offers help and part of your task is separable? huddle_split it to them instead of doing it all yourself.
|
|
27
30
|
A HANDOFF section below (if any) is what the tasks you depend on built — use those exact names.
|
|
28
31
|
6. Do the work by editing files in your current directory. Don't commit — Huddle tests and merges for you.
|
|
@@ -64,14 +67,28 @@ export function launchAgent(opts) {
|
|
|
64
67
|
if (!opts.dryRun)
|
|
65
68
|
writeJsonMerged(mcpConfigPath, adapter.plug.entry(plug));
|
|
66
69
|
}
|
|
67
|
-
|
|
70
|
+
// Teammates' messages after every tool use, for CLIs that support a hook.
|
|
71
|
+
let hookSettingsPath = null;
|
|
72
|
+
if (adapter.mailHook) {
|
|
73
|
+
hookSettingsPath = path.join(runDir, "hooks.json");
|
|
74
|
+
const command = hookCommand([process.execPath, CLI_PATH, "mail", "--hook", "--stamp", path.join(runDir, "mail.stamp")]);
|
|
75
|
+
if (!opts.dryRun) {
|
|
76
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
77
|
+
fs.writeFileSync(hookSettingsPath, JSON.stringify(adapter.mailHook(command), null, 2));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// The shared screen's index (not its content) + this task's linked items.
|
|
81
|
+
const screen = screenForTask(repoRoot, taskId);
|
|
82
|
+
const prompt = `${CHECKIN}\n\n${lessonsBrief({ agentId, repo: repoRoot, dry: opts.dryRun })}YOUR TASK (task_id: ${taskId}): ${taskSpec}${screen.text ? "\n\n" + screen.text : ""}`;
|
|
68
83
|
const { cmd, cwd, env } = adapter.launch({
|
|
84
|
+
context: screen.dir ? { dir: screen.dir, images: screen.images } : undefined,
|
|
69
85
|
worktree: wt.path,
|
|
70
86
|
prompt,
|
|
71
87
|
model: modelFor(adapter, role),
|
|
72
88
|
mcpConfigPath,
|
|
73
89
|
plug,
|
|
74
90
|
approvals: opts.approvals,
|
|
91
|
+
hookSettingsPath,
|
|
75
92
|
});
|
|
76
93
|
const handle = {
|
|
77
94
|
agentId,
|
|
@@ -88,11 +105,9 @@ export function launchAgent(opts) {
|
|
|
88
105
|
return handle;
|
|
89
106
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
90
107
|
const log = fs.createWriteStream(logPath, { flags: "a" });
|
|
91
|
-
handle.child =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
env: { ...process.env, ...env, ...plug.env },
|
|
95
|
-
});
|
|
108
|
+
handle.child = spawnTool(cmd,
|
|
109
|
+
// MCP_TOOL_TIMEOUT: Claude's wait on one tool call; huddle_ask_user may wait 90s for the person.
|
|
110
|
+
{ cwd, stdio: ["ignore", "pipe", "pipe"], env: { MCP_TOOL_TIMEOUT: "150000", ...process.env, ...env, ...plug.env } }, { prompt, stdinPrompt: adapter.stdinPrompt });
|
|
96
111
|
// Everything still goes to the log; stdout is also scanned for token usage.
|
|
97
112
|
let partial = "";
|
|
98
113
|
handle.child.stdout.on("data", (chunk) => {
|
|
@@ -113,19 +128,22 @@ export function launchAgent(opts) {
|
|
|
113
128
|
opts.onTokens?.(handle.meter.tokens);
|
|
114
129
|
});
|
|
115
130
|
handle.child.stderr.on("data", (chunk) => log.write(chunk));
|
|
116
|
-
|
|
131
|
+
// 'close', not 'exit': output can still arrive after the process exits (its pipes drain later),
|
|
132
|
+
// and a write after end() throws an unhandled stream error that takes the whole run down.
|
|
133
|
+
handle.child.on("close", () => log.end());
|
|
117
134
|
return handle;
|
|
118
135
|
}
|
|
119
136
|
/**
|
|
120
137
|
* Stop an agent's process: SIGTERM, then SIGKILL if it is still alive after
|
|
121
138
|
* `graceMs`. Found live on the capstone: a cursor-agent QA check whose
|
|
122
139
|
* connection stalled ignored SIGTERM, so QA's 15-minute time box did nothing
|
|
123
|
-
* and the run hung for 65 more minutes.
|
|
140
|
+
* and the run hung for 65 more minutes. On Windows the whole process tree goes
|
|
141
|
+
* at once (taskkill /T /F) — there is no SIGTERM, and the agent runs under a shim.
|
|
124
142
|
*/
|
|
125
|
-
export function stopChild(child, graceMs = 5000) {
|
|
143
|
+
export function stopChild(child, graceMs = 5000, platform = process.platform) {
|
|
126
144
|
if (!child || child.exitCode !== null || child.signalCode !== null)
|
|
127
145
|
return;
|
|
128
|
-
child
|
|
146
|
+
killTree(child, { platform });
|
|
129
147
|
setTimeout(() => {
|
|
130
148
|
if (child.exitCode === null && child.signalCode === null)
|
|
131
149
|
child.kill("SIGKILL");
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Learning at the end of a run: turn what Huddle itself saw — a council turn
|
|
2
|
+
// that timed out, a task that stayed red through its fix round, two agents
|
|
3
|
+
// colliding on a file, QA bugs, reviews sent back, a silent agent taken over —
|
|
4
|
+
// into lessons for the next run (see room/lessons.ts). Deterministic: no model
|
|
5
|
+
// call, only the run's own records, so a lesson always has evidence.
|
|
6
|
+
import { addLesson, agentBase, lessonsOff, repoKey } from "../room/lessons.js";
|
|
7
|
+
const MAX_PER_KIND = 3;
|
|
8
|
+
const one = (s, n = 140) => {
|
|
9
|
+
const t = String(s ?? "").split("\n").map((l) => l.trim()).find(Boolean) ?? "";
|
|
10
|
+
return t.length <= n ? t : t.slice(0, n - 1) + "…";
|
|
11
|
+
};
|
|
12
|
+
/** The lessons a run teaches. `repo` is repoKey(root); `source` names the run. */
|
|
13
|
+
export function deriveLessons(r, o) {
|
|
14
|
+
const out = [];
|
|
15
|
+
const repo = `repo:${o.repo}`;
|
|
16
|
+
const add = (d) => {
|
|
17
|
+
if (!out.some((x) => x.scope === d.scope && x.text === d.text))
|
|
18
|
+
out.push({ ...d, source: o.source });
|
|
19
|
+
};
|
|
20
|
+
// A council seat that timed out or never answered.
|
|
21
|
+
for (const e of r.room.filter((e) => e.type === "council.skipped").slice(0, MAX_PER_KIND * 2)) {
|
|
22
|
+
const who = agentBase(String(e.p.agent ?? e.agent ?? ""));
|
|
23
|
+
if (!who)
|
|
24
|
+
continue;
|
|
25
|
+
const reason = String(e.p.reason ?? "no answer");
|
|
26
|
+
add(/time/i.test(reason)
|
|
27
|
+
? { scope: `agent:${who}`, text: `${who}: design-council turns time out on its default model — answer the council in far fewer words than the limit, sections only.`, evidence: `council ${e.p.round ?? ""} skipped: ${reason}` }
|
|
28
|
+
: { scope: `agent:${who}`, text: `${who}: its design-council turn came back empty — answer every council round, even briefly.`, evidence: `council ${e.p.round ?? ""} skipped: ${reason}` });
|
|
29
|
+
}
|
|
30
|
+
// Red through the fix round: the test gate rejected it twice.
|
|
31
|
+
const red = r.events.filter((e) => e.type === "failed" && /test/i.test(String(e.reason ?? "")) && !/stopped by you/.test(String(e.reason)));
|
|
32
|
+
for (const e of red.slice(0, MAX_PER_KIND))
|
|
33
|
+
add({
|
|
34
|
+
scope: `agent:${agentBase(String(e.agent))}`,
|
|
35
|
+
text: `Before huddle_done, run the repo's whole test suite yourself and make it pass: work of yours failed Huddle's test gate even after the fix round.`,
|
|
36
|
+
evidence: `${e.agent} · ${one(e.title)} · ${one(e.reason)}`,
|
|
37
|
+
});
|
|
38
|
+
// A rescue: the first agent crashed, went silent or stayed red.
|
|
39
|
+
for (const e of r.events.filter((e) => e.type === "takeover" && e.kind === "rescue").slice(0, MAX_PER_KIND)) {
|
|
40
|
+
const who = agentBase(String(e.from));
|
|
41
|
+
const why = String(e.reason ?? "");
|
|
42
|
+
add({
|
|
43
|
+
scope: `agent:${who}`,
|
|
44
|
+
text: /silent|quiet|progress|no output/i.test(why)
|
|
45
|
+
? `Post huddle_progress after every step: ${who} went quiet mid-task and another agent took it over.`
|
|
46
|
+
: `${who} lost a task to a takeover (${one(why, 90)}): check in with huddle_progress and keep the tests green as you go.`,
|
|
47
|
+
evidence: `${e.agent} took over ${who}'s "${one(e.title, 60)}": ${one(why)}`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// Two agents on the same lines of one file.
|
|
51
|
+
const collided = new Map();
|
|
52
|
+
for (const e of r.events.filter((e) => e.type === "conflict"))
|
|
53
|
+
for (const f of e.files ?? [])
|
|
54
|
+
collided.set(String(f), (collided.get(String(f)) ?? 0) + 1);
|
|
55
|
+
for (const [file, n] of [...collided].sort((a, b) => b[1] - a[1]).slice(0, MAX_PER_KIND))
|
|
56
|
+
add({ scope: repo, text: `Agents collide on ${file}: plan it as one owner — one task edits it, or every task that must is listed on it.`, evidence: `${n} merge conflict(s) in ${file}` });
|
|
57
|
+
// QA found it after merge: the authors' tests missed it.
|
|
58
|
+
const bugs = r.room.filter((e) => e.type === "qa.bug");
|
|
59
|
+
for (const e of bugs.slice(0, MAX_PER_KIND)) {
|
|
60
|
+
const files = (e.p.files ?? []);
|
|
61
|
+
add({
|
|
62
|
+
scope: repo,
|
|
63
|
+
text: `QA found "${one(e.p.title, 90)}"${files.length ? ` in ${files.slice(0, 2).join(", ")}` : ""} after it merged: test that kind of case before huddle_done.`,
|
|
64
|
+
evidence: `qa.bug by ${e.p.by ?? e.agent ?? "?"}: ${one(e.p.repro, 120)}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// A reviewer sent work back: not a lesson. Its notes are about that one task (found live,
|
|
68
|
+
// 0.1.2: "src/main.js: io.stdout.write() is outside the try/catch…" stored as a rule).
|
|
69
|
+
// A general rule from it is the agent's to write: huddle_remember(kind="lesson").
|
|
70
|
+
// A reviewer that couldn't answer in time.
|
|
71
|
+
const slow = new Set(r.events.filter((e) => e.type === "reviewed" && e.skipped && /time/i.test(String(e.notes ?? ""))).map((e) => agentBase(String(e.agent))));
|
|
72
|
+
for (const who of slow)
|
|
73
|
+
add({ scope: `agent:${who}`, text: `${who}: reviews time out — read the diff in the prompt only and answer APPROVE or the changes in a few lines.`, evidence: "review skipped: timed out" });
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** The run's Room records learning reads. */
|
|
77
|
+
export function roomRecords(db, since) {
|
|
78
|
+
return db.eventsOfType(["council.skipped", "qa.bug"], since, 200).map((e) => {
|
|
79
|
+
let p = {};
|
|
80
|
+
try {
|
|
81
|
+
p = JSON.parse(e.payload || "{}");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* keep {} */
|
|
85
|
+
}
|
|
86
|
+
return { type: e.type, agent: e.agent_id ?? null, p };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** Learn from a finished run and store it. Never throws: learning is a bonus, not a step that can fail a run. */
|
|
90
|
+
export function learnFromRun(o) {
|
|
91
|
+
if (lessonsOff())
|
|
92
|
+
return [];
|
|
93
|
+
try {
|
|
94
|
+
const drafts = deriveLessons({ events: o.events, room: roomRecords(o.db, o.since) }, { repo: repoKey(o.root), source: `run:${new Date(o.since).toISOString().slice(0, 19)}` });
|
|
95
|
+
return drafts.map((d) => addLesson(d, o.home)?.lesson).filter((l) => !!l);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=learn.js.map
|
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
// any reason we fall back to a small deterministic template so a run never
|
|
4
4
|
// blocks on planning.
|
|
5
5
|
import { brainFrom, repoContext } from "./brain.js";
|
|
6
|
+
import { linkPlan, parseContextIds } from "../room/context.js";
|
|
7
|
+
import { carryGoal } from "./goal.js";
|
|
8
|
+
/** The JSON fields the planner asks for, in both plan shapes. */
|
|
9
|
+
const PLAIN_FIELDS = `"plain":"one or two short sentences a non-programmer understands: what the finished thing is and what it lets them do (no jargon: no exit codes, modules, ESM)","example":"one concrete example of using it and what you'd see, e.g. todo add milk → added: milk (or \"\" if there's nothing to show)"`;
|
|
10
|
+
/** The plain summary from a plan reply, if the planner wrote one. */
|
|
11
|
+
export function parsePlain(text) {
|
|
12
|
+
for (const body of jsonCandidates(text)) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = JSON.parse(body);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (!Array.isArray(raw?.tasks))
|
|
21
|
+
continue;
|
|
22
|
+
const tidy = (x, n) => (typeof x === "string" ? x.replace(/\s+/g, " ").trim().slice(0, n) : "");
|
|
23
|
+
const what = tidy(raw.plain ?? raw.summary, 400);
|
|
24
|
+
if (!what)
|
|
25
|
+
return undefined;
|
|
26
|
+
const example = tidy(raw.example, 200);
|
|
27
|
+
return example ? { what, example } : { what };
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
6
31
|
/**
|
|
7
32
|
* Fallback when no brain can plan: the whole goal as ONE task. (It used to be
|
|
8
33
|
* a scaffold → UI → logic → tests → review spine; seen live, a planner hiccup
|
|
@@ -50,22 +75,24 @@ ${context}
|
|
|
50
75
|
|
|
51
76
|
${council ? `${council.notes}
|
|
52
77
|
|
|
53
|
-
` : ""}Plan from this snapshot${council ? " and the council's proposals and
|
|
78
|
+
` : ""}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:
|
|
54
79
|
${sizeRules(crew.length, opts.project ?? !!council)}
|
|
55
80
|
- 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.
|
|
56
81
|
- 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.
|
|
57
82
|
- 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.
|
|
58
83
|
- Use depends_on only when a task truly needs another task's merged code.
|
|
59
84
|
- Each spec must be self-contained: what to build, where, the contract, and how to verify it.
|
|
85
|
+
- 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.
|
|
60
86
|
- 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.
|
|
61
87
|
- prefers: 1-3 tags from: ${TAGS.join(", ")}.
|
|
62
|
-
|
|
88
|
+
${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.
|
|
89
|
+
` : ""}
|
|
63
90
|
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.
|
|
64
91
|
Reply with ONLY this JSON, no prose, no code fences:
|
|
65
92
|
${council
|
|
66
|
-
? `{"tasks":[{"title":"short imperative title","spec":"...","files":["path"],"prefers":["tag"],"depends_on":[0],"from":
|
|
67
|
-
"from"
|
|
68
|
-
: `{"tasks":[{"title":"short imperative title","spec":"...","files":["path"],"prefers":["tag"],"depends_on":[0]}]}`}`;
|
|
93
|
+
? `{"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",${PLAIN_FIELDS}}
|
|
94
|
+
"from" and "with" use only these ids: ${council.members.join(", ")}.`
|
|
95
|
+
: `{"tasks":[{"title":"short imperative title","spec":"...","files":["path"],"prefers":["tag"],"depends_on":[0]}],${PLAIN_FIELDS}}`}`;
|
|
69
96
|
}
|
|
70
97
|
/** Candidate JSON bodies in a reply: fenced blocks first, then brace-to-brace. */
|
|
71
98
|
export function jsonCandidates(text) {
|
|
@@ -79,6 +106,12 @@ export function jsonCandidates(text) {
|
|
|
79
106
|
}
|
|
80
107
|
/** The most tasks a plan may have; a longer reply is not a plan. */
|
|
81
108
|
export const MAX_TASKS = 20;
|
|
109
|
+
/** A council task's idea credit: "from" is one primary id (or, from older prompts, a list), "with" the contributors. */
|
|
110
|
+
function ideaFrom(t) {
|
|
111
|
+
const primary = typeof t.from === "string" ? [t.from] : Array.isArray(t.from) ? t.from.map(String) : [];
|
|
112
|
+
const from = [...new Set([...primary, ...(Array.isArray(t.with) ? t.with.map(String) : [])].map((x) => x.trim()).filter(Boolean))];
|
|
113
|
+
return from.length ? { from } : {};
|
|
114
|
+
}
|
|
82
115
|
/** Pull a {"tasks": [...]} plan out of a model's reply, however it was wrapped. */
|
|
83
116
|
export function parsePlan(text) {
|
|
84
117
|
for (const body of jsonCandidates(text)) {
|
|
@@ -102,7 +135,8 @@ export function parsePlan(text) {
|
|
|
102
135
|
spec: String(t.spec ?? ""),
|
|
103
136
|
files: Array.isArray(t.files) ? t.files.map(String) : [],
|
|
104
137
|
prefers: Array.isArray(t.prefers) ? t.prefers.map(String) : [],
|
|
105
|
-
...(
|
|
138
|
+
...ideaFrom(t),
|
|
139
|
+
...(parseContextIds(t.context).length ? { context: parseContextIds(t.context) } : {}),
|
|
106
140
|
// only backward edges → guaranteed acyclic
|
|
107
141
|
depends_on: (Array.isArray(t.depends_on) ? t.depends_on : []).map(toIndex).filter((d) => Number.isInteger(d) && d >= 0 && d < i),
|
|
108
142
|
}));
|
|
@@ -120,8 +154,8 @@ export async function planGoal(goal, crew, repoRoot, opts = {}) {
|
|
|
120
154
|
signal: opts.signal,
|
|
121
155
|
// A plan takes ~6–15s on a cloud model; a call that hangs is retried
|
|
122
156
|
// instead of eating the whole budget (seen live: 4m30s, then the
|
|
123
|
-
// fallback). Local models
|
|
124
|
-
timeoutMs: brain.spec.startsWith("ollama") ? 4 * 60_000 : 2 * 60_000,
|
|
157
|
+
// fallback). Local models and Opus (the default brain) are slower.
|
|
158
|
+
timeoutMs: brain.spec.startsWith("ollama") || brain.spec === "claude" || brain.spec.endsWith(":opus") ? 4 * 60_000 : 2 * 60_000,
|
|
125
159
|
json: true,
|
|
126
160
|
// The snapshot is in the prompt; exploring just costs time. If that
|
|
127
161
|
// first answer isn't a plan, the retry may read the repo (read-only).
|
|
@@ -129,11 +163,20 @@ export async function planGoal(goal, crew, repoRoot, opts = {}) {
|
|
|
129
163
|
});
|
|
130
164
|
ms += res.ms;
|
|
131
165
|
const tasks = res.ok ? parsePlan(res.text) : null;
|
|
166
|
+
if (tasks && opts.project && tasks.length > 1) {
|
|
167
|
+
// A project's specs carry the goal's exact requirements too, as the council's do (goal.ts).
|
|
168
|
+
const carried = carryGoal(goal, tasks);
|
|
169
|
+
return { tasks: carried.tasks, by: brain.label, ms, tokens: res.tokens, goalCheck: carried.check, ...withPlain(res.text) };
|
|
170
|
+
}
|
|
132
171
|
if (tasks)
|
|
133
|
-
return { tasks, by: brain.label, ms, tokens: res.tokens };
|
|
172
|
+
return { tasks, by: brain.label, ms, tokens: res.tokens, ...withPlain(res.text) };
|
|
134
173
|
}
|
|
135
174
|
return { tasks: templatePlan(goal), by: "template", note: `${brain.label} didn't return a usable plan`, ms };
|
|
136
175
|
}
|
|
176
|
+
const withPlain = (text) => {
|
|
177
|
+
const plain = parsePlain(text);
|
|
178
|
+
return plain ? { plain } : {};
|
|
179
|
+
};
|
|
137
180
|
/** Materialize a plan into the Room, wiring up dependency ids correctly. */
|
|
138
181
|
export function seedBoard(db, goal, planned) {
|
|
139
182
|
db.createSession(goal);
|
|
@@ -149,6 +192,8 @@ export function seedBoard(db, goal, planned) {
|
|
|
149
192
|
const task = db.addTask({ title: p.title, spec, prefers: p.prefers, depends_on: deps, files: p.files });
|
|
150
193
|
ids.push(task.id);
|
|
151
194
|
}
|
|
195
|
+
// Shared-screen items the planner tied to each task ride in that task's prompt.
|
|
196
|
+
linkPlan(db, ids, planned);
|
|
152
197
|
return ids;
|
|
153
198
|
}
|
|
154
199
|
/** "(1) … (2) …", "1. … 2. …" or "three tasks": the user already split the work. */
|