huddle-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/QUICKSTART.md +102 -0
  3. package/README.md +23 -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 +6 -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/attach/connect.js +4 -1
  16. package/dist/attach/guard.js +21 -2
  17. package/dist/attach/live.js +5 -1
  18. package/dist/attach/plug.js +14 -3
  19. package/dist/attach/screen.js +150 -0
  20. package/dist/attach/session.js +7 -2
  21. package/dist/cli.js +108 -40
  22. package/dist/mcp/context-tools.js +79 -0
  23. package/dist/mcp/server.js +38 -11
  24. package/dist/orchestrator/ask.js +13 -10
  25. package/dist/orchestrator/brain.js +13 -3
  26. package/dist/orchestrator/council.js +427 -86
  27. package/dist/orchestrator/goal.js +196 -0
  28. package/dist/orchestrator/handoff.js +2 -2
  29. package/dist/orchestrator/launcher.js +29 -13
  30. package/dist/orchestrator/learn.js +105 -0
  31. package/dist/orchestrator/planner.js +25 -7
  32. package/dist/orchestrator/qa.js +62 -14
  33. package/dist/orchestrator/radar.js +719 -0
  34. package/dist/orchestrator/scheduler.js +498 -39
  35. package/dist/orchestrator/takeover.js +155 -0
  36. package/dist/platform.js +273 -0
  37. package/dist/room/context.js +1285 -0
  38. package/dist/room/contract.js +132 -0
  39. package/dist/room/db.js +79 -16
  40. package/dist/room/followup.js +233 -0
  41. package/dist/room/identity.js +2 -2
  42. package/dist/room/lessons.js +275 -0
  43. package/dist/room/live.js +75 -20
  44. package/dist/room/mail.js +212 -0
  45. package/dist/room/progress.js +40 -1
  46. package/dist/room/server.js +169 -39
  47. package/dist/room/tunnel.js +6 -5
  48. package/dist/sandbox/sync.js +4 -4
  49. package/dist/sandbox/worktree.js +145 -14
  50. package/dist/ui/app.js +554 -133
  51. package/dist/ui/catalog.js +62 -24
  52. package/dist/ui/dashboard.js +194 -51
  53. package/dist/ui/doctor.js +15 -11
  54. package/dist/ui/lessons.js +81 -0
  55. package/dist/ui/paste.js +515 -0
  56. package/dist/ui/remote.js +11 -5
  57. package/dist/ui/setup.js +152 -0
  58. package/dist/ui/story.js +666 -69
  59. package/dist/ui/term.js +151 -16
  60. package/dist/ui/watch.js +145 -48
  61. package/package.json +6 -3
@@ -4,6 +4,7 @@
4
4
  import { execFileSync } from "node:child_process";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
+ import { shellCommand } from "../platform.js";
7
8
  /** true if the command exits 0, false if non-zero. */
8
9
  function spawnSyncOk(cmd, args, cwd) {
9
10
  try {
@@ -57,22 +58,50 @@ export function createWorktree(repoRoot, agentId, taskId) {
57
58
  export function listWorktrees(repoRoot) {
58
59
  try {
59
60
  return git(repoRoot, ["worktree", "list", "--porcelain"])
60
- .split("\n")
61
+ .split(/\r?\n/)
61
62
  .filter((l) => l.startsWith("worktree "))
62
- .map((l) => l.slice("worktree ".length));
63
+ // git prints C:/x/y on Windows; resolve() makes it C:\x\y, like path.join does.
64
+ .map((l) => path.resolve(l.slice("worktree ".length)));
63
65
  }
64
66
  catch {
65
67
  return [];
66
68
  }
67
69
  }
68
- export function removeWorktree(repoRoot, wtPath) {
70
+ /**
71
+ * Best-effort cleanup. Windows can't delete a file another process still has
72
+ * open (an agent that is just exiting, an editor, a virus scanner), so git
73
+ * fails with "Permission denied" / EBUSY: retry a few times, then delete the
74
+ * folder ourselves and let `git worktree prune` forget it.
75
+ */
76
+ export function removeWorktree(repoRoot, wtPath, tries = process.platform === "win32" ? 4 : 1) {
77
+ for (let i = 0; i < tries; i++) {
78
+ try {
79
+ git(repoRoot, ["worktree", "remove", "--force", wtPath]);
80
+ return;
81
+ }
82
+ catch {
83
+ if (i < tries - 1)
84
+ sleepMs(250 * (i + 1));
85
+ }
86
+ }
87
+ if (tries <= 1)
88
+ return;
89
+ try {
90
+ fs.rmSync(wtPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
91
+ }
92
+ catch {
93
+ /* still locked: prune below won't drop it, a later run's sweep will */
94
+ }
69
95
  try {
70
- git(repoRoot, ["worktree", "remove", "--force", wtPath]);
96
+ git(repoRoot, ["worktree", "prune"]);
71
97
  }
72
98
  catch {
73
- /* best-effort cleanup */
99
+ /* best effort */
74
100
  }
75
101
  }
102
+ function sleepMs(ms) {
103
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
104
+ }
76
105
  /**
77
106
  * The merge engine, step 1: commit what the agent produced, run the test gate,
78
107
  * and rebase onto the latest main. Nothing lands yet — so a human can look
@@ -95,7 +124,10 @@ export function prepareMerge(repoRoot, wt, testCmd, excludes = [], message = `hu
95
124
  }
96
125
  if (testCmd) {
97
126
  try {
98
- execFileSync("sh", ["-c", testCmd], { cwd: wt.path, stdio: "pipe" });
127
+ // sh on macOS/Linux; cmd.exe on Windows, where `npm test` and friends run as-is.
128
+ const sh = shellCommand(testCmd, "cmd");
129
+ // (execFileSync passes windowsVerbatimArguments through to spawn; its types just don't list it)
130
+ execFileSync(sh.file, sh.args, { cwd: wt.path, stdio: "pipe", ...{ windowsVerbatimArguments: sh.verbatim } });
99
131
  }
100
132
  catch (e) {
101
133
  // The tail of what the tests printed: what the author needs to fix it.
@@ -122,6 +154,45 @@ export function prepareMerge(repoRoot, wt, testCmd, excludes = [], message = `hu
122
154
  const diffstat = git(repoRoot, ["diff", "--stat", `${mainBranch}...${wt.branch}`]);
123
155
  return { ok: true, reason: testCmd ? "tests green" : "no test command found", diffstat };
124
156
  }
157
+ /**
158
+ * Write one file at the repo root and commit just that path on the current
159
+ * branch (what every task worktree branches off). Anything else the user has
160
+ * staged stays staged and out of this commit. false = it couldn't (the run goes on).
161
+ */
162
+ export function commitFile(repoRoot, rel, content, message) {
163
+ const file = path.join(repoRoot, rel);
164
+ const before = fs.existsSync(file) ? fs.readFileSync(file) : null;
165
+ // A Huddle doc commit, not the user's: their hooks (lint, commitlint, a test
166
+ // run) don't apply, and a signing prompt or slow hook can't hang the run.
167
+ const run = (args) => execFileSync("git", args, { cwd: repoRoot, stdio: "ignore", timeout: 30_000 });
168
+ try {
169
+ fs.writeFileSync(file, content);
170
+ run(["add", "--", rel]);
171
+ if (spawnSyncOk("git", ["diff", "--cached", "--quiet", "--", rel], repoRoot))
172
+ return true; // unchanged: already committed
173
+ run(["-c", "commit.gpgsign=false", "commit", "-q", "--no-verify", "--only", "-m", message, "--", rel]);
174
+ return true;
175
+ }
176
+ catch {
177
+ // Leave the checkout as we found it: no half-staged file, no stray file.
178
+ try {
179
+ run(["reset", "-q", "--", rel]);
180
+ }
181
+ catch {
182
+ /* no HEAD yet, or not a repo */
183
+ }
184
+ try {
185
+ if (before)
186
+ fs.writeFileSync(file, before);
187
+ else
188
+ fs.rmSync(file, { force: true });
189
+ }
190
+ catch {
191
+ /* best effort */
192
+ }
193
+ return false;
194
+ }
195
+ }
125
196
  /** The merge engine, step 2: fast-forward main to the prepared branch. */
126
197
  export function landMerge(repoRoot, wt) {
127
198
  const mainBranch = git(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
@@ -131,7 +202,7 @@ export function landMerge(repoRoot, wt) {
131
202
  }
132
203
  catch (e) {
133
204
  // Usually: the user has uncommitted edits in the main checkout on the same files.
134
- return { merged: false, reason: `couldn't fast-forward ${mainBranch}: ${String(e?.message ?? e).split("\n")[0]}` };
205
+ return { merged: false, reason: `couldn't fast-forward ${mainBranch}: ${String(e?.message ?? e).split(/\r?\n/)[0]}` };
135
206
  }
136
207
  }
137
208
  /** The prepared branch's changes vs main, for a reviewer to read. Capped. */
@@ -145,12 +216,70 @@ export function branchDiff(repoRoot, wt, maxChars = 24_000) {
145
216
  return "";
146
217
  }
147
218
  }
219
+ /** Lockfiles: listed as changed, but their diff would bury the real code in a capped prompt. */
220
+ const LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "Cargo.lock", "poetry.lock", "Gemfile.lock", "go.sum"];
221
+ /**
222
+ * Work in progress in a task's worktree, against where it branched off main:
223
+ * commits, staged and unstaged edits, and new untracked files (minus the Plug
224
+ * wiring in `excludes`). What a mid-task buddy check and a pairing helper read.
225
+ * Never throws: a broken worktree is just "no work yet".
226
+ */
227
+ export function wipDiff(repoRoot, worktree, excludes = [], maxChars = 24_000) {
228
+ // Raw bytes for names (unicode/spaces stay readable), and room for a big WIP: execFileSync's 1MB default would throw.
229
+ const run = (cwd, args) => execFileSync("git", ["-c", "core.quotePath=false", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 });
230
+ const names = (out) => out.split("\0").filter(Boolean);
231
+ // Untracked symlinks (a linked node_modules, say) aren't work.
232
+ const isFile = (p) => {
233
+ try {
234
+ return fs.lstatSync(p).isFile();
235
+ }
236
+ catch {
237
+ return false;
238
+ }
239
+ };
240
+ try {
241
+ if (!fs.existsSync(worktree))
242
+ return { diff: "", files: [] };
243
+ const mainBranch = git(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
244
+ const base = git(worktree, ["merge-base", "HEAD", mainBranch]);
245
+ const ex = excludes.map((x) => `:(exclude)${x}`);
246
+ const skip = (f) => excludes.some((x) => f === x || f.startsWith(x.replace(/\/+$/, "") + "/"));
247
+ const tracked = names(run(worktree, ["diff", "--name-only", "-z", base, "--", ".", ...ex]));
248
+ const untracked = names(run(worktree, ["ls-files", "-z", "--others", "--exclude-standard"])).filter((f) => !skip(f) && isFile(path.join(worktree, f)));
249
+ let diff = run(worktree, ["diff", base, "--", ".", ...ex, ...LOCKFILES.map((l) => `:(exclude,glob)**/${l}`)]).trim();
250
+ for (const f of untracked) {
251
+ if (diff.length > maxChars)
252
+ break;
253
+ if (LOCKFILES.includes(path.basename(f)))
254
+ continue;
255
+ let body;
256
+ try {
257
+ const st = fs.statSync(path.join(worktree, f));
258
+ if (st.size > 256 * 1024)
259
+ body = `(${Math.round(st.size / 1024)} KB, not shown)`;
260
+ else {
261
+ const buf = fs.readFileSync(path.join(worktree, f));
262
+ body = buf.includes(0) ? "(binary)" : buf.toString("utf8").slice(0, 6000);
263
+ }
264
+ }
265
+ catch {
266
+ continue;
267
+ }
268
+ diff += `\n--- /dev/null\n+++ b/${f} (new, uncommitted)\n` + body.split("\n").map((l) => "+" + l).join("\n");
269
+ }
270
+ diff = diff.trim();
271
+ return { diff: diff.length > maxChars ? diff.slice(0, maxChars) + "\n… (diff truncated)" : diff, files: [...new Set([...tracked, ...untracked])] };
272
+ }
273
+ catch {
274
+ return { diff: "", files: [] };
275
+ }
276
+ }
148
277
  const MARKER = /^(<{7}|>{7}|\|{7})( |$)/m;
149
278
  function mergeInProgress(wtPath) {
150
279
  return spawnSyncOk("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], wtPath);
151
280
  }
152
281
  function unmergedFiles(wtPath) {
153
- return git(wtPath, ["diff", "--name-only", "--diff-filter=U"]).split("\n").filter(Boolean);
282
+ return git(wtPath, ["diff", "--name-only", "--diff-filter=U"]).split(/\r?\n/).filter(Boolean);
154
283
  }
155
284
  /** A file's content at a commit, or null if it doesn't exist there. */
156
285
  function showAt(cwd, rev, file) {
@@ -182,7 +311,7 @@ export function beginConflictMerge(repoRoot, wt, message) {
182
311
  if (!files.length) {
183
312
  // Not a content conflict (e.g. an untracked file in the way): undo and say so.
184
313
  spawnSyncOk("git", ["merge", "--abort"], wt.path);
185
- return { error: `couldn't merge ${mainBranch} into ${wt.branch}: ${String(e?.stderr ?? e?.message ?? e).trim().split("\n")[0]}` };
314
+ return { error: `couldn't merge ${mainBranch} into ${wt.branch}: ${String(e?.stderr ?? e?.message ?? e).trim().split(/\r?\n/)[0]}` };
186
315
  }
187
316
  return { files, ours, theirs, base };
188
317
  }
@@ -204,7 +333,7 @@ export function conflictBrief(wt, c, maxPerFile = 6_000) {
204
333
  return "";
205
334
  }
206
335
  };
207
- const theirCommits = git(wt.path, ["log", "--format=%s", `${c.base}..${c.theirs}`, "--", ...c.files]).split("\n").filter(Boolean);
336
+ const theirCommits = git(wt.path, ["log", "--format=%s", `${c.base}..${c.theirs}`, "--", ...c.files]).split(/\r?\n/).filter(Boolean);
208
337
  return { marked, oursDiff: diff(c.ours), theirsDiff: diff(c.theirs), theirCommits };
209
338
  }
210
339
  /**
@@ -225,12 +354,14 @@ export function finishConflictMerge(wt, c, excludes = []) {
225
354
  return { ok: false, reason: `still unmerged: ${left.join(", ")}` };
226
355
  }
227
356
  // The resolver may have committed on its own; judge what the worktree holds either way.
357
+ // Line endings don't count: with Windows' core.autocrlf the worktree has CRLF, `git show` LF.
358
+ const eol = (s) => (s === null ? null : s.replace(/\r\n/g, "\n"));
228
359
  for (const f of c.files) {
229
360
  const p = path.join(wt.path, f);
230
- const now = fs.existsSync(p) ? fs.readFileSync(p, "utf8") : null;
231
- if (now === showAt(wt.path, c.theirs, f))
361
+ const now = eol(fs.existsSync(p) ? fs.readFileSync(p, "utf8") : null);
362
+ if (now === eol(showAt(wt.path, c.theirs, f)))
232
363
  return { ok: false, reason: `${f} is just main's version — this task's change was dropped` };
233
- if (now === showAt(wt.path, c.ours, f))
364
+ if (now === eol(showAt(wt.path, c.ours, f)))
234
365
  return { ok: false, reason: `${f} is just this task's version — main's change was dropped` };
235
366
  }
236
367
  try {
@@ -244,7 +375,7 @@ export function finishConflictMerge(wt, c, excludes = []) {
244
375
  }
245
376
  }
246
377
  catch (e) {
247
- return { ok: false, reason: `couldn't commit the resolution: ${String(e?.message ?? e).split("\n")[0]}` };
378
+ return { ok: false, reason: `couldn't commit the resolution: ${String(e?.message ?? e).split(/\r?\n/)[0]}` };
248
379
  }
249
380
  if (!isAncestor(wt.path, c.theirs, "HEAD") || !isAncestor(wt.path, c.ours, "HEAD"))
250
381
  return { ok: false, reason: "the merge was abandoned (main's or this task's commits are missing from the branch)" };