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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The CONTRACT: the exact cross-task names every task codes to — files and who
|
|
2
|
+
// owns them, exported functions with signatures, data shapes, CLI flags,
|
|
3
|
+
// routes, error strings. The design council ratifies it before work starts;
|
|
4
|
+
// agents amend it mid-task (huddle_amend_contract) instead of inventing a name
|
|
5
|
+
// a teammate depends on. Found live: two agents built the same app with
|
|
6
|
+
// different names for the same thing, and the mismatch only surfaced at merge.
|
|
7
|
+
//
|
|
8
|
+
// Stored as Room events (contract.set / contract.amended), scoped to the
|
|
9
|
+
// current session, so no schema change and `huddle story` replays it for free.
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
/** The contract's text is capped: it rides in every task's launch prompt. */
|
|
12
|
+
export const CONTRACT_CAP = 3000;
|
|
13
|
+
/** Cap at a line break, so no name is cut in half (a half name is worse than none). */
|
|
14
|
+
export function capContract(text, cap = CONTRACT_CAP) {
|
|
15
|
+
const t = text.trim();
|
|
16
|
+
if (t.length <= cap)
|
|
17
|
+
return t;
|
|
18
|
+
const cut = t.slice(0, cap - 12);
|
|
19
|
+
const nl = cut.lastIndexOf("\n");
|
|
20
|
+
return (nl > cap / 2 ? cut.slice(0, nl) : cut.replace(/\s+\S*$/, "")) + "\n…(capped)";
|
|
21
|
+
}
|
|
22
|
+
const sessionId = (db) => db.currentSession()?.id ?? null;
|
|
23
|
+
function parse(p) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(p || "{}");
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** The current session's contract with its amendments, or null when none was set. */
|
|
32
|
+
export function getContract(db) {
|
|
33
|
+
const sid = sessionId(db);
|
|
34
|
+
const evs = db.eventsOfType(["contract.set", "contract.amended"], 0, 2000).map((e) => ({ type: e.type, ts: e.ts, p: parse(e.payload) }));
|
|
35
|
+
const sets = evs.filter((e) => e.type === "contract.set" && (e.p.session ?? null) === sid);
|
|
36
|
+
const base = sets[sets.length - 1];
|
|
37
|
+
if (!base)
|
|
38
|
+
return null;
|
|
39
|
+
// Amendments name the contract they amend, so a same-millisecond tie can't misorder them.
|
|
40
|
+
const amendments = evs
|
|
41
|
+
.filter((e) => e.type === "contract.amended" && e.p.base === base.p.id)
|
|
42
|
+
.map((e) => ({ section: String(e.p.section ?? ""), change: String(e.p.change ?? ""), why: String(e.p.why ?? ""), by: String(e.p.by ?? ""), version: Number(e.p.version) || 0, ts: e.ts }))
|
|
43
|
+
.sort((a, b) => a.version - b.version);
|
|
44
|
+
return {
|
|
45
|
+
text: String(base.p.text ?? ""),
|
|
46
|
+
version: amendments.length ? amendments[amendments.length - 1].version : typeof base.p.version === "number" ? base.p.version : 1,
|
|
47
|
+
by: base.p.by ?? null,
|
|
48
|
+
amendments,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Set (or replace) this session's contract: version 1, no amendments. */
|
|
52
|
+
export function setContract(db, text, by) {
|
|
53
|
+
const t = capContract(text);
|
|
54
|
+
db.publish("contract.set", { agent_id: by, payload: { id: crypto.randomUUID(), session: sessionId(db), text: t, by, version: t ? 1 : 0 } });
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Amend the contract: bumps its version and publishes "contract.amended".
|
|
58
|
+
* With no contract yet (a small goal skips the council), it starts an empty
|
|
59
|
+
* one, so agents can still agree names mid-task.
|
|
60
|
+
*/
|
|
61
|
+
export function amendContract(db, a) {
|
|
62
|
+
if (!getContract(db))
|
|
63
|
+
setContract(db, "", null);
|
|
64
|
+
const c = getContract(db);
|
|
65
|
+
const section = String(a.section ?? "").trim().slice(0, 200);
|
|
66
|
+
const change = String(a.change ?? "").trim().slice(0, 1500);
|
|
67
|
+
// A retried tool call is the same amendment, not a new version (and not a second broadcast).
|
|
68
|
+
const last = c.amendments[c.amendments.length - 1];
|
|
69
|
+
if (last && last.by === a.by && last.section === section && last.change === change)
|
|
70
|
+
return { version: last.version, duplicate: true };
|
|
71
|
+
const sets = db.eventsOfType(["contract.set"], 0, 2000).map((e) => parse(e.payload)).filter((p) => (p.session ?? null) === sessionId(db));
|
|
72
|
+
const version = c.version + 1;
|
|
73
|
+
db.publish("contract.amended", {
|
|
74
|
+
agent_id: a.by,
|
|
75
|
+
payload: { base: sets[sets.length - 1].id, version, section, change, why: String(a.why ?? "").trim().slice(0, 500), by: a.by },
|
|
76
|
+
});
|
|
77
|
+
return { version };
|
|
78
|
+
}
|
|
79
|
+
/** The contract as one prompt section for a task's launch; "" when there is none. */
|
|
80
|
+
export function contractBrief(db) {
|
|
81
|
+
const c = getContract(db);
|
|
82
|
+
if (!c || (!c.text && !c.amendments.length))
|
|
83
|
+
return "";
|
|
84
|
+
const text = capContract(c.text);
|
|
85
|
+
const am = c.amendments.slice(-8).map((x) => `- v${x.version} by ${x.by}: ${x.section}: ${x.change}${x.why ? ` (${x.why})` : ""}`);
|
|
86
|
+
return [
|
|
87
|
+
// Not "see HUDDLE_CONTRACT.md": its commit can fail, and it never carries amendments.
|
|
88
|
+
`CONTRACT v${c.version} — the exact names every task codes to (huddle_contract shows the latest):`,
|
|
89
|
+
...(text ? [text] : []),
|
|
90
|
+
...(am.length ? ["Amendments:", ...am] : []),
|
|
91
|
+
"Use these exact names. Never invent a name another task depends on that isn't here: call huddle_amend_contract(section, change, why) first — it tells every teammate.",
|
|
92
|
+
"It never overrides the goal: where they differ on an exact output, message or format, the goal wins (amend this).",
|
|
93
|
+
].join("\n");
|
|
94
|
+
}
|
|
95
|
+
/** The contract as a file for the repo root, so every worktree has it. */
|
|
96
|
+
export function contractFile(text, by, members) {
|
|
97
|
+
return `# HUDDLE CONTRACT
|
|
98
|
+
|
|
99
|
+
The exact cross-task names every task codes to, ratified by the design council (${members.join(", ")})${by ? `, written by ${by}` : ""}.
|
|
100
|
+
To change a name, call huddle_amend_contract — don't edit this file. huddle_contract shows the latest version with amendments.
|
|
101
|
+
|
|
102
|
+
${text.trim()}
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
const NOT_NAMES = new Set(["function", "const", "export", "import", "return", "string", "number", "boolean", "null", "undefined", "void", "async", "await", "interface", "type", "class", "true", "false", "Promise", "Array", "Record", "Object", "JSON", "HUDDLE_CONTRACT"]);
|
|
106
|
+
/**
|
|
107
|
+
* The code names a contract fixes (for the radar's drift check): backticked
|
|
108
|
+
* identifiers, camelCase / snake_case / PascalCase-with-a-hump words, and
|
|
109
|
+
* anything called like `name(`. Plain English words are left out — a false
|
|
110
|
+
* "did you mean" sends an agent renaming code that works.
|
|
111
|
+
*/
|
|
112
|
+
export function contractNames(c) {
|
|
113
|
+
if (!c)
|
|
114
|
+
return [];
|
|
115
|
+
const text = [c.text, ...c.amendments.map((a) => a.change)].join("\n");
|
|
116
|
+
const out = new Set();
|
|
117
|
+
const add = (n) => {
|
|
118
|
+
if (n.length >= 3 && !NOT_NAMES.has(n) && /^[A-Za-z_$][\w$]*$/.test(n))
|
|
119
|
+
out.add(n);
|
|
120
|
+
};
|
|
121
|
+
for (const m of text.matchAll(/`([^`\n]{1,200})`/g))
|
|
122
|
+
for (const w of m[1].match(/[A-Za-z_$][\w$]*/g) ?? [])
|
|
123
|
+
if (/[a-z][A-Z]|_|^[A-Z][a-z]+[A-Z]/.test(w) || m[1].trim() === w)
|
|
124
|
+
add(w);
|
|
125
|
+
for (const m of text.matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g))
|
|
126
|
+
add(m[1]);
|
|
127
|
+
for (const w of text.match(/\b[A-Za-z_$][\w$]*\b/g) ?? [])
|
|
128
|
+
if (/[a-z][A-Z]|[a-z]_[a-z]|^[A-Z][a-z]+[A-Z]/.test(w))
|
|
129
|
+
add(w);
|
|
130
|
+
return [...out].slice(0, 300);
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=contract.js.map
|
package/dist/room/db.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// (sqlite.ts falls back to Node's built-in node:sqlite when the addon is missing.)
|
|
5
5
|
import { openDatabase } from "./sqlite.js";
|
|
6
6
|
import crypto from "node:crypto";
|
|
7
|
+
import nodePath from "node:path";
|
|
8
|
+
import { rememberLesson } from "./lessons.js";
|
|
7
9
|
import { nanoid } from "nanoid";
|
|
8
10
|
const SCHEMA = /* sql */ `
|
|
9
11
|
CREATE TABLE IF NOT EXISTS session (
|
|
@@ -103,7 +105,16 @@ CREATE TABLE IF NOT EXISTS message (
|
|
|
103
105
|
reply_to TEXT,
|
|
104
106
|
task_id TEXT,
|
|
105
107
|
created_at INTEGER NOT NULL,
|
|
106
|
-
read_at INTEGER
|
|
108
|
+
read_at INTEGER,
|
|
109
|
+
-- How it reached the recipient: piggyback | hook | inbox | ask | reply | prompt | cleared.
|
|
110
|
+
delivered_how TEXT
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
-- Every huddle_done, so evals can tell mail read mid-task from mail read at the end.
|
|
114
|
+
CREATE TABLE IF NOT EXISTS done_log (
|
|
115
|
+
agent_id TEXT NOT NULL,
|
|
116
|
+
task_id TEXT NOT NULL,
|
|
117
|
+
ts INTEGER NOT NULL
|
|
107
118
|
);
|
|
108
119
|
|
|
109
120
|
-- What a merged task hands its dependents: summary, files, exported signatures, notes.
|
|
@@ -128,13 +139,15 @@ CREATE INDEX IF NOT EXISTS idx_event_ts ON event(ts);
|
|
|
128
139
|
* directory holding the other.
|
|
129
140
|
*/
|
|
130
141
|
export function pathsOverlap(a, b) {
|
|
131
|
-
const norm = (p) => p.split("::")[0].trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
142
|
+
const norm = (p) => p.split("::")[0].trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
132
143
|
const x = norm(a);
|
|
133
144
|
const y = norm(b);
|
|
134
145
|
if (!x || !y)
|
|
135
146
|
return false;
|
|
136
147
|
return x === y || x.startsWith(y + "/") || y.startsWith(x + "/");
|
|
137
148
|
}
|
|
149
|
+
/** A declared path with forward slashes ("src\\a.ts" → "src/a.ts"). */
|
|
150
|
+
const slashed = (p) => String(p).replace(/\\/g, "/");
|
|
138
151
|
const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
|
139
152
|
/** Human-typable one-time code: 4-4-4 from an unambiguous alphabet. */
|
|
140
153
|
function inviteCode() {
|
|
@@ -144,10 +157,12 @@ function inviteCode() {
|
|
|
144
157
|
return `${s.slice(0, 4)}-${s.slice(4, 8)}-${s.slice(8, 12)}`;
|
|
145
158
|
}
|
|
146
159
|
export class RoomDB {
|
|
160
|
+
file;
|
|
147
161
|
db;
|
|
148
162
|
listeners = new Set();
|
|
149
|
-
constructor(
|
|
150
|
-
this.
|
|
163
|
+
constructor(file) {
|
|
164
|
+
this.file = file;
|
|
165
|
+
this.db = openDatabase(file);
|
|
151
166
|
this.db.pragma("journal_mode = WAL");
|
|
152
167
|
this.db.pragma("foreign_keys = ON");
|
|
153
168
|
this.db.exec(SCHEMA);
|
|
@@ -156,6 +171,7 @@ export class RoomDB {
|
|
|
156
171
|
"ALTER TABLE invite ADD COLUMN github TEXT",
|
|
157
172
|
"ALTER TABLE member ADD COLUMN github TEXT",
|
|
158
173
|
"ALTER TABLE task ADD COLUMN files TEXT NOT NULL DEFAULT '[]'",
|
|
174
|
+
"ALTER TABLE message ADD COLUMN delivered_how TEXT",
|
|
159
175
|
]) {
|
|
160
176
|
try {
|
|
161
177
|
this.db.exec(sql);
|
|
@@ -165,6 +181,14 @@ export class RoomDB {
|
|
|
165
181
|
}
|
|
166
182
|
}
|
|
167
183
|
}
|
|
184
|
+
/** Where the db file is (the shared screen keeps its files next to it). */
|
|
185
|
+
get path() {
|
|
186
|
+
return this.file;
|
|
187
|
+
}
|
|
188
|
+
/** The raw connection, for Room modules that keep their own tables (context.ts). */
|
|
189
|
+
get sql() {
|
|
190
|
+
return this.db;
|
|
191
|
+
}
|
|
168
192
|
/** Subscribe to every event written to the Room (drives WS fan-out). */
|
|
169
193
|
onEvent(fn) {
|
|
170
194
|
this.listeners.add(fn);
|
|
@@ -197,7 +221,8 @@ export class RoomDB {
|
|
|
197
221
|
}
|
|
198
222
|
currentSession() {
|
|
199
223
|
return this.db
|
|
200
|
-
|
|
224
|
+
// rowid breaks a same-millisecond tie: the session created last is current.
|
|
225
|
+
.prepare("SELECT * FROM session WHERE status='open' ORDER BY created_at DESC, rowid DESC LIMIT 1")
|
|
201
226
|
.get();
|
|
202
227
|
}
|
|
203
228
|
// ---- Agents -------------------------------------------------------------
|
|
@@ -286,7 +311,7 @@ export class RoomDB {
|
|
|
286
311
|
this.db.exec("DELETE FROM task; DELETE FROM plan; DELETE FROM file_claim; UPDATE agent SET status='offline';");
|
|
287
312
|
// Handoffs are keyed by this board's task ids; unread messages were about the old goal.
|
|
288
313
|
this.db.prepare("DELETE FROM handoff").run();
|
|
289
|
-
this.db.prepare("UPDATE message SET read_at
|
|
314
|
+
this.db.prepare("UPDATE message SET read_at=?, delivered_how='cleared' WHERE read_at IS NULL").run(Date.now());
|
|
290
315
|
this.emit("board.cleared", {});
|
|
291
316
|
}
|
|
292
317
|
/** Put a claimed task back on the board (a teammate declined or left). */
|
|
@@ -334,6 +359,8 @@ export class RoomDB {
|
|
|
334
359
|
}
|
|
335
360
|
// ---- Plans & overlap ----------------------------------------------------
|
|
336
361
|
announcePlan(p) {
|
|
362
|
+
// An agent on Windows may say src\a.ts; leases are kept as src/a.ts.
|
|
363
|
+
p = { ...p, touches: p.touches.map(slashed) };
|
|
337
364
|
const conflicts = this.checkOverlap(p.touches, p.agent_id);
|
|
338
365
|
const plan = {
|
|
339
366
|
id: nanoid(8),
|
|
@@ -361,6 +388,7 @@ export class RoomDB {
|
|
|
361
388
|
}
|
|
362
389
|
/** Which touched paths/symbols are already held by *someone else*. */
|
|
363
390
|
checkOverlap(touches, byAgent) {
|
|
391
|
+
touches = touches.map(slashed);
|
|
364
392
|
if (!touches.length)
|
|
365
393
|
return [];
|
|
366
394
|
const now = Date.now();
|
|
@@ -376,6 +404,21 @@ export class RoomDB {
|
|
|
376
404
|
const leased = this.db.prepare("SELECT path FROM file_claim WHERE expires_at > ?").all(Date.now());
|
|
377
405
|
return [...active.flatMap((r) => JSON.parse(r.files ?? "[]")), ...leased.map((r) => r.path)];
|
|
378
406
|
}
|
|
407
|
+
/** A split part takes these files: they leave the parent's file list and the parent's leases on them end. */
|
|
408
|
+
handOverFiles(parentId, files) {
|
|
409
|
+
const t = this.getTask(parentId);
|
|
410
|
+
if (!t)
|
|
411
|
+
return;
|
|
412
|
+
// A parent entry that is a whole directory around a part's file stays: it can't be split finer.
|
|
413
|
+
const norm = (p) => p.split("::")[0].trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
414
|
+
const handed = (f) => files.some((p) => pathsOverlap(f, p) && !norm(p).startsWith(norm(f) + "/"));
|
|
415
|
+
const keep = JSON.parse(t.files ?? "[]").filter((f) => !handed(f));
|
|
416
|
+
this.db.prepare("UPDATE task SET files=?, updated_at=? WHERE id=?").run(JSON.stringify(keep), Date.now(), parentId);
|
|
417
|
+
const leases = this.db.prepare("SELECT path FROM file_claim WHERE task_id=?").all(parentId);
|
|
418
|
+
for (const l of leases)
|
|
419
|
+
if (handed(l.path))
|
|
420
|
+
this.db.prepare("DELETE FROM file_claim WHERE path=? AND task_id=?").run(l.path, parentId);
|
|
421
|
+
}
|
|
379
422
|
releaseClaims(agentId) {
|
|
380
423
|
this.db.prepare("DELETE FROM file_claim WHERE held_by=?").run(agentId);
|
|
381
424
|
}
|
|
@@ -514,26 +557,42 @@ export class RoomDB {
|
|
|
514
557
|
getMessage(id) {
|
|
515
558
|
return this.db.prepare("SELECT * FROM message WHERE id=?").get(id);
|
|
516
559
|
}
|
|
517
|
-
/**
|
|
560
|
+
/**
|
|
561
|
+
* Unread messages for `agent`, oldest first (at most `limit`). Reading marks
|
|
562
|
+
* them read, recording `how` they were delivered, unless `peek`.
|
|
563
|
+
*/
|
|
518
564
|
inbox(agent, opts = {}) {
|
|
519
565
|
const rows = this.db
|
|
520
|
-
.prepare("SELECT * FROM message WHERE to_agent=? AND read_at IS NULL ORDER BY created_at, rowid")
|
|
521
|
-
.all(agent);
|
|
522
|
-
if (!opts.peek
|
|
523
|
-
const mark = this.db.prepare("UPDATE message SET read_at=? WHERE id=?");
|
|
524
|
-
const now = Date.now();
|
|
566
|
+
.prepare("SELECT * FROM message WHERE to_agent=? AND read_at IS NULL ORDER BY created_at, rowid LIMIT ?")
|
|
567
|
+
.all(agent, opts.limit ?? -1);
|
|
568
|
+
if (!opts.peek)
|
|
525
569
|
for (const r of rows)
|
|
526
|
-
|
|
527
|
-
}
|
|
570
|
+
this.markRead(r.id, opts.how ?? "inbox");
|
|
528
571
|
return rows;
|
|
529
572
|
}
|
|
573
|
+
/** Messages `agent` already got in a tool result or hook since `sinceTs`, newest last. */
|
|
574
|
+
recentlyDelivered(agent, sinceTs, limit = 3) {
|
|
575
|
+
const rows = this.db
|
|
576
|
+
.prepare(`SELECT * FROM message WHERE to_agent=? AND read_at >= ? AND delivered_how IN ('piggyback','hook')
|
|
577
|
+
ORDER BY read_at DESC, rowid DESC LIMIT ?`)
|
|
578
|
+
.all(agent, sinceTs, limit);
|
|
579
|
+
return rows.reverse();
|
|
580
|
+
}
|
|
581
|
+
/** Every message since `sinceTs` with when/how it was read — the input to delivery metrics. */
|
|
582
|
+
messagesSince(sinceTs = 0) {
|
|
583
|
+
return this.db.prepare("SELECT * FROM message WHERE created_at >= ? ORDER BY created_at, rowid").all(sinceTs);
|
|
584
|
+
}
|
|
585
|
+
/** When each agent called huddle_done (oldest first). */
|
|
586
|
+
doneTimes(sinceTs = 0) {
|
|
587
|
+
return this.db.prepare("SELECT * FROM done_log WHERE ts >= ? ORDER BY ts, rowid").all(sinceTs);
|
|
588
|
+
}
|
|
530
589
|
/** Unread count and senders — the piggyback nudge on every tool result. */
|
|
531
590
|
unread(agent) {
|
|
532
591
|
const rows = this.db.prepare("SELECT from_agent FROM message WHERE to_agent=? AND read_at IS NULL").all(agent);
|
|
533
592
|
return { count: rows.length, from: [...new Set(rows.map((r) => r.from_agent))] };
|
|
534
593
|
}
|
|
535
|
-
markRead(id) {
|
|
536
|
-
this.db.prepare("UPDATE message SET read_at=? WHERE id=? AND read_at IS NULL").run(Date.now(), id);
|
|
594
|
+
markRead(id, how = "inbox") {
|
|
595
|
+
this.db.prepare("UPDATE message SET read_at=?, delivered_how=? WHERE id=? AND read_at IS NULL").run(Date.now(), how, id);
|
|
537
596
|
}
|
|
538
597
|
/**
|
|
539
598
|
* The reply to a routed question: an explicit reply_to, or — since models
|
|
@@ -549,6 +608,7 @@ export class RoomDB {
|
|
|
549
608
|
// ---- Handoffs -------------------------------------------------------------
|
|
550
609
|
/** huddle_done's summary, kept for the handoff the merge will write. */
|
|
551
610
|
noteDone(taskId, agentId, summary) {
|
|
611
|
+
this.db.prepare("INSERT INTO done_log (agent_id, task_id, ts) VALUES (?, ?, ?)").run(agentId, taskId, Date.now());
|
|
552
612
|
this.db
|
|
553
613
|
.prepare(`INSERT INTO handoff (task_id, agent_id, title, summary, created_at) VALUES (?, ?, COALESCE((SELECT title FROM task WHERE id=?), ''), ?, ?)
|
|
554
614
|
ON CONFLICT(task_id) DO UPDATE SET summary=excluded.summary, agent_id=excluded.agent_id`)
|
|
@@ -593,6 +653,9 @@ export class RoomDB {
|
|
|
593
653
|
.prepare("INSERT INTO memory (id,kind,body,why,author,created_at) VALUES (@id,@kind,@body,@why,@author,@created_at)")
|
|
594
654
|
.run(mem);
|
|
595
655
|
this.emit("memory.added", { agent_id: m.author, payload: mem });
|
|
656
|
+
// A lesson outlives the room: <repo>/.huddle/huddle.db → that repo's lessons.
|
|
657
|
+
if (m.kind === "lesson")
|
|
658
|
+
rememberLesson({ body: m.body, why: m.why, author: m.author, root: nodePath.basename(nodePath.dirname(this.file)) === ".huddle" ? nodePath.dirname(nodePath.dirname(this.file)) : null });
|
|
596
659
|
return mem;
|
|
597
660
|
}
|
|
598
661
|
recall(query) {
|
|
@@ -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
|
package/dist/room/identity.js
CHANGED
|
@@ -22,7 +22,7 @@ export async function githubKeys(user) {
|
|
|
22
22
|
const r = await fetch(`https://github.com/${encodeURIComponent(user)}.keys`, { signal: AbortSignal.timeout(8000) });
|
|
23
23
|
if (!r.ok)
|
|
24
24
|
return [];
|
|
25
|
-
const keys = (await r.text()).split(
|
|
25
|
+
const keys = (await r.text()).split(/\r?\n/).filter((l) => l.startsWith("ssh-")).map(bare);
|
|
26
26
|
cache.set(user.toLowerCase(), { at: Date.now(), keys });
|
|
27
27
|
return keys;
|
|
28
28
|
}
|
|
@@ -77,7 +77,7 @@ export async function signAs(user, nonce) {
|
|
|
77
77
|
const r = spawnSync("ssh-keygen", ["-Y", "sign", "-q", "-f", mine.priv, "-n", NAMESPACE, msg], { stdio: ["inherit", "pipe", "pipe"] });
|
|
78
78
|
try {
|
|
79
79
|
if (r.status !== 0)
|
|
80
|
-
return { ok: false, reason: `ssh-keygen couldn't sign: ${String(r.stderr).trim().split(
|
|
80
|
+
return { ok: false, reason: `ssh-keygen couldn't sign: ${String(r.stderr).trim().split(/\r?\n/)[0]}` };
|
|
81
81
|
return { ok: true, key: mine.key, sig: fs.readFileSync(msg + ".sig", "utf8") };
|
|
82
82
|
}
|
|
83
83
|
finally {
|