huddle-cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +102 -0
- package/README.md +376 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +54 -0
- package/dist/adapters/claude.js +86 -0
- package/dist/adapters/codex.js +64 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +92 -0
- package/dist/adapters/custom.js +342 -0
- package/dist/adapters/droid.js +58 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +57 -0
- package/dist/adapters/index.js +161 -0
- package/dist/adapters/kiro.js +41 -0
- package/dist/adapters/opencode.js +63 -0
- package/dist/adapters/qwen.js +67 -0
- package/dist/adapters/types.js +6 -0
- package/dist/adapters/usage.js +141 -0
- package/dist/attach/connect.js +412 -0
- package/dist/attach/guard.js +83 -0
- package/dist/attach/live.js +226 -0
- package/dist/attach/plug.js +185 -0
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +177 -0
- package/dist/cli.js +244 -0
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +267 -0
- package/dist/orchestrator/ask.js +54 -0
- package/dist/orchestrator/brain.js +174 -0
- package/dist/orchestrator/council.js +615 -0
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +171 -0
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +212 -0
- package/dist/orchestrator/qa.js +398 -0
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +1207 -0
- package/dist/orchestrator/takeover.js +240 -0
- package/dist/platform.js +273 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +680 -0
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +348 -0
- package/dist/room/mail.js +212 -0
- package/dist/room/progress.js +149 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +882 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +86 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +392 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1955 -0
- package/dist/ui/catalog.js +113 -0
- package/dist/ui/dashboard.js +335 -0
- package/dist/ui/doctor.js +58 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +338 -0
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +1529 -0
- package/dist/ui/term.js +249 -0
- package/dist/ui/watch.js +271 -0
- package/install.sh +44 -0
- package/package.json +63 -4
|
@@ -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
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Handoffs: when a task merges, the agents whose tasks depended on it start
|
|
2
|
+
// with a compact brief from the author — what changed, the exact signatures it
|
|
3
|
+
// exports, and the notes it left — instead of rediscovering it from the code.
|
|
4
|
+
//
|
|
5
|
+
// Captured right after the merge lands (base = main before the fast-forward),
|
|
6
|
+
// stored in the Room, and injected into each dependent's launch prompt.
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
const MAX_FILES = 30;
|
|
9
|
+
const MAX_SYMBOLS = 25;
|
|
10
|
+
const MAX_NOTES = 8;
|
|
11
|
+
function git(cwd, args) {
|
|
12
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 32 * 1024 * 1024 }).trim();
|
|
13
|
+
}
|
|
14
|
+
export function headSha(repoRoot) {
|
|
15
|
+
try {
|
|
16
|
+
return git(repoRoot, ["rev-parse", "HEAD"]);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Lines that declare something other code can use. Deliberately simple: the
|
|
23
|
+
// signature line is what a dependent needs, not a parse tree.
|
|
24
|
+
const DECL = [
|
|
25
|
+
/^\s*export\s+(default\s+)?(declare\s+)?(async\s+)?(function\*?|class|interface|type|enum|const|let|var|abstract\s+class)\s+[\w$]+/, // TS / JS
|
|
26
|
+
/^\s*export\s*\{[^}]+\}/, // export { a, b }
|
|
27
|
+
/^module\.exports\b|^exports\.[\w$]+\s*=/, // CommonJS
|
|
28
|
+
/^(async\s+)?def\s+[A-Za-z]\w*\s*\(|^class\s+[A-Za-z]\w*/, // Python (top level, public)
|
|
29
|
+
/^func\s+(\([^)]*\)\s*)?[A-Z]\w*\s*\(|^type\s+[A-Z]\w*\s/, // Go (exported)
|
|
30
|
+
/^\s*pub\s+(async\s+)?(fn|struct|enum|trait|type|const|mod)\s+\w+/, // Rust
|
|
31
|
+
];
|
|
32
|
+
/** Exported signature lines added by a unified diff. */
|
|
33
|
+
export function exportedSymbols(diff) {
|
|
34
|
+
const out = [];
|
|
35
|
+
let file = "";
|
|
36
|
+
for (const line of diff.split(/\r?\n/)) {
|
|
37
|
+
if (line.startsWith("+++ ")) {
|
|
38
|
+
file = line.replace(/^\+\+\+ (b\/)?/, "");
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!line.startsWith("+") || line.startsWith("+++"))
|
|
42
|
+
continue;
|
|
43
|
+
const src = line.slice(1);
|
|
44
|
+
if (!DECL.some((r) => r.test(src)))
|
|
45
|
+
continue;
|
|
46
|
+
const sig = src.trim().replace(/\s*[{=]>?\s*$/, "").replace(/\s+/g, " ").slice(0, 160);
|
|
47
|
+
const entry = `${file}: ${sig}`;
|
|
48
|
+
if (!out.includes(entry))
|
|
49
|
+
out.push(entry);
|
|
50
|
+
if (out.length >= MAX_SYMBOLS)
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** What a merge changed on main: name-status lines and exported signatures. */
|
|
56
|
+
export function captureChanges(repoRoot, base) {
|
|
57
|
+
try {
|
|
58
|
+
const files = git(repoRoot, ["diff", "--name-status", "--no-renames", base, "HEAD"])
|
|
59
|
+
.split(/\r?\n/)
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.map((l) => l.replace(/\t/, " "))
|
|
62
|
+
.slice(0, MAX_FILES);
|
|
63
|
+
const diff = git(repoRoot, ["diff", "--unified=0", "--no-color", base, "HEAD"]);
|
|
64
|
+
return { files, symbols: exportedSymbols(diff) };
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { files: [], symbols: [] };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Record the handoff for a just-merged task (base = main before it landed). */
|
|
71
|
+
export function recordHandoff(db, repoRoot, opts) {
|
|
72
|
+
const { files, symbols } = opts.base ? captureChanges(repoRoot, opts.base) : { files: [], symbols: [] };
|
|
73
|
+
// The author's own notes from this task (its "Task … done" line is the summary).
|
|
74
|
+
const notes = db
|
|
75
|
+
.recall()
|
|
76
|
+
.filter((m) => m.author === opts.agentId && m.created_at >= opts.since && !m.body.startsWith(`Task ${opts.taskId} done:`))
|
|
77
|
+
.reverse()
|
|
78
|
+
.slice(0, MAX_NOTES)
|
|
79
|
+
.map((m) => `${m.kind}: ${m.body}${m.why ? ` (why: ${m.why})` : ""}`.slice(0, 400));
|
|
80
|
+
return db.saveHandoff({ task_id: opts.taskId, agent_id: opts.agentId, title: opts.title, files, symbols, notes });
|
|
81
|
+
}
|
|
82
|
+
export function handoffText(h) {
|
|
83
|
+
const lines = [`— from ${h.agent_id}, task "${h.title}" (${h.task_id}), merged into main:`];
|
|
84
|
+
if (h.summary)
|
|
85
|
+
lines.push(` summary: ${h.summary}`);
|
|
86
|
+
if (h.files.length)
|
|
87
|
+
lines.push(` files: ${h.files.join(", ")}`);
|
|
88
|
+
if (h.symbols.length) {
|
|
89
|
+
lines.push(" exports (use these exact names/signatures):");
|
|
90
|
+
for (const s of h.symbols)
|
|
91
|
+
lines.push(` ${s}`);
|
|
92
|
+
}
|
|
93
|
+
if (h.notes.length) {
|
|
94
|
+
lines.push(" notes:");
|
|
95
|
+
for (const n of h.notes)
|
|
96
|
+
lines.push(` - ${n}`);
|
|
97
|
+
}
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The handoff block for a task's launch prompt: one entry per merged
|
|
102
|
+
* dependency that has one. Empty string when there's nothing to hand off.
|
|
103
|
+
*/
|
|
104
|
+
export function handoffsFor(db, taskId) {
|
|
105
|
+
const task = db.getTask(taskId);
|
|
106
|
+
if (!task)
|
|
107
|
+
return { text: "", from: [] };
|
|
108
|
+
const deps = JSON.parse(task.depends_on || "[]");
|
|
109
|
+
const from = deps.map((d) => db.getHandoff(d)).filter((h) => !!h);
|
|
110
|
+
if (!from.length)
|
|
111
|
+
return { text: "", from };
|
|
112
|
+
return {
|
|
113
|
+
text: `\n\nHANDOFF — the tasks yours depends on are merged; their work is already in your directory:\n${from.map(handoffText).join("\n")}`,
|
|
114
|
+
from,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=handoff.js.map
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// The launcher: for a scheduled task, it (1) makes the agent's sandbox worktree,
|
|
2
|
+
// (2) hands the agent the Plug (MCP) in whatever way its vendor expects,
|
|
3
|
+
// (3) spawns the agent CLI non-interactively with a prompt that tells it to use
|
|
4
|
+
// the Room. Output goes to .huddle/logs/, not the user's terminal — the Huddle
|
|
5
|
+
// screen shows progress from Room events instead.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { createWorktree, worktreeFor } from "../sandbox/worktree.js";
|
|
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";
|
|
14
|
+
const CHECKIN = `
|
|
15
|
+
You are collaborating with other AI agents in a shared repo via the "huddle" MCP tools.
|
|
16
|
+
RULES:
|
|
17
|
+
1. Call huddle_recall() and huddle_board() BEFORE you start — inherit context, don't re-derive it.
|
|
18
|
+
2. Your task below is already claimed for you — don't claim it again.
|
|
19
|
+
3. Call huddle_announce_plan() with the files/symbols you'll touch BEFORE editing. If huddle_check_overlap flags a teammate, coordinate via huddle_remember instead of colliding.
|
|
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),
|
|
21
|
+
announce it and make the edit — Huddle lands same-file edits from different agents safely. Never finish with failing tests
|
|
22
|
+
because a file wasn't on your list; Huddle's merge gate runs the full test suite and rejects red work.
|
|
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.
|
|
25
|
+
5. Record decisions with huddle_remember(kind="decision", ..., why=...) so teammates learn WHY.
|
|
26
|
+
Unsure about a shape or contract another task owns? huddle_ask("…", to="<agent>") before guessing — teammates may be on other machines.
|
|
27
|
+
Changing something a teammate relies on? Tell them: huddle_send(to="<agent>" or "all", message="…").
|
|
28
|
+
Answer a teammate's question with huddle_send(reply_to=…).
|
|
29
|
+
A teammate offers help and part of your task is separable? huddle_split it to them instead of doing it all yourself.
|
|
30
|
+
A HANDOFF section below (if any) is what the tasks you depend on built — use those exact names.
|
|
31
|
+
6. Do the work by editing files in your current directory. Don't commit — Huddle tests and merges for you.
|
|
32
|
+
7. When finished, call huddle_done(task_id, summary).
|
|
33
|
+
`.trim();
|
|
34
|
+
// dist/orchestrator/launcher.js -> dist/cli.js
|
|
35
|
+
const CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
36
|
+
function modelFor(adapter, role) {
|
|
37
|
+
return role === "orchestrator"
|
|
38
|
+
? adapter.models.orchestrator
|
|
39
|
+
: role === "fast"
|
|
40
|
+
? adapter.models.fast
|
|
41
|
+
: role === "reviewer"
|
|
42
|
+
? adapter.models.reviewer
|
|
43
|
+
: adapter.models.worker;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Launch `agentId` (an instance of `adapter`) on a task. `dryRun` returns the
|
|
47
|
+
* exact command without creating a worktree or spawning anything.
|
|
48
|
+
*/
|
|
49
|
+
export function launchAgent(opts) {
|
|
50
|
+
const { repoRoot, adapter, agentId, role, taskId, taskSpec, roomUrl } = opts;
|
|
51
|
+
const wt = opts.worktree ?? (opts.dryRun ? worktreeFor(repoRoot, agentId, taskId) : createWorktree(repoRoot, agentId, taskId));
|
|
52
|
+
const slug = `${agentId}-${taskId}`.replace(/[^\w.-]/g, "_");
|
|
53
|
+
const runDir = path.join(repoRoot, ".huddle", "run", slug);
|
|
54
|
+
const logPath = path.join(repoRoot, ".huddle", "logs", `${slug}.log`);
|
|
55
|
+
const plug = {
|
|
56
|
+
command: process.execPath,
|
|
57
|
+
args: [CLI_PATH, "plug"],
|
|
58
|
+
env: { HUDDLE_ROOM_URL: roomUrl, HUDDLE_AGENT_ID: agentId, HUDDLE_SECRET: opts.roomSecret ?? "" },
|
|
59
|
+
};
|
|
60
|
+
let mcpConfigPath = null;
|
|
61
|
+
const excludes = [];
|
|
62
|
+
if (adapter.plug.file && adapter.plug.entry) {
|
|
63
|
+
const inWorktree = adapter.plug.file.in === "worktree";
|
|
64
|
+
mcpConfigPath = path.join(inWorktree ? wt.path : runDir, adapter.plug.file.path);
|
|
65
|
+
if (inWorktree)
|
|
66
|
+
excludes.push(adapter.plug.file.path);
|
|
67
|
+
if (!opts.dryRun)
|
|
68
|
+
writeJsonMerged(mcpConfigPath, adapter.plug.entry(plug));
|
|
69
|
+
}
|
|
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 : ""}`;
|
|
83
|
+
const { cmd, cwd, env } = adapter.launch({
|
|
84
|
+
context: screen.dir ? { dir: screen.dir, images: screen.images } : undefined,
|
|
85
|
+
worktree: wt.path,
|
|
86
|
+
prompt,
|
|
87
|
+
model: modelFor(adapter, role),
|
|
88
|
+
mcpConfigPath,
|
|
89
|
+
plug,
|
|
90
|
+
approvals: opts.approvals,
|
|
91
|
+
hookSettingsPath,
|
|
92
|
+
});
|
|
93
|
+
const handle = {
|
|
94
|
+
agentId,
|
|
95
|
+
taskId,
|
|
96
|
+
worktree: wt.path,
|
|
97
|
+
branch: wt.branch,
|
|
98
|
+
cmd,
|
|
99
|
+
logPath,
|
|
100
|
+
excludes,
|
|
101
|
+
startedAt: Date.now(),
|
|
102
|
+
meter: new UsageMeter(),
|
|
103
|
+
};
|
|
104
|
+
if (opts.dryRun)
|
|
105
|
+
return handle;
|
|
106
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
107
|
+
const log = fs.createWriteStream(logPath, { flags: "a" });
|
|
108
|
+
handle.child = spawnTool(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env, ...plug.env } }, { prompt, stdinPrompt: adapter.stdinPrompt });
|
|
109
|
+
// Everything still goes to the log; stdout is also scanned for token usage.
|
|
110
|
+
let partial = "";
|
|
111
|
+
handle.child.stdout.on("data", (chunk) => {
|
|
112
|
+
log.write(chunk);
|
|
113
|
+
if (!adapter.usage)
|
|
114
|
+
return;
|
|
115
|
+
const lines = (partial + chunk.toString("utf8")).split("\n");
|
|
116
|
+
partial = lines.pop() ?? "";
|
|
117
|
+
let changed = false;
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
const r = adapter.usage(line.trim());
|
|
120
|
+
if (r) {
|
|
121
|
+
handle.meter.add(r);
|
|
122
|
+
changed = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (changed)
|
|
126
|
+
opts.onTokens?.(handle.meter.tokens);
|
|
127
|
+
});
|
|
128
|
+
handle.child.stderr.on("data", (chunk) => log.write(chunk));
|
|
129
|
+
// 'close', not 'exit': output can still arrive after the process exits (its pipes drain later),
|
|
130
|
+
// and a write after end() throws an unhandled stream error that takes the whole run down.
|
|
131
|
+
handle.child.on("close", () => log.end());
|
|
132
|
+
return handle;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Stop an agent's process: SIGTERM, then SIGKILL if it is still alive after
|
|
136
|
+
* `graceMs`. Found live on the capstone: a cursor-agent QA check whose
|
|
137
|
+
* connection stalled ignored SIGTERM, so QA's 15-minute time box did nothing
|
|
138
|
+
* and the run hung for 65 more minutes. On Windows the whole process tree goes
|
|
139
|
+
* at once (taskkill /T /F) — there is no SIGTERM, and the agent runs under a shim.
|
|
140
|
+
*/
|
|
141
|
+
export function stopChild(child, graceMs = 5000, platform = process.platform) {
|
|
142
|
+
if (!child || child.exitCode !== null || child.signalCode !== null)
|
|
143
|
+
return;
|
|
144
|
+
killTree(child, { platform });
|
|
145
|
+
setTimeout(() => {
|
|
146
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
147
|
+
child.kill("SIGKILL");
|
|
148
|
+
}, graceMs).unref();
|
|
149
|
+
}
|
|
150
|
+
// --- tiny helpers ---------------------------------------------------------
|
|
151
|
+
function writeJsonMerged(file, entry) {
|
|
152
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
153
|
+
let existing = {};
|
|
154
|
+
if (fs.existsSync(file)) {
|
|
155
|
+
try {
|
|
156
|
+
existing = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
/* overwrite malformed */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
fs.writeFileSync(file, JSON.stringify(deepMerge(existing, entry), null, 2));
|
|
163
|
+
}
|
|
164
|
+
function deepMerge(a, b) {
|
|
165
|
+
const out = { ...a };
|
|
166
|
+
for (const [k, v] of Object.entries(b)) {
|
|
167
|
+
out[k] = v && typeof v === "object" && !Array.isArray(v) ? deepMerge(a[k] ?? {}, v) : v;
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=launcher.js.map
|
|
@@ -0,0 +1,105 @@
|
|
|
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.
|
|
68
|
+
for (const e of r.events.filter((e) => e.type === "reviewed" && !e.approve && !e.skipped).slice(0, MAX_PER_KIND))
|
|
69
|
+
add({
|
|
70
|
+
scope: repo,
|
|
71
|
+
text: `A review sent "${one(e.title, 60)}" back: ${one(e.notes, 160)}`,
|
|
72
|
+
evidence: `${e.agent} reviewed ${e.author}'s work`,
|
|
73
|
+
});
|
|
74
|
+
// A reviewer that couldn't answer in time.
|
|
75
|
+
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))));
|
|
76
|
+
for (const who of slow)
|
|
77
|
+
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" });
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/** The run's Room records learning reads. */
|
|
81
|
+
export function roomRecords(db, since) {
|
|
82
|
+
return db.eventsOfType(["council.skipped", "qa.bug"], since, 200).map((e) => {
|
|
83
|
+
let p = {};
|
|
84
|
+
try {
|
|
85
|
+
p = JSON.parse(e.payload || "{}");
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* keep {} */
|
|
89
|
+
}
|
|
90
|
+
return { type: e.type, agent: e.agent_id ?? null, p };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Learn from a finished run and store it. Never throws: learning is a bonus, not a step that can fail a run. */
|
|
94
|
+
export function learnFromRun(o) {
|
|
95
|
+
if (lessonsOff())
|
|
96
|
+
return [];
|
|
97
|
+
try {
|
|
98
|
+
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)}` });
|
|
99
|
+
return drafts.map((d) => addLesson(d, o.home)?.lesson).filter((l) => !!l);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=learn.js.map
|