@tritard/waterbrother 0.16.21 → 0.16.23

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/README.md CHANGED
@@ -261,6 +261,14 @@ Rollout order:
261
261
  3. approvals over messaging
262
262
  4. only then group DM collaboration
263
263
 
264
+ Shared project foundation is now live:
265
+ - enable it with `waterbrother project share`
266
+ - inspect it with `waterbrother room status`
267
+ - control conversation vs execution with `waterbrother room mode chat|plan|execute`
268
+ - claim or release the shared operator lock with `waterbrother room claim` and `waterbrother room release`
269
+ - shared project metadata lives in `.waterbrother/shared.json`
270
+ - human collaboration notes live in `ROUNDTABLE.md`
271
+
264
272
  Current Telegram behavior:
265
273
  - if the TUI is open, Telegram prompts are injected into the live TUI session and the work is visible in the terminal
266
274
  - if no live TUI session is attached, Telegram falls back to a remote run with `approval=never`
@@ -269,6 +277,8 @@ Current Telegram behavior:
269
277
  - pending pairings are explicit and expire automatically after 12 hours unless approved
270
278
  - paired Telegram users drive the same live session and permissions as the terminal operator when the TUI bridge is attached
271
279
  - Telegram now supports remote workspace control with `/cwd`, `/use <path>`, `/desktop`, and `/new-project <name>`
280
+ - shared projects now support `/room`, `/mode`, `/claim`, and `/release` from Telegram with a single active-operator lock
281
+ - shared Telegram execution only runs when the shared room is in `execute` mode
272
282
  - pairing is now explicit: first DM creates a pending request, then approve locally with `waterbrother gateway pair telegram <user-id>`
273
283
 
274
284
  ## Release flow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.16.21",
3
+ "version": "0.16.23",
4
4
  "description": "Waterbrother: bring-your-own-model coding CLI with local tools, sessions, operator modes, and approval controls",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -57,6 +57,16 @@ import { runQualityChecks, formatQualityFindings, buildQualityFixPrompt } from "
57
57
  import { scanForInitiatives, formatInitiatives, buildInitiativeFixPrompt } from "./initiative.js";
58
58
  import { formatPlanForDisplay } from "./planner.js";
59
59
  import { parseCharterFromGoal, runExperimentLoop, formatExperimentSummary, gitReturnToBranch } from "./experiment.js";
60
+ import {
61
+ claimSharedOperator,
62
+ disableSharedProject,
63
+ enableSharedProject,
64
+ formatSharedProjectStatus,
65
+ getSharedProjectPaths,
66
+ loadSharedProject,
67
+ releaseSharedOperator,
68
+ setSharedRoomMode
69
+ } from "./shared-project.js";
60
70
 
61
71
  const execFileAsync = promisify(execFile);
62
72
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -135,6 +145,12 @@ const INTERACTIVE_COMMANDS = [
135
145
  { name: "/gateway unpair <user-id>", description: "Remove a paired Telegram user id" },
136
146
  { name: "/gateway stop", description: "Stop the tracked Telegram gateway process" },
137
147
  { name: "/channels", description: "Show messaging channel readiness" },
148
+ { name: "/share-project", description: "Enable shared-project mode in the current cwd" },
149
+ { name: "/unshare-project", description: "Disable shared-project mode in the current cwd" },
150
+ { name: "/room", description: "Show shared room status for the current project" },
151
+ { name: "/room mode <chat|plan|execute>", description: "Set collaboration mode for the shared room" },
152
+ { name: "/room claim", description: "Claim operator control for the shared room" },
153
+ { name: "/room release", description: "Release operator control for the shared room" },
138
154
  { name: "/cwd", description: "Show current working directory" },
139
155
  { name: "/use <path>", description: "Switch the live session to a different working directory" },
140
156
  { name: "/desktop", description: "Switch the live session to ~/Desktop" },
@@ -253,6 +269,12 @@ Usage:
253
269
  waterbrother gateway pairings [telegram]
254
270
  waterbrother gateway pair [telegram] <user-id>
255
271
  waterbrother gateway unpair [telegram] <user-id>
272
+ waterbrother project share
273
+ waterbrother project unshare
274
+ waterbrother room status
275
+ waterbrother room mode <chat|plan|execute>
276
+ waterbrother room claim
277
+ waterbrother room release
256
278
  waterbrother mcp list
257
279
  waterbrother commit [--push]
258
280
  waterbrother pr [--branch=<name>]
@@ -3697,6 +3719,100 @@ async function runGatewayCommand(positional, runtime, { cwd = process.cwd(), asJ
3697
3719
  throw new Error("Usage: waterbrother gateway status|run <telegram>|stop [telegram]|pairings [telegram]|pair [telegram] <user-id>|unpair [telegram] <user-id>");
3698
3720
  }
3699
3721
 
3722
+ function getLocalOperatorIdentity() {
3723
+ const userName = String(process.env.USER || process.env.USERNAME || "local").trim() || "local";
3724
+ return {
3725
+ id: `local:${userName}`,
3726
+ name: userName
3727
+ };
3728
+ }
3729
+
3730
+ async function runProjectCommand(positional, { cwd = process.cwd(), asJson = false } = {}) {
3731
+ const sub = String(positional[1] || "").trim().toLowerCase();
3732
+ if (sub === "share") {
3733
+ const operator = getLocalOperatorIdentity();
3734
+ const project = await enableSharedProject(cwd, { userId: operator.id, userName: operator.name, role: "owner" });
3735
+ const paths = getSharedProjectPaths(cwd);
3736
+ if (asJson) {
3737
+ printData({ ok: true, action: "share", project, paths }, true);
3738
+ return;
3739
+ }
3740
+ console.log(`Shared project enabled in ${cwd}`);
3741
+ console.log(`shared metadata: ${paths.sharedJson}`);
3742
+ console.log(`roundtable: ${paths.roundtable}`);
3743
+ return;
3744
+ }
3745
+
3746
+ if (sub === "unshare") {
3747
+ const project = await disableSharedProject(cwd);
3748
+ if (asJson) {
3749
+ printData({ ok: true, action: "unshare", project }, true);
3750
+ return;
3751
+ }
3752
+ console.log(project ? `Shared project disabled in ${cwd}` : "Project was not shared");
3753
+ return;
3754
+ }
3755
+
3756
+ throw new Error("Usage: waterbrother project share|unshare");
3757
+ }
3758
+
3759
+ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false } = {}) {
3760
+ const sub = String(positional[1] || "status").trim().toLowerCase();
3761
+ if (sub === "status") {
3762
+ const project = await loadSharedProject(cwd);
3763
+ if (asJson) {
3764
+ printData(project || { enabled: false, cwd }, true);
3765
+ return;
3766
+ }
3767
+ console.log(formatSharedProjectStatus(project));
3768
+ return;
3769
+ }
3770
+
3771
+ if (sub === "claim") {
3772
+ const operator = getLocalOperatorIdentity();
3773
+ const project = await claimSharedOperator(cwd, operator);
3774
+ if (asJson) {
3775
+ printData({ ok: true, action: "claim", project }, true);
3776
+ return;
3777
+ }
3778
+ console.log(`Room claimed by ${operator.name}`);
3779
+ return;
3780
+ }
3781
+
3782
+ if (sub === "release") {
3783
+ const operator = getLocalOperatorIdentity();
3784
+ const project = await releaseSharedOperator(cwd, operator.id);
3785
+ if (asJson) {
3786
+ printData({ ok: true, action: "release", project }, true);
3787
+ return;
3788
+ }
3789
+ console.log(`Room released by ${operator.name}`);
3790
+ return;
3791
+ }
3792
+
3793
+ if (sub === "mode") {
3794
+ const nextMode = String(positional[2] || "").trim().toLowerCase();
3795
+ if (!nextMode) {
3796
+ const project = await loadSharedProject(cwd);
3797
+ if (asJson) {
3798
+ printData({ ok: true, roomMode: project?.roomMode || null, project }, true);
3799
+ return;
3800
+ }
3801
+ console.log(project?.roomMode || "shared project: off");
3802
+ return;
3803
+ }
3804
+ const project = await setSharedRoomMode(cwd, nextMode);
3805
+ if (asJson) {
3806
+ printData({ ok: true, action: "mode", roomMode: project.roomMode, project }, true);
3807
+ return;
3808
+ }
3809
+ console.log(`Room mode set to ${project.roomMode}`);
3810
+ return;
3811
+ }
3812
+
3813
+ throw new Error("Usage: waterbrother room status|mode <chat|plan|execute>|claim|release");
3814
+ }
3815
+
3700
3816
  async function runMcpCommand(positional, runtime, { cwd, asJson = false } = {}) {
3701
3817
  const sub = positional[1] || "list";
3702
3818
  if (sub !== "list") {
@@ -7495,6 +7611,65 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7495
7611
  continue;
7496
7612
  }
7497
7613
 
7614
+ if (line === "/share-project") {
7615
+ try {
7616
+ await runProjectCommand(["project", "share"], { cwd: context.cwd, asJson: false });
7617
+ } catch (error) {
7618
+ console.log(`share-project failed: ${error instanceof Error ? error.message : String(error)}`);
7619
+ }
7620
+ continue;
7621
+ }
7622
+
7623
+ if (line === "/unshare-project") {
7624
+ try {
7625
+ await runProjectCommand(["project", "unshare"], { cwd: context.cwd, asJson: false });
7626
+ } catch (error) {
7627
+ console.log(`unshare-project failed: ${error instanceof Error ? error.message : String(error)}`);
7628
+ }
7629
+ continue;
7630
+ }
7631
+
7632
+ if (line === "/room") {
7633
+ try {
7634
+ await runRoomCommand(["room", "status"], { cwd: context.cwd, asJson: false });
7635
+ } catch (error) {
7636
+ console.log(`room status failed: ${error instanceof Error ? error.message : String(error)}`);
7637
+ }
7638
+ continue;
7639
+ }
7640
+
7641
+ if (line.startsWith("/room mode ")) {
7642
+ const nextMode = line.replace("/room mode", "").trim().toLowerCase();
7643
+ if (!nextMode) {
7644
+ console.log("Usage: /room mode <chat|plan|execute>");
7645
+ continue;
7646
+ }
7647
+ try {
7648
+ await runRoomCommand(["room", "mode", nextMode], { cwd: context.cwd, asJson: false });
7649
+ } catch (error) {
7650
+ console.log(`room mode failed: ${error instanceof Error ? error.message : String(error)}`);
7651
+ }
7652
+ continue;
7653
+ }
7654
+
7655
+ if (line === "/room claim") {
7656
+ try {
7657
+ await runRoomCommand(["room", "claim"], { cwd: context.cwd, asJson: false });
7658
+ } catch (error) {
7659
+ console.log(`room claim failed: ${error instanceof Error ? error.message : String(error)}`);
7660
+ }
7661
+ continue;
7662
+ }
7663
+
7664
+ if (line === "/room release") {
7665
+ try {
7666
+ await runRoomCommand(["room", "release"], { cwd: context.cwd, asJson: false });
7667
+ } catch (error) {
7668
+ console.log(`room release failed: ${error instanceof Error ? error.message : String(error)}`);
7669
+ }
7670
+ continue;
7671
+ }
7672
+
7498
7673
  if (line === "/gateway pairings") {
7499
7674
  const ttlMinutes = Number.isFinite(Number(context.runtime.channels?.telegram?.pairingExpiryMinutes))
7500
7675
  ? Math.max(1, Math.floor(Number(context.runtime.channels.telegram.pairingExpiryMinutes)))
@@ -9311,6 +9486,16 @@ export async function runCli(argv) {
9311
9486
  return;
9312
9487
  }
9313
9488
 
9489
+ if (command === "project") {
9490
+ await runProjectCommand(positional, { cwd: startupCwd, asJson });
9491
+ return;
9492
+ }
9493
+
9494
+ if (command === "room") {
9495
+ await runRoomCommand(positional, { cwd: startupCwd, asJson });
9496
+ return;
9497
+ }
9498
+
9314
9499
  if (command === "gateway") {
9315
9500
  await runGatewayCommand(positional, runtime, { cwd: startupCwd, asJson });
9316
9501
  return;
package/src/gateway.js CHANGED
@@ -8,6 +8,7 @@ import { createSession, listSessions, loadSession, saveSession } from "./session
8
8
  import { DEFAULT_PENDING_PAIRING_TTL_MINUTES, loadGatewayBridge, loadGatewayState, prunePendingPairings, saveGatewayBridge, saveGatewayState } from "./gateway-state.js";
9
9
  import { getGatewayStatus, getChannelSpec } from "./channels.js";
10
10
  import { canonicalizeLoosePath } from "./path-utils.js";
11
+ import { claimSharedOperator, loadSharedProject, releaseSharedOperator, setSharedRoom, setSharedRoomMode } from "./shared-project.js";
11
12
 
12
13
  const execFileAsync = promisify(execFile);
13
14
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -24,6 +25,10 @@ const TELEGRAM_COMMANDS = [
24
25
  { command: "status", description: "Show the linked remote session" },
25
26
  { command: "cwd", description: "Show the current remote working directory" },
26
27
  { command: "runtime", description: "Show active runtime status" },
28
+ { command: "room", description: "Show shared room status" },
29
+ { command: "mode", description: "Show or set shared room mode" },
30
+ { command: "claim", description: "Claim operator control for a shared project" },
31
+ { command: "release", description: "Release operator control for a shared project" },
27
32
  { command: "sessions", description: "List recent remote sessions" },
28
33
  { command: "new", description: "Start a fresh remote session" },
29
34
  { command: "clear", description: "Clear current remote conversation" }
@@ -193,6 +198,10 @@ function buildRemoteHelp() {
193
198
  "<code>/desktop</code> switch the linked session to <code>~/Desktop</code>",
194
199
  "<code>/new-project &lt;name&gt;</code> create a folder on Desktop and switch into it",
195
200
  "<code>/runtime</code> show active provider/model/runtime state",
201
+ "<code>/room</code> show shared project room status",
202
+ "<code>/mode</code> or <code>/mode &lt;chat|plan|execute&gt;</code> inspect or change shared room mode",
203
+ "<code>/claim</code> claim operator control for a shared project",
204
+ "<code>/release</code> release operator control for a shared project",
196
205
  "<code>/sessions</code> list recent linked remote sessions",
197
206
  "<code>/resume &lt;session-id&gt;</code> switch the linked remote session",
198
207
  "<code>/new</code> start a fresh remote session",
@@ -257,6 +266,32 @@ function formatSessionListMarkup(currentSessionId, sessions = []) {
257
266
  return lines.join("\n");
258
267
  }
259
268
 
269
+ function formatTelegramRoomMarkup(project) {
270
+ if (!project?.enabled) {
271
+ return "<b>Shared room</b>\nThis project is not shared.";
272
+ }
273
+ const members = Array.isArray(project.members) ? project.members : [];
274
+ const active = project.activeOperator?.id
275
+ ? `${project.activeOperator.name || project.activeOperator.id} (${project.activeOperator.id})`
276
+ : "none";
277
+ const roomLabel = project.room?.chatId
278
+ ? `${project.room.provider || "telegram"} ${project.room.chatId}${project.room.title ? ` (${project.room.title})` : ""}`
279
+ : "not linked";
280
+ const memberLines = members.length
281
+ ? members.map((member) => `• ${escapeTelegramHtml(member.name || member.id)} <i>(${escapeTelegramHtml(member.role || "editor")})</i>`).join("\n")
282
+ : "• none";
283
+ return [
284
+ "<b>Shared room</b>",
285
+ `project: <code>${escapeTelegramHtml(project.projectName || "")}</code>`,
286
+ `mode: <code>${escapeTelegramHtml(project.mode || "single-operator")}</code>`,
287
+ `room mode: <code>${escapeTelegramHtml(project.roomMode || "chat")}</code>`,
288
+ `room: <code>${escapeTelegramHtml(roomLabel)}</code>`,
289
+ `active operator: <code>${escapeTelegramHtml(active)}</code>`,
290
+ "<b>Members</b>",
291
+ memberLines
292
+ ].join("\n");
293
+ }
294
+
260
295
  function extractRetryDelayMs(error, attempt) {
261
296
  const retryAfter = Number(error?.retryAfterSeconds);
262
297
  if (Number.isFinite(retryAfter) && retryAfter > 0) {
@@ -565,6 +600,52 @@ class TelegramGateway {
565
600
  return session.cwd;
566
601
  }
567
602
 
603
+ async loadSharedProjectForSession(sessionId) {
604
+ const session = await loadSession(sessionId);
605
+ const project = await loadSharedProject(session.cwd || this.cwd);
606
+ return { session, project };
607
+ }
608
+
609
+ async bindSharedRoomForMessage(message, sessionId) {
610
+ const { session, project } = await this.loadSharedProjectForSession(sessionId);
611
+ if (!project?.enabled) {
612
+ return { session, project };
613
+ }
614
+ const next = await setSharedRoom(session.cwd || this.cwd, {
615
+ provider: "telegram",
616
+ chatId: String(message.chat.id),
617
+ title: String(message.chat.title || "").trim()
618
+ });
619
+ return { session, project: next };
620
+ }
621
+
622
+ async ensureSharedOperator(message, sessionId) {
623
+ const { session, project } = await this.loadSharedProjectForSession(sessionId);
624
+ if (!project?.enabled) return { ok: true, project: null, session };
625
+ const bound = await this.bindSharedRoomForMessage(message, sessionId);
626
+ if (bound.project?.roomMode !== "execute") {
627
+ return {
628
+ ok: false,
629
+ project: bound.project,
630
+ session: bound.session,
631
+ reason: `Shared room is in ${bound.project?.roomMode || "chat"} mode. Switch to /mode execute before running code.`
632
+ };
633
+ }
634
+ const userId = String(message?.from?.id || "").trim();
635
+ if (!bound.project?.activeOperator?.id) {
636
+ return { ok: false, project: bound.project, session: bound.session, reason: "No active operator. Use /claim first." };
637
+ }
638
+ if (bound.project.activeOperator.id !== userId) {
639
+ return {
640
+ ok: false,
641
+ project: bound.project,
642
+ session: bound.session,
643
+ reason: `Room is currently claimed by ${bound.project.activeOperator.name || bound.project.activeOperator.id}. Use /claim after they release it.`
644
+ };
645
+ }
646
+ return { ok: true, project: bound.project, session: bound.session };
647
+ }
648
+
568
649
  async getLiveBridgeHost() {
569
650
  const bridge = await loadGatewayBridge("telegram");
570
651
  const host = bridge.activeHost || {};
@@ -793,6 +874,73 @@ class TelegramGateway {
793
874
  return;
794
875
  }
795
876
 
877
+ if (text === "/room") {
878
+ const { project } = await this.bindSharedRoomForMessage(message, sessionId);
879
+ await this.sendMessage(message.chat.id, project?.enabled ? formatTelegramRoomMarkup(project) : "<b>Shared room</b>\nThis project is not shared.", message.message_id);
880
+ return;
881
+ }
882
+
883
+ if (text === "/mode") {
884
+ const { project } = await this.bindSharedRoomForMessage(message, sessionId);
885
+ await this.sendMessage(
886
+ message.chat.id,
887
+ project?.enabled
888
+ ? `<b>Shared room mode</b>\n<code>${escapeTelegramHtml(project.roomMode || "chat")}</code>`
889
+ : "This project is not shared.",
890
+ message.message_id
891
+ );
892
+ return;
893
+ }
894
+
895
+ if (text.startsWith("/mode ")) {
896
+ const requestedMode = text.replace("/mode", "").trim().toLowerCase();
897
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
898
+ if (!project?.enabled) {
899
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
900
+ return;
901
+ }
902
+ try {
903
+ const next = await setSharedRoomMode(session.cwd || this.cwd, requestedMode);
904
+ await this.sendMessage(message.chat.id, `Shared room mode set to <code>${escapeTelegramHtml(next.roomMode)}</code>`, message.message_id);
905
+ } catch (error) {
906
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
907
+ }
908
+ return;
909
+ }
910
+
911
+ if (text === "/claim") {
912
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
913
+ if (!project?.enabled) {
914
+ await this.sendMessage(message.chat.id, "This project is not shared. Enable sharing from the terminal first with <code>waterbrother project share</code>.", message.message_id);
915
+ return;
916
+ }
917
+ try {
918
+ const claimed = await claimSharedOperator(session.cwd || this.cwd, {
919
+ id: userId,
920
+ name: peer?.username || [message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim() || userId
921
+ });
922
+ await this.sendMessage(message.chat.id, `Shared room claimed by <code>${escapeTelegramHtml(claimed.activeOperator?.name || claimed.activeOperator?.id || userId)}</code>`, message.message_id);
923
+ } catch (error) {
924
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
925
+ }
926
+ return;
927
+ }
928
+
929
+ if (text === "/release") {
930
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
931
+ if (!project?.enabled) {
932
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
933
+ return;
934
+ }
935
+ try {
936
+ const released = await releaseSharedOperator(session.cwd || this.cwd, userId);
937
+ await this.sendMessage(message.chat.id, released.activeOperator?.id ? formatTelegramRoomMarkup(released) : "Shared room released.", message.message_id);
938
+ } catch (error) {
939
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
940
+ }
941
+ return;
942
+ }
943
+
796
944
  if (text === "/runtime") {
797
945
  const status = await this.runRuntimeStatus();
798
946
  await this.sendMessage(message.chat.id, formatRuntimeStatus(status), message.message_id);
@@ -903,6 +1051,11 @@ class TelegramGateway {
903
1051
  const stopTyping = await this.startTypingLoop(message.chat.id);
904
1052
  let previewMessage = null;
905
1053
  try {
1054
+ const operatorGate = await this.ensureSharedOperator(message, sessionId);
1055
+ if (!operatorGate.ok) {
1056
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(operatorGate.reason || "Shared room is not available."), message.message_id, { parseMode: "HTML" });
1057
+ return;
1058
+ }
906
1059
  previewMessage = await this.sendProgressMessage(message.chat.id, message.message_id);
907
1060
  const content = (await this.runPromptViaBridge(message, sessionId, text))
908
1061
  ?? (await this.runPromptFallback(sessionId, text));
@@ -0,0 +1,279 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const SHARED_FILE = path.join(".waterbrother", "shared.json");
7
+ const ROUNDTABLE_FILE = "ROUNDTABLE.md";
8
+
9
+ function normalizeMember(member = {}) {
10
+ return {
11
+ id: String(member.id || "").trim(),
12
+ name: String(member.name || "").trim(),
13
+ role: ["owner", "editor", "observer"].includes(String(member.role || "").trim()) ? String(member.role).trim() : "editor",
14
+ paired: member.paired !== false
15
+ };
16
+ }
17
+
18
+ function normalizeSharedProject(project = {}, cwd = process.cwd()) {
19
+ const members = Array.isArray(project.members) ? project.members.map(normalizeMember).filter((item) => item.id) : [];
20
+ const activeOperator = project.activeOperator && typeof project.activeOperator === "object"
21
+ ? {
22
+ id: String(project.activeOperator.id || "").trim(),
23
+ name: String(project.activeOperator.name || "").trim(),
24
+ claimedAt: String(project.activeOperator.claimedAt || "").trim()
25
+ }
26
+ : null;
27
+
28
+ return {
29
+ version: 1,
30
+ enabled: project.enabled !== false,
31
+ projectId: String(project.projectId || `proj_${crypto.randomBytes(4).toString("hex")}`).trim(),
32
+ projectName: String(project.projectName || path.basename(cwd || process.cwd()) || "project").trim(),
33
+ cwd: String(cwd || project.cwd || process.cwd()).trim(),
34
+ room: {
35
+ provider: String(project.room?.provider || "").trim(),
36
+ chatId: String(project.room?.chatId || "").trim(),
37
+ title: String(project.room?.title || "").trim()
38
+ },
39
+ mode: String(project.mode || "single-operator").trim() || "single-operator",
40
+ roomMode: ["chat", "plan", "execute"].includes(String(project.roomMode || "").trim())
41
+ ? String(project.roomMode).trim()
42
+ : "chat",
43
+ members,
44
+ activeOperator: activeOperator?.id ? activeOperator : null,
45
+ approvalPolicy: String(project.approvalPolicy || "owner").trim() || "owner",
46
+ createdAt: String(project.createdAt || new Date().toISOString()).trim(),
47
+ updatedAt: String(project.updatedAt || new Date().toISOString()).trim()
48
+ };
49
+ }
50
+
51
+ function sharedFilePath(cwd) {
52
+ return path.join(cwd, SHARED_FILE);
53
+ }
54
+
55
+ function roundtablePath(cwd) {
56
+ return path.join(cwd, ROUNDTABLE_FILE);
57
+ }
58
+
59
+ async function ensureProjectStateDir(cwd) {
60
+ await fs.mkdir(path.join(cwd, ".waterbrother"), { recursive: true });
61
+ }
62
+
63
+ async function pathExists(target) {
64
+ try {
65
+ await fs.access(target);
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ async function writeJsonAtomically(targetPath, value) {
73
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
74
+ await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
75
+ await fs.rename(tempPath, targetPath);
76
+ }
77
+
78
+ function defaultRoundtableContent(project) {
79
+ const memberLines = project.members.length
80
+ ? project.members.map((member) => `- ${member.name || member.id} (${member.role})`).join("\n")
81
+ : "- none yet";
82
+ const activeOperator = project.activeOperator?.id
83
+ ? `${project.activeOperator.name || project.activeOperator.id}`
84
+ : "none";
85
+ return [
86
+ "# Roundtable",
87
+ "",
88
+ "## Project",
89
+ `- Name: ${project.projectName}`,
90
+ `- Mode: ${project.mode}`,
91
+ `- Room mode: ${project.roomMode}`,
92
+ `- Approval policy: ${project.approvalPolicy}`,
93
+ project.room?.provider ? `- Room: ${project.room.provider} ${project.room.chatId || ""}`.trim() : "- Room: not linked",
94
+ "",
95
+ "## Members",
96
+ memberLines,
97
+ "",
98
+ "## Current Goal",
99
+ "-",
100
+ "",
101
+ "## Active Operator",
102
+ `- ${activeOperator}`,
103
+ "",
104
+ "## Decisions",
105
+ "-",
106
+ "",
107
+ "## Open Questions",
108
+ "-",
109
+ "",
110
+ "## Task Queue",
111
+ "- open: -",
112
+ "- active: -",
113
+ "- blocked: -",
114
+ "- done: -",
115
+ ""
116
+ ].join("\n");
117
+ }
118
+
119
+ export async function ensureRoundtable(cwd, project) {
120
+ const target = roundtablePath(cwd);
121
+ if (await pathExists(target)) return target;
122
+ await fs.writeFile(target, defaultRoundtableContent(project), "utf8");
123
+ return target;
124
+ }
125
+
126
+ export async function appendRoundtableEvent(cwd, line) {
127
+ const target = roundtablePath(cwd);
128
+ if (!(await pathExists(target))) return null;
129
+ await fs.appendFile(target, `${String(line || "").trim()}\n`, "utf8");
130
+ return target;
131
+ }
132
+
133
+ export async function loadSharedProject(cwd) {
134
+ try {
135
+ const raw = await fs.readFile(sharedFilePath(cwd), "utf8");
136
+ return normalizeSharedProject(JSON.parse(raw), cwd);
137
+ } catch (error) {
138
+ if (error?.code === "ENOENT") return null;
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ export async function saveSharedProject(cwd, project) {
144
+ await ensureProjectStateDir(cwd);
145
+ const next = normalizeSharedProject({
146
+ ...project,
147
+ updatedAt: new Date().toISOString()
148
+ }, cwd);
149
+ await writeJsonAtomically(sharedFilePath(cwd), next);
150
+ await ensureRoundtable(cwd, next);
151
+ return next;
152
+ }
153
+
154
+ export async function enableSharedProject(cwd, options = {}) {
155
+ const existing = await loadSharedProject(cwd);
156
+ const userName = String(options.userName || process.env.USER || os.userInfo().username || "local").trim();
157
+ const userId = String(options.userId || `local:${userName}`).trim();
158
+ const initialRole = String(options.role || "owner").trim() || "owner";
159
+ const member = normalizeMember({ id: userId, name: userName, role: initialRole, paired: true });
160
+ const members = existing?.members?.length ? [...existing.members] : [];
161
+ if (!members.some((item) => item.id === member.id)) members.unshift(member);
162
+ const project = await saveSharedProject(cwd, {
163
+ ...existing,
164
+ enabled: true,
165
+ projectName: existing?.projectName || path.basename(cwd),
166
+ members,
167
+ activeOperator: existing?.activeOperator || {
168
+ id: member.id,
169
+ name: member.name,
170
+ claimedAt: new Date().toISOString()
171
+ }
172
+ });
173
+ await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: sharing enabled by ${member.name || member.id}`);
174
+ return project;
175
+ }
176
+
177
+ export async function disableSharedProject(cwd) {
178
+ const existing = await loadSharedProject(cwd);
179
+ if (!existing) return null;
180
+ const next = await saveSharedProject(cwd, {
181
+ ...existing,
182
+ enabled: false,
183
+ activeOperator: null
184
+ });
185
+ await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: sharing disabled`);
186
+ return next;
187
+ }
188
+
189
+ export async function setSharedRoom(cwd, room = {}) {
190
+ const existing = await loadSharedProject(cwd);
191
+ if (!existing) throw new Error("Project is not shared. Run `waterbrother project share` first.");
192
+ const next = await saveSharedProject(cwd, {
193
+ ...existing,
194
+ room: {
195
+ provider: String(room.provider || existing.room?.provider || "").trim(),
196
+ chatId: String(room.chatId || existing.room?.chatId || "").trim(),
197
+ title: String(room.title || existing.room?.title || "").trim()
198
+ }
199
+ });
200
+ return next;
201
+ }
202
+
203
+ export async function setSharedRoomMode(cwd, roomMode = "chat") {
204
+ const existing = await loadSharedProject(cwd);
205
+ if (!existing?.enabled) throw new Error("Project is not shared.");
206
+ const normalized = String(roomMode || "").trim().toLowerCase();
207
+ if (!["chat", "plan", "execute"].includes(normalized)) {
208
+ throw new Error("Invalid room mode. Expected one of chat, plan, execute.");
209
+ }
210
+ const next = await saveSharedProject(cwd, {
211
+ ...existing,
212
+ roomMode: normalized
213
+ });
214
+ await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: room mode set to ${normalized}`);
215
+ return next;
216
+ }
217
+
218
+ export async function claimSharedOperator(cwd, operator = {}) {
219
+ const existing = await loadSharedProject(cwd);
220
+ if (!existing?.enabled) throw new Error("Project is not shared.");
221
+ const nextOperator = {
222
+ id: String(operator.id || "").trim(),
223
+ name: String(operator.name || "").trim(),
224
+ claimedAt: new Date().toISOString()
225
+ };
226
+ if (!nextOperator.id) throw new Error("operator id is required");
227
+ if (existing.activeOperator?.id && existing.activeOperator.id !== nextOperator.id) {
228
+ throw new Error(`Room is currently claimed by ${existing.activeOperator.name || existing.activeOperator.id}`);
229
+ }
230
+ const members = [...(existing.members || [])];
231
+ if (!members.some((item) => item.id === nextOperator.id)) {
232
+ members.push(normalizeMember({ id: nextOperator.id, name: nextOperator.name, role: "editor", paired: true }));
233
+ }
234
+ const next = await saveSharedProject(cwd, {
235
+ ...existing,
236
+ members,
237
+ activeOperator: nextOperator
238
+ });
239
+ await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: operator claimed by ${nextOperator.name || nextOperator.id}`);
240
+ return next;
241
+ }
242
+
243
+ export async function releaseSharedOperator(cwd, operatorId = "") {
244
+ const existing = await loadSharedProject(cwd);
245
+ if (!existing?.enabled) throw new Error("Project is not shared.");
246
+ const normalizedId = String(operatorId || "").trim();
247
+ if (existing.activeOperator?.id && normalizedId && existing.activeOperator.id !== normalizedId) {
248
+ throw new Error(`Room is currently claimed by ${existing.activeOperator.name || existing.activeOperator.id}`);
249
+ }
250
+ const next = await saveSharedProject(cwd, {
251
+ ...existing,
252
+ activeOperator: null
253
+ });
254
+ await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: operator released`);
255
+ return next;
256
+ }
257
+
258
+ export function formatSharedProjectStatus(project) {
259
+ if (!project) return "shared project: off";
260
+ return JSON.stringify({
261
+ enabled: project.enabled,
262
+ projectId: project.projectId,
263
+ projectName: project.projectName,
264
+ cwd: project.cwd,
265
+ room: project.room,
266
+ mode: project.mode,
267
+ roomMode: project.roomMode,
268
+ approvalPolicy: project.approvalPolicy,
269
+ activeOperator: project.activeOperator,
270
+ members: project.members
271
+ }, null, 2);
272
+ }
273
+
274
+ export function getSharedProjectPaths(cwd) {
275
+ return {
276
+ sharedJson: sharedFilePath(cwd),
277
+ roundtable: roundtablePath(cwd)
278
+ };
279
+ }