huddle-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/QUICKSTART.md +102 -0
  3. package/README.md +23 -4
  4. package/dist/adapters/amp.js +1 -1
  5. package/dist/adapters/antigravity.js +2 -0
  6. package/dist/adapters/claude.js +19 -3
  7. package/dist/adapters/codex.js +6 -1
  8. package/dist/adapters/cursor.js +12 -5
  9. package/dist/adapters/custom.js +4 -2
  10. package/dist/adapters/droid.js +1 -0
  11. package/dist/adapters/goose.js +2 -0
  12. package/dist/adapters/index.js +44 -18
  13. package/dist/adapters/kiro.js +1 -1
  14. package/dist/adapters/opencode.js +1 -1
  15. package/dist/attach/connect.js +4 -1
  16. package/dist/attach/guard.js +21 -2
  17. package/dist/attach/live.js +5 -1
  18. package/dist/attach/plug.js +14 -3
  19. package/dist/attach/screen.js +150 -0
  20. package/dist/attach/session.js +7 -2
  21. package/dist/cli.js +108 -40
  22. package/dist/mcp/context-tools.js +79 -0
  23. package/dist/mcp/server.js +38 -11
  24. package/dist/orchestrator/ask.js +13 -10
  25. package/dist/orchestrator/brain.js +13 -3
  26. package/dist/orchestrator/council.js +427 -86
  27. package/dist/orchestrator/goal.js +196 -0
  28. package/dist/orchestrator/handoff.js +2 -2
  29. package/dist/orchestrator/launcher.js +29 -13
  30. package/dist/orchestrator/learn.js +105 -0
  31. package/dist/orchestrator/planner.js +25 -7
  32. package/dist/orchestrator/qa.js +62 -14
  33. package/dist/orchestrator/radar.js +719 -0
  34. package/dist/orchestrator/scheduler.js +498 -39
  35. package/dist/orchestrator/takeover.js +155 -0
  36. package/dist/platform.js +273 -0
  37. package/dist/room/context.js +1285 -0
  38. package/dist/room/contract.js +132 -0
  39. package/dist/room/db.js +79 -16
  40. package/dist/room/followup.js +233 -0
  41. package/dist/room/identity.js +2 -2
  42. package/dist/room/lessons.js +275 -0
  43. package/dist/room/live.js +75 -20
  44. package/dist/room/mail.js +212 -0
  45. package/dist/room/progress.js +40 -1
  46. package/dist/room/server.js +169 -39
  47. package/dist/room/tunnel.js +6 -5
  48. package/dist/sandbox/sync.js +4 -4
  49. package/dist/sandbox/worktree.js +145 -14
  50. package/dist/ui/app.js +554 -133
  51. package/dist/ui/catalog.js +62 -24
  52. package/dist/ui/dashboard.js +194 -51
  53. package/dist/ui/doctor.js +15 -11
  54. package/dist/ui/lessons.js +81 -0
  55. package/dist/ui/paste.js +515 -0
  56. package/dist/ui/remote.js +11 -5
  57. package/dist/ui/setup.js +152 -0
  58. package/dist/ui/story.js +666 -69
  59. package/dist/ui/term.js +151 -16
  60. package/dist/ui/watch.js +145 -48
  61. package/package.json +6 -3
@@ -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(path) {
150
- this.db = openDatabase(path);
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
- .prepare("SELECT * FROM session WHERE status='open' ORDER BY created_at DESC LIMIT 1")
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=? WHERE read_at IS NULL").run(Date.now());
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
- /** Unread messages for `agent`, oldest first. Reading marks them read unless `peek`. */
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 && rows.length) {
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
- mark.run(now, r.id);
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
@@ -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("\n").filter((l) => l.startsWith("ssh-")).map(bare);
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("\n")[0]}` };
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 {