huddle-cli 0.0.0-stage → 0.1.0

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +357 -2
  3. package/dist/adapters/amp.js +40 -0
  4. package/dist/adapters/antigravity.js +52 -0
  5. package/dist/adapters/claude.js +70 -0
  6. package/dist/adapters/codex.js +59 -0
  7. package/dist/adapters/copilot.js +69 -0
  8. package/dist/adapters/cursor.js +85 -0
  9. package/dist/adapters/custom.js +340 -0
  10. package/dist/adapters/droid.js +57 -0
  11. package/dist/adapters/gemini.js +61 -0
  12. package/dist/adapters/goose.js +55 -0
  13. package/dist/adapters/index.js +135 -0
  14. package/dist/adapters/kiro.js +41 -0
  15. package/dist/adapters/opencode.js +63 -0
  16. package/dist/adapters/qwen.js +67 -0
  17. package/dist/adapters/types.js +6 -0
  18. package/dist/adapters/usage.js +141 -0
  19. package/dist/attach/connect.js +409 -0
  20. package/dist/attach/guard.js +64 -0
  21. package/dist/attach/live.js +222 -0
  22. package/dist/attach/plug.js +174 -0
  23. package/dist/attach/session.js +172 -0
  24. package/dist/cli.js +176 -0
  25. package/dist/mcp/server.js +240 -0
  26. package/dist/orchestrator/ask.js +51 -0
  27. package/dist/orchestrator/brain.js +164 -0
  28. package/dist/orchestrator/council.js +274 -0
  29. package/dist/orchestrator/handoff.js +117 -0
  30. package/dist/orchestrator/launcher.js +155 -0
  31. package/dist/orchestrator/planner.js +194 -0
  32. package/dist/orchestrator/qa.js +350 -0
  33. package/dist/orchestrator/scheduler.js +748 -0
  34. package/dist/orchestrator/takeover.js +85 -0
  35. package/dist/preflight.js +31 -0
  36. package/dist/relay/server.js +113 -0
  37. package/dist/room/db.js +617 -0
  38. package/dist/room/identity.js +122 -0
  39. package/dist/room/live.js +293 -0
  40. package/dist/room/progress.js +110 -0
  41. package/dist/room/relaylink.js +85 -0
  42. package/dist/room/server.js +752 -0
  43. package/dist/room/sqlite.js +156 -0
  44. package/dist/room/transport.js +107 -0
  45. package/dist/room/tunnel.js +85 -0
  46. package/dist/sandbox/sync.js +104 -0
  47. package/dist/sandbox/worktree.js +261 -0
  48. package/dist/types.js +4 -0
  49. package/dist/ui/app.js +1534 -0
  50. package/dist/ui/catalog.js +75 -0
  51. package/dist/ui/dashboard.js +192 -0
  52. package/dist/ui/doctor.js +54 -0
  53. package/dist/ui/logo.js +40 -0
  54. package/dist/ui/remote.js +332 -0
  55. package/dist/ui/story.js +932 -0
  56. package/dist/ui/term.js +114 -0
  57. package/dist/ui/watch.js +174 -0
  58. package/install.sh +44 -0
  59. package/package.json +60 -4
@@ -0,0 +1,261 @@
1
+ // Sandboxes = git worktrees. Each claimed task gets its own working copy that
2
+ // shares the repo's object store, so isolation is cheap and "merging" is just
3
+ // git. The user never sees any of this — it's the hidden plumbing.
4
+ import { execFileSync } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ /** true if the command exits 0, false if non-zero. */
8
+ function spawnSyncOk(cmd, args, cwd) {
9
+ try {
10
+ execFileSync(cmd, args, { cwd, stdio: "ignore" });
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ function git(cwd, args) {
18
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
19
+ }
20
+ /** true if `a` is an ancestor of (or the same commit as) `b`. */
21
+ function isAncestor(cwd, a, b) {
22
+ return spawnSyncOk("git", ["merge-base", "--is-ancestor", a, b], cwd);
23
+ }
24
+ export function isGitRepo(dir) {
25
+ try {
26
+ git(dir, ["rev-parse", "--is-inside-work-tree"]);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /**
34
+ * Create an isolated worktree for a task, branched off the current HEAD.
35
+ * Worktrees live under .huddle/worktrees/ so they're easy to clean up.
36
+ */
37
+ /** Where a task's worktree lives — computed without touching disk. */
38
+ export function worktreeFor(repoRoot, agentId, taskId) {
39
+ const slug = `${agentId}-${taskId}`.replace(/[^\w.-]/g, "_");
40
+ return { branch: `huddle/${slug}`, path: path.join(repoRoot, ".huddle", "worktrees", slug) };
41
+ }
42
+ export function createWorktree(repoRoot, agentId, taskId) {
43
+ const { branch, path: wtPath } = worktreeFor(repoRoot, agentId, taskId);
44
+ fs.mkdirSync(path.dirname(wtPath), { recursive: true });
45
+ if (fs.existsSync(wtPath))
46
+ return { branch, path: wtPath };
47
+ // Reuse the branch if it already exists, else create it.
48
+ const branches = git(repoRoot, ["branch", "--list", branch]);
49
+ if (branches) {
50
+ git(repoRoot, ["worktree", "add", wtPath, branch]);
51
+ }
52
+ else {
53
+ git(repoRoot, ["worktree", "add", "-b", branch, wtPath, "HEAD"]);
54
+ }
55
+ return { branch, path: wtPath };
56
+ }
57
+ export function listWorktrees(repoRoot) {
58
+ try {
59
+ return git(repoRoot, ["worktree", "list", "--porcelain"])
60
+ .split("\n")
61
+ .filter((l) => l.startsWith("worktree "))
62
+ .map((l) => l.slice("worktree ".length));
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ }
68
+ export function removeWorktree(repoRoot, wtPath) {
69
+ try {
70
+ git(repoRoot, ["worktree", "remove", "--force", wtPath]);
71
+ }
72
+ catch {
73
+ /* best-effort cleanup */
74
+ }
75
+ }
76
+ /**
77
+ * The merge engine, step 1: commit what the agent produced, run the test gate,
78
+ * and rebase onto the latest main. Nothing lands yet — so a human can look
79
+ * first in manual mode. Tests are the referee: nothing merges on agent vibes.
80
+ */
81
+ export function prepareMerge(repoRoot, wt, testCmd, excludes = [], message = `huddle: ${wt.branch}`) {
82
+ // Commit — minus Huddle's own Plug wiring files, which must never reach history.
83
+ try {
84
+ // An exclusion for a path that's already ignored (e.g. `huddle connect` put
85
+ // it in .git/info/exclude) makes `git add` fail outright — skip those.
86
+ const live = excludes.filter((e) => spawnSyncOk("git", ["check-ignore", "-q", e], wt.path) === false);
87
+ git(wt.path, ["add", "-A", "--", ".", ...live.map((e) => `:!${e}`)]);
88
+ // Only commit if something is staged (delegated branches arrive committed).
89
+ const staged = spawnSyncOk("git", ["diff", "--cached", "--quiet"], wt.path) === false;
90
+ if (staged)
91
+ git(wt.path, ["commit", "-m", message]);
92
+ }
93
+ catch {
94
+ /* nothing to commit */
95
+ }
96
+ if (testCmd) {
97
+ try {
98
+ execFileSync("sh", ["-c", testCmd], { cwd: wt.path, stdio: "pipe" });
99
+ }
100
+ catch (e) {
101
+ // The tail of what the tests printed: what the author needs to fix it.
102
+ const out = `${e?.stdout ?? ""}\n${e?.stderr ?? ""}`.trim();
103
+ return { ok: false, reason: `tests failed: ${String(e?.message ?? e).slice(0, 200)}`, diffstat: "", testOutput: out.slice(-3000) };
104
+ }
105
+ }
106
+ const mainBranch = git(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
107
+ if (!isAncestor(wt.path, mainBranch, "HEAD")) {
108
+ // A branch whose conflict was resolved carries a merge commit: replaying it
109
+ // with rebase would raise the same conflict again, so bring main in by merge.
110
+ const resolvedBefore = git(wt.path, ["rev-list", "--merges", `${mainBranch}..HEAD`]) !== "";
111
+ try {
112
+ if (resolvedBefore)
113
+ git(wt.path, ["merge", "--no-edit", mainBranch]);
114
+ else
115
+ git(wt.path, ["rebase", mainBranch]);
116
+ }
117
+ catch {
118
+ spawnSyncOk("git", [resolvedBefore ? "merge" : "rebase", "--abort"], wt.path);
119
+ return { ok: false, conflict: true, reason: "conflict on rebase — needs resolver", diffstat: "" };
120
+ }
121
+ }
122
+ const diffstat = git(repoRoot, ["diff", "--stat", `${mainBranch}...${wt.branch}`]);
123
+ return { ok: true, reason: testCmd ? "tests green" : "no test command found", diffstat };
124
+ }
125
+ /** The merge engine, step 2: fast-forward main to the prepared branch. */
126
+ export function landMerge(repoRoot, wt) {
127
+ const mainBranch = git(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
128
+ try {
129
+ git(repoRoot, ["merge", "--ff-only", wt.branch]);
130
+ return { merged: true, reason: "merged" };
131
+ }
132
+ catch (e) {
133
+ // 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]}` };
135
+ }
136
+ }
137
+ /** The prepared branch's changes vs main, for a reviewer to read. Capped. */
138
+ export function branchDiff(repoRoot, wt, maxChars = 24_000) {
139
+ const mainBranch = git(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
140
+ try {
141
+ const d = git(repoRoot, ["diff", `${mainBranch}...${wt.branch}`]);
142
+ return d.length > maxChars ? d.slice(0, maxChars) + "\n… (diff truncated)" : d;
143
+ }
144
+ catch {
145
+ return "";
146
+ }
147
+ }
148
+ const MARKER = /^(<{7}|>{7}|\|{7})( |$)/m;
149
+ function mergeInProgress(wtPath) {
150
+ return spawnSyncOk("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], wtPath);
151
+ }
152
+ function unmergedFiles(wtPath) {
153
+ return git(wtPath, ["diff", "--name-only", "--diff-filter=U"]).split("\n").filter(Boolean);
154
+ }
155
+ /** A file's content at a commit, or null if it doesn't exist there. */
156
+ function showAt(cwd, rev, file) {
157
+ try {
158
+ return execFileSync("git", ["show", `${rev}:${file}`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ }
164
+ /**
165
+ * Start merging main into the task branch; stops on conflicts with markers in
166
+ * the files. `files` empty = it merged cleanly (main moved on to something
167
+ * compatible) and the merge is already committed.
168
+ */
169
+ export function beginConflictMerge(repoRoot, wt, message) {
170
+ const mainBranch = git(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
171
+ if (mergeInProgress(wt.path))
172
+ spawnSyncOk("git", ["merge", "--abort"], wt.path);
173
+ const ours = git(wt.path, ["rev-parse", "HEAD"]);
174
+ const theirs = git(repoRoot, ["rev-parse", mainBranch]);
175
+ const base = git(wt.path, ["merge-base", ours, theirs]);
176
+ try {
177
+ git(wt.path, ["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, theirs]);
178
+ return { files: [], ours, theirs, base };
179
+ }
180
+ catch (e) {
181
+ const files = unmergedFiles(wt.path);
182
+ if (!files.length) {
183
+ // Not a content conflict (e.g. an untracked file in the way): undo and say so.
184
+ 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]}` };
186
+ }
187
+ return { files, ours, theirs, base };
188
+ }
189
+ }
190
+ /** Both sides of a conflict, for the resolver's prompt. Capped. */
191
+ export function conflictBrief(wt, c, maxPerFile = 6_000) {
192
+ const cap = (s, n) => (s.length > n ? s.slice(0, n) + "\n… (truncated)" : s);
193
+ const marked = c.files
194
+ .map((f) => {
195
+ const p = path.join(wt.path, f);
196
+ return `--- ${f}\n${fs.existsSync(p) ? cap(fs.readFileSync(p, "utf8"), maxPerFile) : "(deleted on one side)"}`;
197
+ })
198
+ .join("\n\n");
199
+ const diff = (to) => {
200
+ try {
201
+ return cap(git(wt.path, ["diff", c.base, to, "--", ...c.files]), maxPerFile * 2);
202
+ }
203
+ catch {
204
+ return "";
205
+ }
206
+ };
207
+ const theirCommits = git(wt.path, ["log", "--format=%s", `${c.base}..${c.theirs}`, "--", ...c.files]).split("\n").filter(Boolean);
208
+ return { marked, oursDiff: diff(c.ours), theirsDiff: diff(c.theirs), theirCommits };
209
+ }
210
+ /**
211
+ * Check the resolver's work and commit the merge. Refuses if markers remain,
212
+ * files are still unmerged, or a file is byte-identical to one side — taking
213
+ * one side wholesale drops the other agent's change.
214
+ */
215
+ export function finishConflictMerge(wt, c, excludes = []) {
216
+ for (const f of c.files) {
217
+ const p = path.join(wt.path, f);
218
+ if (fs.existsSync(p) && MARKER.test(fs.readFileSync(p, "utf8")))
219
+ return { ok: false, reason: `conflict markers left in ${f}` };
220
+ }
221
+ if (mergeInProgress(wt.path)) {
222
+ git(wt.path, ["add", "-A", "--", ".", ...excludes.map((e) => `:!${e}`)]);
223
+ const left = unmergedFiles(wt.path);
224
+ if (left.length)
225
+ return { ok: false, reason: `still unmerged: ${left.join(", ")}` };
226
+ }
227
+ // The resolver may have committed on its own; judge what the worktree holds either way.
228
+ for (const f of c.files) {
229
+ 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))
232
+ 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))
234
+ return { ok: false, reason: `${f} is just this task's version — main's change was dropped` };
235
+ }
236
+ try {
237
+ if (mergeInProgress(wt.path))
238
+ git(wt.path, ["commit", "--no-edit"]);
239
+ else {
240
+ // Committed by the resolver: anything still uncommitted belongs with it.
241
+ git(wt.path, ["add", "-A", "--", ".", ...excludes.map((e) => `:!${e}`)]);
242
+ if (!spawnSyncOk("git", ["diff", "--cached", "--quiet"], wt.path))
243
+ git(wt.path, ["commit", "-m", "huddle: finish conflict resolution"]);
244
+ }
245
+ }
246
+ catch (e) {
247
+ return { ok: false, reason: `couldn't commit the resolution: ${String(e?.message ?? e).split("\n")[0]}` };
248
+ }
249
+ if (!isAncestor(wt.path, c.theirs, "HEAD") || !isAncestor(wt.path, c.ours, "HEAD"))
250
+ return { ok: false, reason: "the merge was abandoned (main's or this task's commits are missing from the branch)" };
251
+ return { ok: true, reason: `resolved ${c.files.join(", ")}` };
252
+ }
253
+ /** Give up on a resolution: the branch goes back to exactly the task's own commits. */
254
+ export function abortConflictMerge(wt, c) {
255
+ if (mergeInProgress(wt.path))
256
+ spawnSyncOk("git", ["merge", "--abort"], wt.path);
257
+ // A bad resolution the resolver committed itself: drop it (it stays in the reflog).
258
+ if (git(wt.path, ["rev-parse", "HEAD"]) !== c.ours)
259
+ spawnSyncOk("git", ["reset", "--hard", c.ours], wt.path);
260
+ }
261
+ //# sourceMappingURL=worktree.js.map
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ // Shared types for the Huddle daemon. The Room is the single source of truth;
2
+ // these mirror the SQLite schema in room/db.ts.
3
+ export {};
4
+ //# sourceMappingURL=types.js.map