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.
- package/CHANGELOG.md +31 -0
- package/QUICKSTART.md +102 -0
- package/README.md +23 -4
- package/dist/adapters/amp.js +1 -1
- package/dist/adapters/antigravity.js +2 -0
- package/dist/adapters/claude.js +19 -3
- package/dist/adapters/codex.js +6 -1
- package/dist/adapters/cursor.js +12 -5
- package/dist/adapters/custom.js +4 -2
- package/dist/adapters/droid.js +1 -0
- package/dist/adapters/goose.js +2 -0
- package/dist/adapters/index.js +44 -18
- package/dist/adapters/kiro.js +1 -1
- package/dist/adapters/opencode.js +1 -1
- package/dist/attach/connect.js +4 -1
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +14 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +108 -40
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +38 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +13 -3
- package/dist/orchestrator/council.js +427 -86
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +29 -13
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +25 -7
- package/dist/orchestrator/qa.js +62 -14
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +498 -39
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +273 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +79 -16
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +2 -2
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +75 -20
- package/dist/room/mail.js +212 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +169 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +554 -133
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +194 -51
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +11 -5
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +666 -69
- package/dist/ui/term.js +151 -16
- package/dist/ui/watch.js +145 -48
- package/package.json +6 -3
package/dist/sandbox/worktree.js
CHANGED
|
@@ -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(
|
|
61
|
+
.split(/\r?\n/)
|
|
61
62
|
.filter((l) => l.startsWith("worktree "))
|
|
62
|
-
|
|
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
|
-
|
|
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", "
|
|
96
|
+
git(repoRoot, ["worktree", "prune"]);
|
|
71
97
|
}
|
|
72
98
|
catch {
|
|
73
|
-
/* best
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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)" };
|