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,233 @@
|
|
|
1
|
+
// Notes that need an answer: a buddy check's notes and a radar finding, sent
|
|
2
|
+
// to an agent while it works on a task.
|
|
3
|
+
//
|
|
4
|
+
// Found live (capstone round 4): codex's buddy check told cursor at 50% that
|
|
5
|
+
// isBlankRecord drops real CSV records like ",,,". Cursor finished without a
|
|
6
|
+
// word about it, and QA round 2 filed exactly that bug a whole fix cycle
|
|
7
|
+
// later. Now each such note stays "open" for the author's task until it is
|
|
8
|
+
// answered:
|
|
9
|
+
//
|
|
10
|
+
// replied the author sent a message with reply_to = the note's id
|
|
11
|
+
// ("won't fix: <why>" is an answer too)
|
|
12
|
+
// fixed a file the note names (or, if it names none, any file of the
|
|
13
|
+
// author's work in progress) changed after the note was delivered
|
|
14
|
+
//
|
|
15
|
+
// huddle_done with notes still open is refused ONCE, with the list
|
|
16
|
+
// (src/room/server.ts). The second huddle_done always goes through, so this
|
|
17
|
+
// can never deadlock a task. What happened to each note (addressed or
|
|
18
|
+
// ignored) is published as note.closed, for `huddle story`, the live view and
|
|
19
|
+
// the evals. Built on Room events and the message table; no new tables.
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
/** One-time refusal of huddle_done per task: the second one always goes through. */
|
|
24
|
+
export const REFUSE_ONCE = "done.refused";
|
|
25
|
+
const json = (s) => {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(s || "{}");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** Files the author's worktree has changed (tracked edits and new files), relative paths. */
|
|
34
|
+
function changedFiles(worktree) {
|
|
35
|
+
try {
|
|
36
|
+
const r = spawnSync("git", ["-c", "core.quotePath=false", "status", "--porcelain", "-z", "-uall"], { cwd: worktree, encoding: "utf8" });
|
|
37
|
+
if (r.status !== 0)
|
|
38
|
+
return [];
|
|
39
|
+
const out = [];
|
|
40
|
+
const parts = String(r.stdout ?? "").split("\0");
|
|
41
|
+
for (let i = 0; i < parts.length; i++) {
|
|
42
|
+
const p = parts[i];
|
|
43
|
+
if (p.length < 4)
|
|
44
|
+
continue;
|
|
45
|
+
out.push(p.slice(3));
|
|
46
|
+
if (p[0] === "R" || p[0] === "C")
|
|
47
|
+
i++; // a rename's source path follows
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function trackedFiles(worktree) {
|
|
56
|
+
try {
|
|
57
|
+
const r = spawnSync("git", ["-c", "core.quotePath=false", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: worktree, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
58
|
+
return r.status === 0 ? String(r.stdout ?? "").split("\0").filter(Boolean) : [];
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The files a note names: a path with a directory ("src/csv.js", even one
|
|
66
|
+
* not written yet — "test/books.test.js doesn't exist yet" is answered by
|
|
67
|
+
* creating it), or a bare name that is a file in the worktree ("csv.js").
|
|
68
|
+
* "import.meta.url" and "e.g." are not files.
|
|
69
|
+
*/
|
|
70
|
+
export function namedFiles(body, worktree) {
|
|
71
|
+
const found = new Set();
|
|
72
|
+
const re = /(?:^|[\s(`'"“[])((?:\.\/)?(?:[\w@.-]+\/)*[\w@-][\w@.-]*\.[A-Za-z][A-Za-z0-9]{0,7})(?=$|[\s)`'"”,:;!?\]]|\.(?:\s|$))/g;
|
|
73
|
+
let all = null;
|
|
74
|
+
for (const m of String(body).matchAll(re)) {
|
|
75
|
+
const f = m[1].replace(/^\.\//, "");
|
|
76
|
+
if (f.split("/").includes("..") || path.isAbsolute(f))
|
|
77
|
+
continue;
|
|
78
|
+
if (f.includes("/")) {
|
|
79
|
+
found.add(f);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
all ??= trackedFiles(worktree);
|
|
83
|
+
for (const t of all)
|
|
84
|
+
if (t === f || t.endsWith("/" + f))
|
|
85
|
+
found.add(t);
|
|
86
|
+
}
|
|
87
|
+
return [...found].slice(0, 12);
|
|
88
|
+
}
|
|
89
|
+
/** Start tracking `msg` (already in the author's inbox) as a note the author must answer. */
|
|
90
|
+
export function trackNote(db, msg, o) {
|
|
91
|
+
if (!msg.task_id)
|
|
92
|
+
return;
|
|
93
|
+
try {
|
|
94
|
+
db.publish("note.open", {
|
|
95
|
+
task_id: msg.task_id,
|
|
96
|
+
agent_id: msg.to_agent,
|
|
97
|
+
payload: { id: msg.id, to: msg.to_agent, by: o.by, kind: o.kind, files: namedFiles(msg.body, o.worktree), worktree: o.worktree, ...(o.key ? { key: o.key } : {}) },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* a closed db never breaks a run */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Every note tracked for `taskId`, oldest first. */
|
|
105
|
+
export function notesFor(db, taskId) {
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const e of db.eventsOfType(["note.open"], 0, 1000)) {
|
|
108
|
+
if (e.task_id !== taskId)
|
|
109
|
+
continue;
|
|
110
|
+
const p = json(e.payload);
|
|
111
|
+
const m = db.getMessage(String(p.id ?? ""));
|
|
112
|
+
if (!m)
|
|
113
|
+
continue;
|
|
114
|
+
out.push({ id: m.id, to: m.to_agent, task: taskId, by: String(p.by ?? "huddle"), kind: p.kind === "radar" ? "radar" : "buddy", files: Array.isArray(p.files) ? p.files.map(String) : [], worktree: String(p.worktree ?? ""), body: m.body, at: m.created_at });
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** Notes already closed for `taskId`: id -> how. */
|
|
119
|
+
export function closedNotes(db, taskId) {
|
|
120
|
+
const out = new Map();
|
|
121
|
+
for (const e of db.eventsOfType(["note.closed"], 0, 1000)) {
|
|
122
|
+
if (e.task_id !== taskId)
|
|
123
|
+
continue;
|
|
124
|
+
const p = json(e.payload);
|
|
125
|
+
if (p.id)
|
|
126
|
+
out.set(String(p.id), p.how);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
/** Has the author answered this note? replied / fixed, or null (still open). */
|
|
131
|
+
export function noteAnswer(db, n) {
|
|
132
|
+
// A change to a file the note names counts first: found live (capstone round 5), authors
|
|
133
|
+
// who fixed a note AND replied "Fixed both" were all tallied "replied" (0 fixed).
|
|
134
|
+
const delivered = db.getMessage(n.id)?.read_at;
|
|
135
|
+
const changedSince = (files) => !!delivered &&
|
|
136
|
+
!!n.worktree &&
|
|
137
|
+
files.some((f) => {
|
|
138
|
+
try {
|
|
139
|
+
return fs.statSync(path.join(n.worktree, f)).mtimeMs > delivered;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false; // not written (yet): not a fix
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
if (n.files.length && changedSince(n.files))
|
|
146
|
+
return "fixed";
|
|
147
|
+
const msgs = db.messagesSince(n.at);
|
|
148
|
+
if (msgs.some((m) => m.reply_to === n.id && m.from_agent === n.to))
|
|
149
|
+
return "replied";
|
|
150
|
+
// A note that names no file: any change to the author's work after it was seen.
|
|
151
|
+
if (!n.files.length && n.worktree && changedSince(changedFiles(n.worktree)))
|
|
152
|
+
return "fixed";
|
|
153
|
+
return null; // never seen, or seen and not acted on
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The notes on `taskId` still waiting for the author's answer. A radar
|
|
157
|
+
* re-alert repeats a finding: notes with the same text count once, and an
|
|
158
|
+
* answer to any copy answers them all.
|
|
159
|
+
*/
|
|
160
|
+
export function openNotes(db, taskId) {
|
|
161
|
+
const closed = closedNotes(db, taskId);
|
|
162
|
+
const notes = notesFor(db, taskId).filter((n) => !closed.has(n.id));
|
|
163
|
+
const answered = new Set(notes.filter((n) => noteAnswer(db, n)).map((n) => n.body));
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
return notes.filter((n) => {
|
|
166
|
+
if (answered.has(n.body) || seen.has(n.body))
|
|
167
|
+
return false;
|
|
168
|
+
seen.add(n.body);
|
|
169
|
+
return true;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The task is finishing: record how each of its notes ended — replied, fixed,
|
|
174
|
+
* or ignored. Idempotent (a closed note stays closed). Returns this call's tally.
|
|
175
|
+
*/
|
|
176
|
+
export function closeNotes(db, taskId) {
|
|
177
|
+
const closed = closedNotes(db, taskId);
|
|
178
|
+
const notes = notesFor(db, taskId).filter((n) => !closed.has(n.id));
|
|
179
|
+
const how = new Map(notes.map((n) => [n.id, noteAnswer(db, n)]));
|
|
180
|
+
// One answer covers every copy of the same text (radar re-alerts).
|
|
181
|
+
const byBody = new Map();
|
|
182
|
+
for (const n of notes) {
|
|
183
|
+
const h = how.get(n.id);
|
|
184
|
+
if (h && !byBody.has(n.body))
|
|
185
|
+
byBody.set(n.body, h);
|
|
186
|
+
}
|
|
187
|
+
const out = { addressed: [], ignored: [] };
|
|
188
|
+
for (const n of notes) {
|
|
189
|
+
const h = how.get(n.id) ?? byBody.get(n.body) ?? "ignored";
|
|
190
|
+
(h === "ignored" ? out.ignored : out.addressed).push(n);
|
|
191
|
+
try {
|
|
192
|
+
db.publish("note.closed", { task_id: taskId, agent_id: n.to, payload: { id: n.id, by: n.by, to: n.to, kind: n.kind, how: h, body: n.body.slice(0, 300) } });
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
/* closed db */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The radar no longer sees finding `key` (src/orchestrator/radar.ts): every
|
|
202
|
+
* open note about it is answered — fixed, by whichever side changed its code.
|
|
203
|
+
*/
|
|
204
|
+
export function resolveNotes(db, key) {
|
|
205
|
+
let n = 0;
|
|
206
|
+
for (const e of db.eventsOfType(["note.open"], 0, 1000)) {
|
|
207
|
+
const p = json(e.payload);
|
|
208
|
+
if (p.key !== key || !e.task_id || closedNotes(db, e.task_id).has(String(p.id)))
|
|
209
|
+
continue;
|
|
210
|
+
const m = db.getMessage(String(p.id));
|
|
211
|
+
db.publish("note.closed", { task_id: e.task_id, agent_id: p.to ?? e.agent_id, payload: { id: p.id, by: p.by, to: p.to, kind: p.kind, how: "fixed", body: String(m?.body ?? "").slice(0, 300) } });
|
|
212
|
+
n++;
|
|
213
|
+
}
|
|
214
|
+
return n;
|
|
215
|
+
}
|
|
216
|
+
/** Was this task's huddle_done already refused once? */
|
|
217
|
+
export function refusedOnce(db, taskId) {
|
|
218
|
+
return db.eventsOfType([REFUSE_ONCE], 0, 1000).some((e) => e.task_id === taskId);
|
|
219
|
+
}
|
|
220
|
+
/** What the author is told when huddle_done is refused: each open note, and how to answer it. */
|
|
221
|
+
export function refusal(notes) {
|
|
222
|
+
const who = (n) => (n.kind === "radar" ? "huddle" : n.by);
|
|
223
|
+
const first = notes[0];
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
refused: true,
|
|
227
|
+
open_notes: notes.map((n) => ({ id: n.id, from: who(n), kind: n.kind, text: n.body.length > 600 ? n.body.slice(0, 600) + "…" : n.body })),
|
|
228
|
+
note: `NOT done yet: ${notes.length} teammate note${notes.length > 1 ? "s" : ""} on this task ${notes.length > 1 ? "have" : "has"} no answer. ` +
|
|
229
|
+
`Address each: fix it, or reply huddle_send(to='${who(first)}', reply_to='${first.id}', message="won't fix: <why>"). ` +
|
|
230
|
+
`Then call huddle_done again (that one always goes through; an unanswered note is recorded as ignored).`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
//# sourceMappingURL=followup.js.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Identity = your GitHub account, proven with the SSH key you already use for git.
|
|
2
|
+
//
|
|
3
|
+
// GitHub publishes everyone's public SSH keys at https://github.com/<user>.keys.
|
|
4
|
+
// So when a room invites @harsh, whoever joins must sign a one-time challenge
|
|
5
|
+
// with a private key whose public half is on Harsh's GitHub. Huddle verifies
|
|
6
|
+
// with `ssh-keygen -Y verify`. No passwords, no OAuth app, no credentials held —
|
|
7
|
+
// the private key never leaves the joiner's machine.
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
const NAMESPACE = "huddle-join";
|
|
14
|
+
const cache = new Map();
|
|
15
|
+
/** "ssh-ed25519 AAAA… comment" → "ssh-ed25519 AAAA…" */
|
|
16
|
+
const bare = (line) => line.trim().split(/\s+/).slice(0, 2).join(" ");
|
|
17
|
+
export async function githubKeys(user) {
|
|
18
|
+
const hit = cache.get(user.toLowerCase());
|
|
19
|
+
if (hit && Date.now() - hit.at < 10 * 60_000)
|
|
20
|
+
return hit.keys;
|
|
21
|
+
try {
|
|
22
|
+
const r = await fetch(`https://github.com/${encodeURIComponent(user)}.keys`, { signal: AbortSignal.timeout(8000) });
|
|
23
|
+
if (!r.ok)
|
|
24
|
+
return [];
|
|
25
|
+
const keys = (await r.text()).split(/\r?\n/).filter((l) => l.startsWith("ssh-")).map(bare);
|
|
26
|
+
cache.set(user.toLowerCase(), { at: Date.now(), keys });
|
|
27
|
+
return keys;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Your GitHub login, from the gh CLI or git config. */
|
|
34
|
+
export function githubUser() {
|
|
35
|
+
const gh = spawnSync("gh", ["api", "user", "--jq", ".login"], { encoding: "utf8", timeout: 8000 });
|
|
36
|
+
if (gh.status === 0 && gh.stdout.trim())
|
|
37
|
+
return gh.stdout.trim();
|
|
38
|
+
const g = spawnSync("git", ["config", "github.user"], { encoding: "utf8" });
|
|
39
|
+
return g.stdout?.trim() || null;
|
|
40
|
+
}
|
|
41
|
+
function localKeyPairs() {
|
|
42
|
+
const dir = path.join(os.homedir(), ".ssh");
|
|
43
|
+
let files = [];
|
|
44
|
+
try {
|
|
45
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".pub"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
return files
|
|
51
|
+
.map((f) => {
|
|
52
|
+
const pub = path.join(dir, f);
|
|
53
|
+
const priv = pub.slice(0, -4);
|
|
54
|
+
try {
|
|
55
|
+
return { priv, pub, key: bare(fs.readFileSync(pub, "utf8")) };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
.filter((x) => !!x && fs.existsSync(x.priv));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Sign a challenge as GitHub user `user`: pick the local key that's on their
|
|
65
|
+
* GitHub, sign with ssh-keygen (it asks for your passphrase if the key has one).
|
|
66
|
+
*/
|
|
67
|
+
export async function signAs(user, nonce) {
|
|
68
|
+
const published = new Set(await githubKeys(user));
|
|
69
|
+
if (!published.size)
|
|
70
|
+
return { ok: false, reason: `couldn't read github.com/${user}.keys (no keys, or no network)` };
|
|
71
|
+
const mine = localKeyPairs().find((k) => published.has(k.key));
|
|
72
|
+
if (!mine)
|
|
73
|
+
return { ok: false, reason: `none of your ~/.ssh keys are on github.com/${user} — are you ${user}?` };
|
|
74
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "huddle-sign-"));
|
|
75
|
+
const msg = path.join(tmp, "challenge");
|
|
76
|
+
fs.writeFileSync(msg, nonce);
|
|
77
|
+
const r = spawnSync("ssh-keygen", ["-Y", "sign", "-q", "-f", mine.priv, "-n", NAMESPACE, msg], { stdio: ["inherit", "pipe", "pipe"] });
|
|
78
|
+
try {
|
|
79
|
+
if (r.status !== 0)
|
|
80
|
+
return { ok: false, reason: `ssh-keygen couldn't sign: ${String(r.stderr).trim().split(/\r?\n/)[0]}` };
|
|
81
|
+
return { ok: true, key: mine.key, sig: fs.readFileSync(msg + ".sig", "utf8") };
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Is `sig` a signature of `nonce` by a key that's on github.com/<user>.keys? */
|
|
88
|
+
export async function verifyAs(user, nonce, key, sig) {
|
|
89
|
+
if (!(await githubKeys(user)).includes(bare(key)))
|
|
90
|
+
return false;
|
|
91
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "huddle-verify-"));
|
|
92
|
+
try {
|
|
93
|
+
const allowed = path.join(tmp, "allowed");
|
|
94
|
+
const sigFile = path.join(tmp, "sig");
|
|
95
|
+
fs.writeFileSync(allowed, `${user} namespaces="${NAMESPACE}" ${bare(key)}\n`);
|
|
96
|
+
fs.writeFileSync(sigFile, sig);
|
|
97
|
+
const r = spawnSync("ssh-keygen", ["-Y", "verify", "-f", allowed, "-I", user, "-n", NAMESPACE, "-s", sigFile], {
|
|
98
|
+
input: nonce,
|
|
99
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
100
|
+
});
|
|
101
|
+
return r.status === 0;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export const newNonce = () => crypto.randomBytes(24).toString("hex");
|
|
108
|
+
/** "github.com/owner/repo" → "owner/repo", when the remote is on GitHub. */
|
|
109
|
+
export function githubRepo(canonical) {
|
|
110
|
+
const m = /^github\.com\/([^/]+\/[^/]+)$/.exec(canonical);
|
|
111
|
+
return m ? m[1] : null;
|
|
112
|
+
}
|
|
113
|
+
/** Can `user` push to owner/repo? true/false, or null if we can't tell (no gh). */
|
|
114
|
+
export function isCollaborator(repo, user) {
|
|
115
|
+
const r = spawnSync("gh", ["api", `repos/${repo}/collaborators/${user}`, "--silent"], { encoding: "utf8", timeout: 10_000 });
|
|
116
|
+
if (r.error)
|
|
117
|
+
return null;
|
|
118
|
+
if (r.status === 0)
|
|
119
|
+
return true;
|
|
120
|
+
return /404|not a collaborator/i.test(`${r.stderr}${r.stdout}`) ? false : null;
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=identity.js.map
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Lessons: what Huddle learned on this machine, carried from one run to the
|
|
2
|
+
// next. A room's shared memory dies with its goal; a lesson outlives it —
|
|
3
|
+
// "cursor's council turns time out", "agents collide on src/index.js: give
|
|
4
|
+
// it one owner" — and goes into the next run's agent and planner prompts.
|
|
5
|
+
//
|
|
6
|
+
// Stored as an append-only log, ~/.huddle/lessons.jsonl (HUDDLE_HOME moves
|
|
7
|
+
// it). Each line is one op — add, hit, use, forget, clear — so two runs
|
|
8
|
+
// appending at once never lose each other's writes, and a torn or garbled
|
|
9
|
+
// line is skipped instead of losing the file. HUDDLE_NO_LESSONS=1 turns the
|
|
10
|
+
// whole thing off: nothing read, nothing learned.
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { nanoid } from "nanoid";
|
|
17
|
+
export const LESSON_MAX = 300;
|
|
18
|
+
const EVIDENCE_MAX = 200;
|
|
19
|
+
/** Compact the log past this many lines (uses pile up: one per lesson per launch). */
|
|
20
|
+
const COMPACT_LINES = 2000;
|
|
21
|
+
/** Opt-out, read at call time so a test can flip it. */
|
|
22
|
+
export const lessonsOff = () => !!process.env.HUDDLE_NO_LESSONS;
|
|
23
|
+
export function lessonsFile(home) {
|
|
24
|
+
return path.join(home ?? process.env.HUDDLE_HOME ?? path.join(os.homedir(), ".huddle"), "lessons.jsonl");
|
|
25
|
+
}
|
|
26
|
+
const cap = (s, n) => {
|
|
27
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
28
|
+
return t.length <= n ? t : t.slice(0, n - 1).replace(/\s+\S*$/, "") + "…";
|
|
29
|
+
};
|
|
30
|
+
/** Lowercase words, punctuation gone: what two phrasings of one lesson share. */
|
|
31
|
+
export function normalize(text) {
|
|
32
|
+
return text
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.normalize("NFKD")
|
|
35
|
+
.replace(/[^\p{L}\p{N}\s/._#-]+/gu, " ")
|
|
36
|
+
.replace(/\s+/g, " ")
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
/** Paths, ids, numbers: "src/index.js", "#12", "v2" — the words a lesson is ABOUT. */
|
|
40
|
+
const specific = (w) => /[/._#\d]/.test(w);
|
|
41
|
+
function words(text) {
|
|
42
|
+
return new Set(normalize(text).split(" ").filter((w) => w.length > 2 && !specific(w)));
|
|
43
|
+
}
|
|
44
|
+
function names(text) {
|
|
45
|
+
return [...new Set(normalize(text).split(" ").filter(specific))].sort().join(" ");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Near-identical: same words in another order, a word or two changed — but
|
|
49
|
+
* about the same things: "collide on src/index.js" never merges with
|
|
50
|
+
* "collide on README.md".
|
|
51
|
+
*/
|
|
52
|
+
export function similar(a, b) {
|
|
53
|
+
const na = normalize(a);
|
|
54
|
+
const nb = normalize(b);
|
|
55
|
+
if (na === nb)
|
|
56
|
+
return true;
|
|
57
|
+
if (names(a) !== names(b))
|
|
58
|
+
return false;
|
|
59
|
+
const wa = words(a);
|
|
60
|
+
const wb = words(b);
|
|
61
|
+
if (!wa.size || !wb.size)
|
|
62
|
+
return false;
|
|
63
|
+
let both = 0;
|
|
64
|
+
for (const w of wa)
|
|
65
|
+
if (wb.has(w))
|
|
66
|
+
both++;
|
|
67
|
+
return both / (wa.size + wb.size - both) >= 0.75;
|
|
68
|
+
}
|
|
69
|
+
function readOps(file) {
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = fs.readFileSync(file, "utf8");
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const ops = [];
|
|
78
|
+
for (const line of raw.split("\n")) {
|
|
79
|
+
if (!line.trim())
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
const o = JSON.parse(line);
|
|
83
|
+
if (o && typeof o.op === "string")
|
|
84
|
+
ops.push(o);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* a torn write or hand edit: skip the line, keep the rest */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return ops;
|
|
91
|
+
}
|
|
92
|
+
function reduce(ops) {
|
|
93
|
+
const byId = new Map();
|
|
94
|
+
for (const o of ops) {
|
|
95
|
+
if (o.op === "clear")
|
|
96
|
+
byId.clear();
|
|
97
|
+
else if (o.op === "add" && o.lesson?.id && typeof o.lesson.text === "string")
|
|
98
|
+
byId.set(o.lesson.id, { ...o.lesson, hits: Number(o.lesson.hits) || 1 });
|
|
99
|
+
else if (o.op === "hit") {
|
|
100
|
+
const l = byId.get(o.id);
|
|
101
|
+
if (l) {
|
|
102
|
+
l.hits++;
|
|
103
|
+
if (o.evidence)
|
|
104
|
+
l.evidence = o.evidence;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (o.op === "use")
|
|
108
|
+
for (const id of o.ids ?? []) {
|
|
109
|
+
const l = byId.get(id);
|
|
110
|
+
if (l)
|
|
111
|
+
l.last_used = Math.max(l.last_used ?? 0, o.ts);
|
|
112
|
+
}
|
|
113
|
+
else if (o.op === "forget")
|
|
114
|
+
byId.delete(o.id);
|
|
115
|
+
}
|
|
116
|
+
return [...byId.values()];
|
|
117
|
+
}
|
|
118
|
+
function append(file, ops) {
|
|
119
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
120
|
+
// One write per call, O_APPEND: concurrent runs interleave whole lines.
|
|
121
|
+
fs.appendFileSync(file, ops.map((o) => JSON.stringify(o)).join("\n") + "\n");
|
|
122
|
+
}
|
|
123
|
+
/** Rewrite the log as one add per live lesson. Temp file + rename: a reader never sees half a file. */
|
|
124
|
+
function compact(file) {
|
|
125
|
+
const live = reduce(readOps(file));
|
|
126
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
127
|
+
fs.writeFileSync(tmp, live.map((lesson) => JSON.stringify({ op: "add", lesson })).join("\n") + (live.length ? "\n" : ""));
|
|
128
|
+
fs.renameSync(tmp, file);
|
|
129
|
+
}
|
|
130
|
+
function maybeCompact(file) {
|
|
131
|
+
try {
|
|
132
|
+
const n = fs.readFileSync(file, "utf8").split("\n").length;
|
|
133
|
+
if (n > COMPACT_LINES)
|
|
134
|
+
compact(file);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* best effort */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Every lesson, unsorted. */
|
|
141
|
+
export function allLessons(home) {
|
|
142
|
+
return reduce(readOps(lessonsFile(home)));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Add a lesson, or — if one in the same scope says nearly the same thing —
|
|
146
|
+
* count it again (hits+1) and return that one. Returns null when lessons are
|
|
147
|
+
* off or the text is empty.
|
|
148
|
+
*/
|
|
149
|
+
export function addLesson(d, home) {
|
|
150
|
+
if (lessonsOff())
|
|
151
|
+
return null;
|
|
152
|
+
const text = cap(String(d.text ?? ""), LESSON_MAX);
|
|
153
|
+
if (!text || !validScope(d.scope))
|
|
154
|
+
return null;
|
|
155
|
+
const file = lessonsFile(home);
|
|
156
|
+
const evidence = cap(String(d.evidence ?? ""), EVIDENCE_MAX);
|
|
157
|
+
const same = reduce(readOps(file)).find((l) => l.scope === d.scope && similar(l.text, text));
|
|
158
|
+
if (same) {
|
|
159
|
+
append(file, [{ op: "hit", id: same.id, ts: Date.now(), ...(evidence ? { evidence } : {}) }]);
|
|
160
|
+
return { lesson: { ...same, hits: same.hits + 1, evidence: evidence || same.evidence }, added: false };
|
|
161
|
+
}
|
|
162
|
+
const lesson = { id: nanoid(8), text, scope: d.scope, source: cap(String(d.source ?? ""), 120), evidence, hits: 1, created: Date.now(), last_used: null };
|
|
163
|
+
append(file, [{ op: "add", lesson }]);
|
|
164
|
+
maybeCompact(file);
|
|
165
|
+
return { lesson, added: true };
|
|
166
|
+
}
|
|
167
|
+
export function validScope(s) {
|
|
168
|
+
return s === "all" || (typeof s === "string" && /^(agent|repo):[^\s]+$/.test(s));
|
|
169
|
+
}
|
|
170
|
+
/** The CLI id a worker runs: claude#2 learns what claude learned. */
|
|
171
|
+
export const agentBase = (id) => id.split("#")[0].split("/").pop() ?? id;
|
|
172
|
+
const keys = new Map();
|
|
173
|
+
/**
|
|
174
|
+
* A repo's identity for lessons: its first commit, so every worktree and clone
|
|
175
|
+
* of one project shares them. A repo with no commits yet: its path.
|
|
176
|
+
*/
|
|
177
|
+
export function repoKey(dir) {
|
|
178
|
+
let real = dir;
|
|
179
|
+
try {
|
|
180
|
+
real = fs.realpathSync(dir);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
/* keep as given */
|
|
184
|
+
}
|
|
185
|
+
const known = keys.get(real);
|
|
186
|
+
if (known)
|
|
187
|
+
return known;
|
|
188
|
+
const r = spawnSync("git", ["rev-list", "--max-parents=0", "HEAD"], { cwd: real, encoding: "utf8" });
|
|
189
|
+
const root = r.status === 0 ? (r.stdout ?? "").trim().split("\n").sort()[0] : "";
|
|
190
|
+
const key = (root || createHash("sha1").update(real).digest("hex")).slice(0, 12);
|
|
191
|
+
keys.set(real, key);
|
|
192
|
+
return key;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The lessons for one prompt: everything scoped "all", this repo's, and this
|
|
196
|
+
* agent's — or, with no agent (the planner, who assigns everyone), every
|
|
197
|
+
* agent's. Most-seen first, then most recent; at most `limit` and `maxChars`.
|
|
198
|
+
*/
|
|
199
|
+
export function lessonsFor(o = {}) {
|
|
200
|
+
if (lessonsOff())
|
|
201
|
+
return [];
|
|
202
|
+
const repo = o.repo ? `repo:${repoKey(o.repo)}` : null;
|
|
203
|
+
const agent = o.agentId ? `agent:${agentBase(o.agentId)}` : null;
|
|
204
|
+
const fits = (l) => l.scope === "all" || l.scope === repo || (agent ? l.scope === agent : l.scope.startsWith("agent:"));
|
|
205
|
+
const ranked = allLessons(o.home)
|
|
206
|
+
.filter(fits)
|
|
207
|
+
.sort((a, b) => b.hits - a.hits || Math.max(b.last_used ?? 0, b.created) - Math.max(a.last_used ?? 0, a.created));
|
|
208
|
+
const out = [];
|
|
209
|
+
let chars = 0;
|
|
210
|
+
for (const l of ranked) {
|
|
211
|
+
if (out.length >= (o.limit ?? 8))
|
|
212
|
+
break;
|
|
213
|
+
if (chars + l.text.length > (o.maxChars ?? 1200))
|
|
214
|
+
continue;
|
|
215
|
+
chars += l.text.length;
|
|
216
|
+
out.push(l);
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
/** Note that these lessons went into a prompt. */
|
|
221
|
+
export function markUsed(ids, home) {
|
|
222
|
+
if (lessonsOff() || !ids.length)
|
|
223
|
+
return;
|
|
224
|
+
try {
|
|
225
|
+
append(lessonsFile(home), [{ op: "use", ids, ts: Date.now() }]);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
/* a read-only home never breaks a run */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The prompt section, or "" when there's nothing to say. `dry`: don't count
|
|
233
|
+
* it as used (a --plan dry run).
|
|
234
|
+
*/
|
|
235
|
+
export function lessonsBrief(o = {}) {
|
|
236
|
+
let picked;
|
|
237
|
+
try {
|
|
238
|
+
picked = lessonsFor(o);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return ""; // a broken store never blocks a launch
|
|
242
|
+
}
|
|
243
|
+
if (!picked.length)
|
|
244
|
+
return "";
|
|
245
|
+
if (!o.dry)
|
|
246
|
+
markUsed(picked.map((l) => l.id), o.home);
|
|
247
|
+
const who = (l) => (!o.agentId && l.scope.startsWith("agent:") ? `(${l.scope.slice(6)}) ` : "");
|
|
248
|
+
return `LESSONS FROM EARLIER RUNS (what Huddle learned on this machine — follow them unless your task says otherwise):\n${picked.map((l) => `- ${who(l)}${l.text}`).join("\n")}\n\n`;
|
|
249
|
+
}
|
|
250
|
+
export function forgetLesson(id, home) {
|
|
251
|
+
if (!allLessons(home).some((l) => l.id === id))
|
|
252
|
+
return false;
|
|
253
|
+
append(lessonsFile(home), [{ op: "forget", id }]);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
export function clearLessons(home) {
|
|
257
|
+
const n = allLessons(home).length;
|
|
258
|
+
const file = lessonsFile(home);
|
|
259
|
+
if (fs.existsSync(file))
|
|
260
|
+
fs.rmSync(file);
|
|
261
|
+
return n;
|
|
262
|
+
}
|
|
263
|
+
/** An agent's own huddle_remember(kind="lesson"): kept for the next runs in this repo. Never throws. */
|
|
264
|
+
export function rememberLesson(o) {
|
|
265
|
+
if (lessonsOff())
|
|
266
|
+
return null;
|
|
267
|
+
try {
|
|
268
|
+
const text = o.why ? `${o.body} (${o.why})` : o.body;
|
|
269
|
+
return addLesson({ text, scope: o.root ? `repo:${repoKey(o.root)}` : "all", source: `agent:${o.author}`, evidence: `huddle_remember by ${o.author}` }, o.home)?.lesson ?? null;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
//# sourceMappingURL=lessons.js.map
|