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.
Files changed (73) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/QUICKSTART.md +135 -0
  3. package/README.md +26 -4
  4. package/dist/adapters/amp.js +1 -1
  5. package/dist/adapters/antigravity.js +2 -0
  6. package/dist/adapters/claude.js +19 -3
  7. package/dist/adapters/codex.js +9 -1
  8. package/dist/adapters/cursor.js +12 -5
  9. package/dist/adapters/custom.js +4 -2
  10. package/dist/adapters/droid.js +1 -0
  11. package/dist/adapters/goose.js +2 -0
  12. package/dist/adapters/index.js +44 -18
  13. package/dist/adapters/kiro.js +1 -1
  14. package/dist/adapters/opencode.js +1 -1
  15. package/dist/app/live.js +142 -0
  16. package/dist/app/page.js +701 -0
  17. package/dist/app/server.js +262 -0
  18. package/dist/args.js +18 -0
  19. package/dist/attach/connect.js +7 -4
  20. package/dist/attach/guard.js +21 -2
  21. package/dist/attach/live.js +5 -1
  22. package/dist/attach/plug.js +21 -3
  23. package/dist/attach/screen.js +150 -0
  24. package/dist/attach/session.js +7 -2
  25. package/dist/cli.js +134 -42
  26. package/dist/mcp/context-tools.js +79 -0
  27. package/dist/mcp/server.js +57 -11
  28. package/dist/orchestrator/ask.js +13 -10
  29. package/dist/orchestrator/brain.js +16 -4
  30. package/dist/orchestrator/budget.js +283 -0
  31. package/dist/orchestrator/busy.js +106 -0
  32. package/dist/orchestrator/council.js +434 -87
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +2 -2
  35. package/dist/orchestrator/launcher.js +31 -13
  36. package/dist/orchestrator/learn.js +101 -0
  37. package/dist/orchestrator/planner.js +54 -9
  38. package/dist/orchestrator/qa.js +71 -15
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +575 -50
  41. package/dist/orchestrator/takeover.js +155 -0
  42. package/dist/platform.js +377 -0
  43. package/dist/room/context.js +1285 -0
  44. package/dist/room/contract.js +132 -0
  45. package/dist/room/db.js +79 -16
  46. package/dist/room/followup.js +233 -0
  47. package/dist/room/identity.js +2 -2
  48. package/dist/room/lessons.js +275 -0
  49. package/dist/room/live.js +75 -20
  50. package/dist/room/mail.js +225 -0
  51. package/dist/room/progress.js +40 -1
  52. package/dist/room/server.js +220 -39
  53. package/dist/room/tunnel.js +6 -5
  54. package/dist/room/user.js +54 -0
  55. package/dist/sandbox/sync.js +4 -4
  56. package/dist/sandbox/worktree.js +145 -14
  57. package/dist/ui/app.js +963 -159
  58. package/dist/ui/brief.js +494 -0
  59. package/dist/ui/catalog.js +62 -24
  60. package/dist/ui/dashboard.js +269 -55
  61. package/dist/ui/doctor.js +15 -11
  62. package/dist/ui/export.js +1194 -0
  63. package/dist/ui/lessons.js +81 -0
  64. package/dist/ui/narrator.js +56 -0
  65. package/dist/ui/paste.js +515 -0
  66. package/dist/ui/remote.js +11 -5
  67. package/dist/ui/setup.js +152 -0
  68. package/dist/ui/story.js +716 -94
  69. package/dist/ui/talk.js +176 -0
  70. package/dist/ui/term.js +182 -16
  71. package/dist/ui/transcript.js +434 -0
  72. package/dist/ui/watch.js +149 -48
  73. package/package.json +6 -3
@@ -0,0 +1,494 @@
1
+ // The hand-off brief: memory that outlives the run. When the team leaves, a
2
+ // solo `claude` / `codex` / `cursor` opened in the repo knew nothing about what
3
+ // they built or agreed. Every run now ends with .huddle/BRIEF.md (and a copy
4
+ // per run in .huddle/runs/<id>/), written from the Room's records alone — no
5
+ // model call — and a short pointer to it in the files agents read on their own:
6
+ // CLAUDE.md (claude), AGENTS.md (codex, cursor and others).
7
+ //
8
+ // The pointer is a marked block; nothing outside the markers is ever touched.
9
+ // It is never committed. A file Huddle created goes in .git/info/exclude; a
10
+ // file you own keeps the block as a working-tree change only between runs:
11
+ // each run lifts it out first (so your checkout is as you left it, the
12
+ // "uncommitted changes" check and merges into main never trip on it, and task
13
+ // agents don't read a stale pointer), and puts it back at the end.
14
+ //
15
+ // huddle brief print the latest brief
16
+ // huddle brief --run <n|id> one run's brief (number from `huddle story --list`)
17
+ // huddle brief off | on stop / resume the pointers in CLAUDE.md and AGENTS.md
18
+ // HUDDLE_NO_BRIEF=1 no brief at all
19
+ import { execFileSync } from "node:child_process";
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { buildStory, goalTitle, pickRun, splitRuns } from "./story.js";
23
+ import { C } from "./term.js";
24
+ export const BRIEF_START = "<!-- huddle:brief start -->";
25
+ export const BRIEF_END = "<!-- huddle:brief end -->";
26
+ /** The files agents read by themselves. GEMINI.md only when it's already there. */
27
+ export const POINTER_FILES = ["CLAUDE.md", "AGENTS.md"];
28
+ const IF_PRESENT = ["GEMINI.md"];
29
+ /** Big enough for the decisions and open issues, small enough that an agent reads it whole. */
30
+ export const BRIEF_CAP = 6000;
31
+ const EXCLUDE_START = "# >>> huddle brief >>>";
32
+ const EXCLUDE_END = "# <<< huddle brief <<<";
33
+ export const briefOff = () => !!process.env.HUDDLE_NO_BRIEF && process.env.HUDDLE_NO_BRIEF !== "0";
34
+ const flat = (s, n = 200) => {
35
+ const l = String(s ?? "").replace(/\s+/g, " ").trim();
36
+ return l.length <= n ? l : l.slice(0, n - 1) + "…";
37
+ };
38
+ const str = (v) => (v === undefined || v === null ? "" : typeof v === "string" ? v : JSON.stringify(v));
39
+ const arr = (v) => (Array.isArray(v) ? v.map(str).filter(Boolean) : []);
40
+ /** Forward slashes, repo-relative where we can tell: a brief written on Windows reads the same everywhere. */
41
+ export const slash = (f) => f.replace(/\\/g, "/").replace(/^\.\//, "");
42
+ const date = (ts) => (ts ? new Date(ts).toISOString().slice(0, 16).replace("T", " ") + " UTC" : "");
43
+ /** At most `n` lines, then how many more. */
44
+ const capped = (lines, n, more = "more in `huddle story`") => (lines.length > n ? [...lines.slice(0, n), `- …and ${lines.length - n} ${more}`] : lines);
45
+ /** The brief for one run. Deterministic: the same Room records give the same text. */
46
+ export function buildBrief(s, x = {}) {
47
+ const ev = s.run.events;
48
+ const titleOf = new Map();
49
+ for (const e of ev)
50
+ if (e.type === "task.added")
51
+ titleOf.set(str(e.p.id) || e.task || "", str(e.p.title));
52
+ const tt = (task, p) => str(p.title) || (task ? titleOf.get(task) ?? task : "");
53
+ // Merged tasks, in order, with who landed them and what they said they did.
54
+ const merged = ev.filter((e) => e.type === "run.merged").map((e) => ({ task: e.task ?? "", agent: str(e.p.agent) || e.agent || "?", title: tt(e.task, e.p) }));
55
+ const landed = new Set(merged.map((m) => m.task));
56
+ const done = new Map();
57
+ for (const e of ev) {
58
+ const m = e.type === "memory.added" ? /^Task (\S+) done:\s*([\s\S]*)$/.exec(str(e.p.body)) : null;
59
+ if (m)
60
+ done.set(m[1], m[2]);
61
+ }
62
+ const planned = new Map(s.plan.tasks.map((t) => [t.id, t.files]));
63
+ const out = [];
64
+ const agents = s.agents.length ? s.agents.join(", ") : "no agents";
65
+ out.push(`# Huddle hand-off brief`);
66
+ out.push("");
67
+ out.push(`Run ${s.run.index}${s.run.id ? ` (${s.run.id})` : ""} · ${date(s.run.start)} · ${agents} · ${s.merged} merged, ${s.failed} failed`);
68
+ out.push("");
69
+ out.push("A team of AI agents worked on this repo together through Huddle. This is what they left for whoever works here next.");
70
+ out.push("");
71
+ out.push("## Goal");
72
+ out.push("");
73
+ const goal = s.run.goal.trim();
74
+ out.push(goal.length > 700 ? goal.slice(0, 699).trimEnd() + "… (full goal: `huddle story`)" : goal);
75
+ out.push("");
76
+ out.push("## What was built");
77
+ out.push("");
78
+ if (!merged.length)
79
+ out.push("Nothing was merged in this run.");
80
+ const built = merged.map((m) => {
81
+ const h = x.handoffs?.get(m.task);
82
+ const files = (h?.files?.length ? h.files : planned.get(m.task) ?? []).map(slash);
83
+ const fileNote = files.length ? ` — ${files.slice(0, 6).join(", ")}${files.length > 6 ? ` +${files.length - 6}` : ""}${h?.files?.length ? "" : " (planned)"}` : "";
84
+ const what = flat(h?.summary || done.get(m.task) || "", 160);
85
+ return `- ${m.title} (${m.agent})${fileNote}${what && what !== m.title ? `\n ${what}` : ""}`;
86
+ });
87
+ out.push(...capped(built, 12));
88
+ out.push("");
89
+ out.push("## Who built what");
90
+ out.push("");
91
+ const crew = [];
92
+ for (const a of s.agents) {
93
+ const st = s.stats.get(a);
94
+ const mine = merged.filter((m) => m.agent === a).map((m) => m.title);
95
+ const extra = [st?.reviews ? `${st.reviews} review(s)` : "", st?.failed ? `${st.failed} failed` : "", st?.bugsFound ? `${st.bugsFound} QA bug(s) found` : "", st?.takeoversIn ? `took over ${st.takeoversIn}` : ""].filter(Boolean);
96
+ if (!mine.length && !extra.length)
97
+ continue;
98
+ crew.push(`- ${a}: ${mine.length ? mine.map((t) => flat(t, 70)).join("; ") : "no merged tasks"}${extra.length ? ` · ${extra.join(", ")}` : ""}`);
99
+ }
100
+ out.push(...(crew.length ? crew : ["No agent finished a task."]));
101
+ out.push("");
102
+ out.push("## Decisions and why");
103
+ out.push("");
104
+ const dec = [];
105
+ const cp = ev.filter((e) => e.type === "council.plan").at(-1)?.p;
106
+ const members = arr(cp?.members);
107
+ for (const d of arr(cp?.decisions))
108
+ dec.push(`- ${flat(d)} (design council${members.length ? `: ${members.join(", ")}` : ""})`);
109
+ for (const r of Array.isArray(cp?.risks) ? cp.risks : [])
110
+ if (r?.body)
111
+ dec.push(`- Risk: ${flat(r.body)}${r.by ? ` (flagged by ${r.by})` : ""}`);
112
+ const c = s.collab;
113
+ if (c.synthesizer?.why)
114
+ dec.push(`- Plan written by ${c.synthesizer.id}: ${flat(c.synthesizer.why, 160)}`);
115
+ for (const o of c.objections)
116
+ dec.push(`- ${o.taken ? "Changed the plan after" : "Kept the plan over"} ${o.by}'s objection: ${flat(o.objection, 160)}`);
117
+ // An agent's own decisions, with the reason it gave (huddle_remember kind=decision).
118
+ for (const e of ev) {
119
+ if (e.type !== "memory.added" || str(e.p.kind) !== "decision")
120
+ continue;
121
+ const by = str(e.p.author) || e.agent || "";
122
+ if (by === "council" || by === "huddle")
123
+ continue;
124
+ dec.push(`- ${flat(e.p.body)} (${by}${e.p.why ? `; why: ${flat(e.p.why, 140)}` : ""})`);
125
+ }
126
+ out.push(...(dec.length ? capped(dec, 14) : ["No decisions were recorded."]));
127
+ if (c.contract || c.amendments.length) {
128
+ const v = c.amendments.length ? Math.max(...c.amendments.map((a) => a.version)) : 1;
129
+ out.push("");
130
+ out.push(`Contract: HUDDLE_CONTRACT.md (v${v}${c.contract ? `, written by ${c.contract.by}` : ""}) fixes the names, files and messages every task coded to. Keep to it, or change it on purpose.`);
131
+ out.push(...capped(c.amendments.map((a) => `- v${a.version} by ${a.by}: ${flat(a.change, 160)}`), 6));
132
+ }
133
+ out.push("");
134
+ out.push("## Open issues");
135
+ out.push("");
136
+ const open = [];
137
+ const lastFail = new Map();
138
+ for (const e of ev)
139
+ if (e.type === "run.failed" && e.task)
140
+ lastFail.set(e.task, { agent: str(e.p.agent) || e.agent || "?", title: tt(e.task, e.p), reason: str(e.p.reason), needsYou: e.p.needsYou === true });
141
+ for (const [task, f] of lastFail)
142
+ if (!landed.has(task))
143
+ open.push(`- ${f.needsYou ? "Needs you" : "Failed"}: ${f.title} (${f.agent})${f.reason ? ` — ${flat(f.reason, 140)}` : ""}`);
144
+ for (const e of ev)
145
+ if (e.type === "run.stuck")
146
+ open.push(`- Blocked by those failures, never started: ${arr(e.p.blocked).map((b) => flat(b, 60)).join("; ")}`);
147
+ for (const b of c.bugs.filter((b) => !b.fixedBy))
148
+ open.push(`- QA bug not fixed: ${flat(b.title, 160)} (found by ${b.by}${b.to ? `, fix was ${b.to}'s` : ""})`);
149
+ for (const f of c.followups.filter((f) => f.how === "ignored"))
150
+ open.push(`- ${f.kind === "radar" ? "Radar finding" : `${f.by}'s buddy note`} on ${f.task ? `"${flat(f.task, 60)}"` : "a task"} was never answered: ${flat(f.note.replace(/^buddy check from [^:]*:\s*/i, ""), 160)}`);
151
+ for (const b of c.buddy.filter((b) => b.late))
152
+ open.push(`- ${b.agent}'s notes on ${b.author}'s work came back after it landed: ${flat(b.notes, 160)}`);
153
+ for (const e of ev) {
154
+ if (e.type === "qa.capped")
155
+ open.push(`- QA stopped after round ${str(e.p.round)}: the last fixes were not re-checked${Number(e.p.open) ? ` (${str(e.p.open)} still open)` : ""}.`);
156
+ if (e.type === "qa.unchecked")
157
+ open.push(`- QA round ${str(e.p.round)} did not check the build: ${flat(e.p.reason, 140) || "no check finished"}.`);
158
+ }
159
+ out.push(...(open.length ? capped(open, 14) : ["None recorded."]));
160
+ out.push("");
161
+ out.push("## How to continue");
162
+ out.push("");
163
+ const next = [];
164
+ if (x.testCmd)
165
+ next.push(`- Tests: \`${x.testCmd}\` (every merge passed it).`);
166
+ else
167
+ next.push("- No test command was found, so merges weren't gated on tests.");
168
+ for (const p of x.parked ?? [])
169
+ next.push(`- Parked work for "${flat(p.title, 60)}" is on \`${p.branch}\`: \`git merge ${p.branch}\`, resolve, commit.`);
170
+ for (const b of x.branches ?? [])
171
+ next.push(`- Unmerged work for "${flat(b.title, 60)}" is on \`${b.branch}\`.`);
172
+ if (c.contract || c.amendments.length)
173
+ next.push("- Read HUDDLE_CONTRACT.md before renaming anything it names.");
174
+ next.push("- Everything that happened: `huddle story` (timeline), `huddle story --html story.html`, `huddle export` (the full record).");
175
+ out.push(...next);
176
+ return capBrief(out.join("\n") + "\n");
177
+ }
178
+ /** Cut at a line, under the cap, saying where the rest is. */
179
+ export function capBrief(text, cap = BRIEF_CAP) {
180
+ if (text.length <= cap)
181
+ return text;
182
+ const tail = "\n\n… (cut to fit; `huddle story` and `huddle export` have the rest)\n";
183
+ const cut = text.lastIndexOf("\n", cap - tail.length);
184
+ return text.slice(0, cut > 0 ? cut : cap - tail.length) + tail;
185
+ }
186
+ // ---- the pointer block (pure: text in, text out) -------------------------------
187
+ const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
188
+ /** The block's lines, in the file's own line endings. */
189
+ export function pointerBlock(info, eol = "\n") {
190
+ return [
191
+ BRIEF_START,
192
+ `This repo was last worked on by a Huddle team run (${info.date}, "${goalTitle(info.goal, 80).replace(/"/g, "'")}"). Read .huddle/BRIEF.md before changing code; it lists the decisions, the contract and open issues.`,
193
+ "(Kept up to date by Huddle between the markers; `huddle brief off` removes it.)",
194
+ BRIEF_END,
195
+ ].join(eol);
196
+ }
197
+ /** [start, end) of the block in `text`, end past the end marker; null if none. */
198
+ function span(text) {
199
+ const i = text.indexOf(BRIEF_START);
200
+ const j = i < 0 ? -1 : text.indexOf(BRIEF_END, i);
201
+ return i < 0 || j < 0 ? null : [i, j + BRIEF_END.length];
202
+ }
203
+ export const hasBlock = (text) => span(text) !== null;
204
+ /** The block as it stands in `text` (markers included), or null. */
205
+ export const blockOf = (text) => {
206
+ const s = span(text);
207
+ return s ? text.slice(s[0], s[1]) : null;
208
+ };
209
+ /**
210
+ * `text` with `block` in it: in place of the old one, or at the top (agents
211
+ * read top-down) with a blank line after it. Everything else, byte for byte.
212
+ */
213
+ export function upsertBlock(text, block) {
214
+ const s = span(text);
215
+ if (s)
216
+ return text.slice(0, s[0]) + block + text.slice(s[1]);
217
+ const bom = text.startsWith("") ? "" : "";
218
+ const eol = eolOf(text);
219
+ const body = text.slice(bom.length);
220
+ return bom + block + eol + (body ? eol : "") + body;
221
+ }
222
+ /** `text` without the block — exactly what it was before upsertBlock added it. */
223
+ export function stripBlock(text) {
224
+ const s = span(text);
225
+ if (!s)
226
+ return text;
227
+ let end = s[1];
228
+ for (let k = 0; k < 2; k++) {
229
+ if (text.startsWith("\r\n", end))
230
+ end += 2;
231
+ else if (text[end] === "\n")
232
+ end += 1;
233
+ }
234
+ return text.slice(0, s[0]) + text.slice(end);
235
+ }
236
+ const stateFile = (root) => path.join(root, ".huddle", "brief.json");
237
+ function readState(root) {
238
+ try {
239
+ const j = JSON.parse(fs.readFileSync(stateFile(root), "utf8"));
240
+ return { created: arr(j.created), lifted: arr(j.lifted), declined: arr(j.declined), last: j.last };
241
+ }
242
+ catch {
243
+ return { created: [], lifted: [], declined: [] };
244
+ }
245
+ }
246
+ function writeState(root, s) {
247
+ fs.mkdirSync(path.dirname(stateFile(root)), { recursive: true });
248
+ fs.writeFileSync(stateFile(root), JSON.stringify(s, null, 2));
249
+ }
250
+ function git(root, args) {
251
+ try {
252
+ return { ok: true, out: execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) };
253
+ }
254
+ catch {
255
+ return { ok: false, out: "" };
256
+ }
257
+ }
258
+ const tracked = (root, f) => git(root, ["ls-files", "--error-unmatch", "--", f]).ok;
259
+ /** The committed text, or null if HEAD doesn't have the file. */
260
+ const atHead = (root, f) => {
261
+ const r = git(root, ["show", `HEAD:${f}`]);
262
+ return r.ok ? r.out : null;
263
+ };
264
+ /** Keep the files Huddle created out of commits, in a marked block of .git/info/exclude (like `huddle connect`). */
265
+ function setExcludes(root, files) {
266
+ const p = git(root, ["rev-parse", "--git-path", "info/exclude"]).out.trim() || ".git/info/exclude";
267
+ const ex = path.isAbsolute(p) ? p : path.join(root, p);
268
+ const cur = fs.existsSync(ex) ? fs.readFileSync(ex, "utf8") : "";
269
+ const i = cur.indexOf(EXCLUDE_START);
270
+ const j = cur.indexOf(EXCLUDE_END);
271
+ const base = i >= 0 && j > i ? cur.slice(0, i) + cur.slice(j + EXCLUDE_END.length).replace(/^\r?\n/, "") : cur;
272
+ const want = files.length ? `${EXCLUDE_START}\n${[...new Set(files)].sort().map((f) => "/" + f).join("\n")}\n${EXCLUDE_END}\n` : "";
273
+ const next = want ? base.replace(/([^\n])$/, "$1\n") + want : base;
274
+ if (next === cur)
275
+ return;
276
+ fs.mkdirSync(path.dirname(ex), { recursive: true });
277
+ fs.writeFileSync(ex, next);
278
+ }
279
+ const write = (abs, text) => {
280
+ if (!fs.existsSync(abs) || fs.readFileSync(abs, "utf8") !== text)
281
+ fs.writeFileSync(abs, text);
282
+ };
283
+ /**
284
+ * Before a run: take the pointer out, so the checkout is as the user left it.
285
+ * A file you committed with the block in keeps the committed block; a file
286
+ * Huddle created and nobody added to is removed (and comes back after).
287
+ */
288
+ export function liftPointers(root) {
289
+ if (!fs.existsSync(path.join(root, ".huddle")))
290
+ return [];
291
+ const st = readState(root);
292
+ const lifted = [];
293
+ for (const f of [...POINTER_FILES, ...IF_PRESENT]) {
294
+ const abs = path.join(root, f);
295
+ if (!fs.existsSync(abs))
296
+ continue;
297
+ const cur = fs.readFileSync(abs, "utf8");
298
+ if (!hasBlock(cur))
299
+ continue;
300
+ const head = tracked(root, f) ? atHead(root, f) : null;
301
+ const committed = head !== null ? blockOf(head) : null;
302
+ const next = committed ? upsertBlock(cur, committed) : stripBlock(cur);
303
+ if (st.created.includes(f) && !next.replace(/^/, "").trim()) {
304
+ fs.rmSync(abs);
305
+ if (!st.lifted.includes(f))
306
+ st.lifted.push(f);
307
+ }
308
+ else {
309
+ write(abs, next);
310
+ // You wrote in a file Huddle made: it's yours now, and git should see it.
311
+ st.created = st.created.filter((x) => x !== f);
312
+ }
313
+ lifted.push(f);
314
+ }
315
+ if (lifted.length) {
316
+ writeState(root, st);
317
+ setExcludes(root, st.created);
318
+ }
319
+ return lifted;
320
+ }
321
+ /** After a run (or when it stopped early): point CLAUDE.md / AGENTS.md at the brief. Idempotent. */
322
+ export function placePointers(root, opts) {
323
+ const res = { files: [], created: [], tracked: [] };
324
+ if (!fs.existsSync(path.join(root, ".huddle")))
325
+ return res;
326
+ const st = readState(root);
327
+ if (!opts.enabled || briefOff() || !st.last || !fs.existsSync(path.join(root, ".huddle", "BRIEF.md")))
328
+ return res;
329
+ for (const f of [...POINTER_FILES, ...IF_PRESENT]) {
330
+ const abs = path.join(root, f);
331
+ if (!fs.existsSync(abs)) {
332
+ if (IF_PRESENT.includes(f) || st.declined.includes(f))
333
+ continue;
334
+ // Huddle made it, it wasn't lifted, and it's gone: you deleted it. Don't make it again.
335
+ if (st.created.includes(f) && !st.lifted.includes(f)) {
336
+ st.created = st.created.filter((x) => x !== f);
337
+ st.declined.push(f);
338
+ continue;
339
+ }
340
+ fs.writeFileSync(abs, pointerBlock(st.last) + "\n");
341
+ if (!st.created.includes(f))
342
+ st.created.push(f);
343
+ res.files.push(f);
344
+ res.created.push(f);
345
+ continue;
346
+ }
347
+ const cur = fs.readFileSync(abs, "utf8");
348
+ if (st.created.includes(f) && stripBlock(cur).replace(/^/, "").trim())
349
+ st.created = st.created.filter((x) => x !== f); // yours now
350
+ write(abs, upsertBlock(cur, pointerBlock(st.last, eolOf(cur))));
351
+ res.files.push(f);
352
+ if (st.created.includes(f))
353
+ res.created.push(f);
354
+ else if (tracked(root, f))
355
+ res.tracked.push(f);
356
+ }
357
+ st.lifted = [];
358
+ writeState(root, st);
359
+ setExcludes(root, st.created);
360
+ return res;
361
+ }
362
+ /** `huddle brief off`: every pointer out now; files Huddle made (and nobody added to) removed. */
363
+ export function removePointers(root) {
364
+ if (!fs.existsSync(path.join(root, ".huddle")))
365
+ return [];
366
+ const st = readState(root);
367
+ const touched = [];
368
+ for (const f of [...POINTER_FILES, ...IF_PRESENT]) {
369
+ const abs = path.join(root, f);
370
+ if (!fs.existsSync(abs))
371
+ continue;
372
+ const cur = fs.readFileSync(abs, "utf8");
373
+ if (!hasBlock(cur))
374
+ continue;
375
+ const left = stripBlock(cur);
376
+ if (st.created.includes(f) && !left.replace(/^/, "").trim())
377
+ fs.rmSync(abs);
378
+ else
379
+ write(abs, left);
380
+ touched.push(f);
381
+ }
382
+ st.created = st.created.filter((f) => fs.existsSync(path.join(root, f)));
383
+ st.lifted = [];
384
+ writeState(root, st);
385
+ setExcludes(root, st.created);
386
+ return touched;
387
+ }
388
+ // ---- end of a run --------------------------------------------------------------
389
+ /** A run's folder name: its session id, made safe for every filesystem. */
390
+ export const runDir = (r) => (r.id ? r.id.replace(/[^\w.-]/g, "_") : `run-${r.index}`);
391
+ /**
392
+ * The end of every run: the latest run's brief to .huddle/runs/<id>/BRIEF.md
393
+ * and .huddle/BRIEF.md, then the pointers. Never throws — a brief that
394
+ * couldn't be written mustn't cost the run its RESULT.
395
+ */
396
+ export function writeBrief(o) {
397
+ if (briefOff())
398
+ return null;
399
+ try {
400
+ const runs = splitRuns(o.events);
401
+ const run = runs.at(-1);
402
+ if (!run)
403
+ return null;
404
+ const story = buildStory(run, runs.length);
405
+ const handoffs = new Map();
406
+ for (const e of run.events)
407
+ if (e.type === "run.merged" && e.task && o.getHandoff) {
408
+ const h = o.getHandoff(e.task);
409
+ if (h)
410
+ handoffs.set(e.task, h);
411
+ }
412
+ // Failed tasks whose branch is still there: the work isn't lost.
413
+ const parked = new Set((o.parked ?? []).map((p) => p.branch));
414
+ const branches = [];
415
+ const merged = new Set(run.events.filter((e) => e.type === "run.merged").map((e) => e.task));
416
+ for (const e of run.events) {
417
+ if (e.type !== "run.failed" || !e.task || merged.has(e.task))
418
+ continue;
419
+ const agent = String(e.p.agent ?? e.agent ?? "");
420
+ const branch = `huddle/${`${agent}-${e.task}`.replace(/[^\w.-]/g, "_")}`; // worktreeFor's naming
421
+ if (!parked.has(branch) && !branches.some((b) => b.branch === branch) && git(o.root, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]).ok)
422
+ branches.push({ title: String(e.p.title ?? e.task), branch });
423
+ }
424
+ const text = buildBrief(story, { handoffs, testCmd: o.testCmd, parked: o.parked, branches });
425
+ const dir = path.join(o.root, ".huddle", "runs", runDir(run));
426
+ fs.mkdirSync(dir, { recursive: true });
427
+ fs.writeFileSync(path.join(dir, "BRIEF.md"), text);
428
+ fs.writeFileSync(path.join(o.root, ".huddle", "BRIEF.md"), text);
429
+ const st = readState(o.root);
430
+ writeState(o.root, { ...st, last: { date: new Date(run.start || Date.now()).toISOString().slice(0, 10), goal: run.goal, run: runDir(run) } });
431
+ return { path: path.join(o.root, ".huddle", "BRIEF.md"), pointers: placePointers(o.root, { enabled: o.enabled }) };
432
+ }
433
+ catch {
434
+ return null;
435
+ }
436
+ }
437
+ /** The RESULT's line about it, and where the pointer change lives. */
438
+ export function briefLines(w) {
439
+ const p = w.pointers;
440
+ const out = [` ${C.violet("◆")} brief for your next solo session: .huddle/BRIEF.md${p.files.length ? ` (pointed to from ${p.files.join(", ")})` : ""}`];
441
+ const notes = [
442
+ p.tracked.length ? `${p.tracked.join(", ")}: block left uncommitted, lifted out while runs work` : "",
443
+ p.created.length ? `${p.created.join(", ")}: new, git-excluded` : "",
444
+ ].filter(Boolean);
445
+ if (notes.length)
446
+ out.push(C.faint(` ${notes.join(" · ")} · huddle brief off to stop`));
447
+ return out;
448
+ }
449
+ // ---- CLI -----------------------------------------------------------------------
450
+ /** `huddle brief [--run <n|id>] | off | on`. */
451
+ export async function runBrief(args) {
452
+ const { dbPath, readPrefs, repoRoot, writePrefs } = await import("./app.js");
453
+ const root = repoRoot();
454
+ if (args[0] === "off" || args[0] === "on") {
455
+ const on = args[0] === "on";
456
+ writePrefs({ ...readPrefs(), brief: on });
457
+ if (!on) {
458
+ const gone = removePointers(root);
459
+ console.log(`${C.green("✓")} brief pointers off${gone.length ? ` · removed from ${gone.join(", ")}` : ""} ${C.faint("· .huddle/BRIEF.md is still written · huddle brief on to resume")}`);
460
+ return;
461
+ }
462
+ const r = placePointers(root, { enabled: true });
463
+ console.log(`${C.green("✓")} brief pointers on${r.files.length ? ` · ${r.files.join(", ")} point to .huddle/BRIEF.md` : C.faint(" · they appear after the next run")}`);
464
+ return;
465
+ }
466
+ const i = args.indexOf("--run");
467
+ const which = i >= 0 ? args[i + 1] : args.find((a) => a.startsWith("--run="))?.slice(6);
468
+ if (which === undefined) {
469
+ const f = path.join(root, ".huddle", "BRIEF.md");
470
+ if (fs.existsSync(f))
471
+ return void process.stdout.write(fs.readFileSync(f, "utf8"));
472
+ }
473
+ if (!fs.existsSync(dbPath()))
474
+ return void console.log(C.faint(" no runs yet — the brief is written at the end of each `huddle join` run."));
475
+ const { RoomDB } = await import("../room/db.js");
476
+ const db = new RoomDB(dbPath());
477
+ let runs;
478
+ try {
479
+ runs = splitRuns(db.allEvents());
480
+ }
481
+ finally {
482
+ db.close();
483
+ }
484
+ const run = pickRun(runs, which);
485
+ if (!run) {
486
+ console.log(C.red(`✗ no run ${which ?? ""}.`) + " See `huddle story --list`.");
487
+ process.exitCode = 1;
488
+ return;
489
+ }
490
+ // The saved one has the handoffs' files and parked branches; an older run gets one from its events.
491
+ const saved = path.join(root, ".huddle", "runs", runDir(run), "BRIEF.md");
492
+ process.stdout.write(fs.existsSync(saved) ? fs.readFileSync(saved, "utf8") : buildBrief(buildStory(run, runs.length)));
493
+ }
494
+ //# sourceMappingURL=brief.js.map
@@ -1,34 +1,45 @@
1
1
  // `huddle agents --all`: every connector Huddle knows — built in, or declared
2
2
  // in agents.json — with whether it's on this machine and how to get it.
3
3
  // `huddle agents install <id>` installs one, always after asking.
4
+ import os from "node:os";
4
5
  import readline from "node:readline/promises";
5
- import { spawnSync } from "node:child_process";
6
- import { ADAPTERS, AGENTS_FILE_ERRORS, BUILTIN_ADAPTERS, probe } from "../adapters/index.js";
6
+ import { ADAPTERS, AGENTS_FILE_ERRORS, BUILTIN_ADAPTERS, LEGACY, installCommand, noInstallHint, probe } from "../adapters/index.js";
7
7
  import { agentsFiles } from "../adapters/custom.js";
8
- import { C, dot, pad } from "./term.js";
8
+ import { runInteractive, setAgentChoice, skippedAgents } from "./app.js";
9
+ import { C, dot, lineWidth, pad, wrap } from "./term.js";
9
10
  function all() {
10
11
  const seen = new Map();
11
12
  for (const a of [...BUILTIN_ADAPTERS, ...ADAPTERS])
12
13
  seen.set(a.id, a); // the roster's version wins (agents.json overrides)
13
14
  return [...seen.values()];
14
15
  }
15
- const tilde = (p) => p.replace(process.env.HOME ?? "\0", "~");
16
+ const tilde = (p) => p.replace(os.homedir(), "~");
17
+ const stateText = (r) => r.state === "ready" ? C.green("ready") : r.state === "not_authed" ? C.yellow("not signed in") : C.faint("not installed");
18
+ /** One agent's row: state, whether you use it, and the next command when there is one. */
19
+ function printRow(a, r, skipped) {
20
+ const from = a.source ? C.faint(` · ${tilde(a.source)}`) : "";
21
+ const choice = skipped ? C.faint("skipped") : C.violet("used");
22
+ console.log(` ${dot(a.id)} ${pad(C.bold(a.id), 13)} ${pad(C.faint(a.vendor), 13)} ${pad(stateText(r), 14)} ${from ? pad(choice, 8) + from : choice}`);
23
+ if (skipped)
24
+ return;
25
+ const cmd = installCommand(a);
26
+ // Hints wrap under the row (80 columns when piped), never past the edge.
27
+ const hint = (t) => wrap(t, (lineWidth() ?? 80) - 7, " ").forEach((l) => console.log(` ${C.faint(l)}`));
28
+ if (r.state === "not_installed")
29
+ hint(cmd ? "$ " + cmd : noInstallHint(a));
30
+ else if (r.state === "not_authed")
31
+ hint(r.detail);
32
+ }
16
33
  export function printCatalog() {
17
34
  console.log(`\n${C.bold("Connectors")} ${C.faint("· any CLI that runs headless and speaks MCP can join")}\n`);
18
- for (const a of all()) {
19
- const r = probe(a);
20
- const state = r.state === "ready" ? C.green("ready") : r.state === "not_authed" ? C.yellow("not signed in") : C.faint("not installed");
21
- const from = a.source ? C.faint(` · ${tilde(a.source)}`) : "";
22
- console.log(` ${dot(a.id)} ${pad(C.bold(a.id), 13)} ${pad(C.faint(a.vendor), 13)} ${pad(state, 14)}${from}`);
23
- if (r.state === "not_installed" && a.install.cmd)
24
- console.log(` ${C.faint("$ " + a.install.cmd)}`);
25
- else if (r.state === "not_authed")
26
- console.log(` ${C.faint(r.detail)}`);
27
- }
35
+ const skip = skippedAgents();
36
+ for (const a of all())
37
+ printRow(a, probe(a), skip.has(a.id));
28
38
  for (const e of AGENTS_FILE_ERRORS)
29
39
  console.log(` ${C.yellow("!")} ${C.faint(e)}`);
30
40
  const f = agentsFiles(null);
31
41
  console.log(`\n ${C.faint("install one:")} huddle agents install <id>`);
42
+ console.log(` ${C.faint("choose:")} huddle agents use <id> ${C.faint("·")} huddle agents skip <id>`);
32
43
  console.log(` ${C.faint("add your own:")} ${tilde(f.user)} ${C.faint("(see docs/ADAPTERS.md)")}\n`);
33
44
  }
34
45
  /** Install a connector's CLI (and offer its sign-in) — only after a yes. */
@@ -38,32 +49,29 @@ export async function installConnector(id) {
38
49
  console.log(C.red(`✗ no connector called ${id}.`) + " See `huddle agents --all`.");
39
50
  return false;
40
51
  }
41
- if (!a.install.cmd) {
42
- console.log(`${a.id} has no install command — install ${a.bin} the way its docs say${a.homepage ? `: ${a.homepage}` : "."}`);
52
+ const cmd = installCommand(a);
53
+ if (!cmd) {
54
+ console.log(`${a.id}: ${noInstallHint(a)}`);
43
55
  return false;
44
56
  }
45
57
  if (!process.stdin.isTTY) {
46
- console.log(`To install ${a.id}, run:\n ${a.install.cmd}`);
58
+ console.log(`To install ${a.id}, run:\n ${cmd}`);
47
59
  return false;
48
60
  }
49
61
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
50
62
  try {
51
- console.log(`\n ${dot(a.id)} ${C.bold(a.id)} ${C.faint("$ " + a.install.cmd)}`);
63
+ console.log(`\n ${dot(a.id)} ${C.bold(a.id)} ${C.faint("$ " + cmd)}`);
52
64
  const ans = (await rl.question(`Install ${a.id}? ${C.faint("[Y/n]")} `)).trim().toLowerCase();
53
65
  if (ans && !ans.startsWith("y"))
54
66
  return false;
55
- rl.pause();
56
- const ok = spawnSync("sh", ["-c", a.install.cmd], { stdio: "inherit" }).status === 0;
57
- rl.resume();
67
+ const ok = runInteractive(rl, cmd);
58
68
  console.log(ok ? C.green(`✓ ${a.id} installed`) : C.red(`✗ ${a.id} install failed — see output above`));
59
69
  if (ok && a.install.login) {
60
70
  const go = (await rl.question(`Sign in to ${a.id} now? ${C.faint("[Y/n]")} `)).trim().toLowerCase();
61
71
  if (!go || go.startsWith("y")) {
62
72
  if (a.install.loginNote)
63
73
  console.log(C.faint(` ${a.install.loginNote}`));
64
- rl.pause();
65
- spawnSync(a.install.login[0], a.install.login.slice(1), { stdio: "inherit" });
66
- rl.resume();
74
+ runInteractive(rl, a.install.login);
67
75
  }
68
76
  }
69
77
  return ok;
@@ -72,4 +80,34 @@ export async function installConnector(id) {
72
80
  rl.close();
73
81
  }
74
82
  }
83
+ /** `huddle agents`: the agents Huddle would use here, their state, and your use/skip choice. */
84
+ export function printAgents() {
85
+ const skip = skippedAgents();
86
+ console.log(`\n${C.bold("Your agents")}\n`);
87
+ for (const a of ADAPTERS) {
88
+ const r = probe(a);
89
+ // Opt-in connectors stay out of the list until their CLI is on this machine.
90
+ if (LEGACY.has(a.id) && r.state === "not_installed")
91
+ continue;
92
+ printRow(a, r, skip.has(a.id));
93
+ }
94
+ console.log(`\n ${C.faint("choose:")} huddle agents use claude,codex ${C.faint("·")} huddle agents skip cursor`);
95
+ console.log(` ${C.faint("set up the rest:")} huddle setup`);
96
+ console.log(` ${C.faint("every connector:")} huddle agents --all\n`);
97
+ }
98
+ /** `huddle agents use|skip <ids>`: remembered in ~/.huddle/prefs.json. */
99
+ export function chooseAgents(verb, list) {
100
+ const ids = list.flatMap((x) => x.split(",")).map((s) => s.trim()).filter(Boolean);
101
+ const known = new Set(all().map((a) => a.id));
102
+ const unknown = ids.filter((id) => !known.has(id));
103
+ if (!ids.length || unknown.length) {
104
+ console.log(C.red(`✗ no agent called ${unknown.join(", ") || "(none given)"}.`) + ` Known: ${[...known].join(", ")}`);
105
+ return false;
106
+ }
107
+ setAgentChoice(ids, verb === "use");
108
+ console.log(verb === "use"
109
+ ? `${C.green("✓")} using ${ids.join(", ")} ${C.faint("— `huddle setup` installs or signs in any that aren't ready")}`
110
+ : `${C.green("✓")} skipping ${ids.join(", ")} ${C.faint("— never installed, asked about, or put to work. Undo: huddle agents use " + ids.join(","))}`);
111
+ return true;
112
+ }
75
113
  //# sourceMappingURL=catalog.js.map