@tritard/waterbrother 0.16.27 → 0.16.29

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
@@ -266,6 +266,7 @@ Shared project foundation is now live:
266
266
  - inspect it with `waterbrother room status`
267
267
  - control conversation vs execution with `waterbrother room mode chat|plan|execute`
268
268
  - manage collaborators with `waterbrother room members`, `waterbrother room add`, and `waterbrother room remove`
269
+ - manage the shared backlog with `waterbrother room tasks`, `waterbrother room task add`, and `waterbrother room task move`
269
270
  - claim or release the shared operator lock with `waterbrother room claim` and `waterbrother room release`
270
271
  - shared project metadata lives in `.waterbrother/shared.json`
271
272
  - human collaboration notes live in `ROUNDTABLE.md`
@@ -278,10 +279,12 @@ Current Telegram behavior:
278
279
  - pending pairings are explicit and expire automatically after 12 hours unless approved
279
280
  - paired Telegram users drive the same live session and permissions as the terminal operator when the TUI bridge is attached
280
281
  - Telegram now supports remote workspace control with `/cwd`, `/use <path>`, `/desktop`, and `/new-project <name>`
281
- - shared projects now support `/room`, `/members`, `/mode`, `/claim`, `/release`, `/invite`, and `/remove-member` from Telegram
282
+ - shared projects now support `/room`, `/members`, `/tasks`, `/mode`, `/claim`, `/release`, `/invite`, `/remove-member`, and `/task ...` from Telegram
282
283
  - shared Telegram execution only runs when the shared room is in `execute` mode
283
284
  - room administration is owner-only, and only owners/editors can hold the operator lock
285
+ - `/room` status now shows the active executor surface plus provider/model/runtime identity
284
286
  - in Telegram groups, Waterbrother only responds when directly targeted: slash commands, `@botname` mentions, or replies to a bot message
287
+ - in Telegram groups, directly targeted messages are now classified as chat, planning, or execution; only explicit execution requests run the live session
285
288
  - pairing is now explicit: first DM creates a pending request, then approve locally with `waterbrother gateway pair telegram <user-id>`
286
289
 
287
290
  ## Release flow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.16.27",
3
+ "version": "0.16.29",
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
@@ -58,13 +58,16 @@ import { scanForInitiatives, formatInitiatives, buildInitiativeFixPrompt } from
58
58
  import { formatPlanForDisplay } from "./planner.js";
59
59
  import { parseCharterFromGoal, runExperimentLoop, formatExperimentSummary, gitReturnToBranch } from "./experiment.js";
60
60
  import {
61
+ addSharedTask,
61
62
  claimSharedOperator,
62
63
  disableSharedProject,
63
64
  enableSharedProject,
64
65
  formatSharedProjectStatus,
65
66
  getSharedProjectPaths,
66
67
  listSharedMembers,
68
+ listSharedTasks,
67
69
  loadSharedProject,
70
+ moveSharedTask,
68
71
  releaseSharedOperator,
69
72
  removeSharedMember,
70
73
  setSharedRoomMode,
@@ -154,6 +157,9 @@ const INTERACTIVE_COMMANDS = [
154
157
  { name: "/room members", description: "List shared-project members" },
155
158
  { name: "/room add <id> [owner|editor|observer]", description: "Add or update a shared-project member" },
156
159
  { name: "/room remove <id>", description: "Remove a shared-project member" },
160
+ { name: "/room tasks", description: "List Roundtable tasks for the shared project" },
161
+ { name: "/room task add <text>", description: "Add a Roundtable task" },
162
+ { name: "/room task move <id> <open|active|blocked|done>", description: "Move a Roundtable task between states" },
157
163
  { name: "/room mode <chat|plan|execute>", description: "Set collaboration mode for the shared room" },
158
164
  { name: "/room claim", description: "Claim operator control for the shared room" },
159
165
  { name: "/room release", description: "Release operator control for the shared room" },
@@ -281,6 +287,9 @@ Usage:
281
287
  waterbrother room members
282
288
  waterbrother room add <member-id> [owner|editor|observer]
283
289
  waterbrother room remove <member-id>
290
+ waterbrother room tasks
291
+ waterbrother room task add <text>
292
+ waterbrother room task move <id> <open|active|blocked|done>
284
293
  waterbrother room mode <chat|plan|execute>
285
294
  waterbrother room claim
286
295
  waterbrother room release
@@ -2603,6 +2612,23 @@ function buildRuntimeStatusPayload(runtime, agent, currentSession = null) {
2603
2612
  };
2604
2613
  }
2605
2614
 
2615
+ function buildRoomStatusPayload(project, runtime = null, currentSession = null) {
2616
+ if (!project) {
2617
+ return { enabled: false };
2618
+ }
2619
+ return {
2620
+ ...project,
2621
+ executor: {
2622
+ surface: "local-tui",
2623
+ runtimeProfile: currentSession?.runtimeProfile || runtime?.gateway?.defaultRuntimeProfile || "",
2624
+ provider: runtime?.provider || "unknown",
2625
+ model: runtime?.model || "unknown",
2626
+ designModel: runtime?.designModel || "unknown",
2627
+ agentProfile: runtime?.agentProfile || "coder"
2628
+ }
2629
+ };
2630
+ }
2631
+
2606
2632
  async function applyRuntimeSelection({
2607
2633
  config,
2608
2634
  context,
@@ -3765,16 +3791,17 @@ async function runProjectCommand(positional, { cwd = process.cwd(), asJson = fal
3765
3791
  throw new Error("Usage: waterbrother project share|unshare");
3766
3792
  }
3767
3793
 
3768
- async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false } = {}) {
3794
+ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false, runtime = null, currentSession = null } = {}) {
3769
3795
  const sub = String(positional[1] || "status").trim().toLowerCase();
3770
3796
  const operator = getLocalOperatorIdentity();
3771
3797
  if (sub === "status") {
3772
3798
  const project = await loadSharedProject(cwd);
3799
+ const payload = buildRoomStatusPayload(project, runtime, currentSession);
3773
3800
  if (asJson) {
3774
- printData(project || { enabled: false, cwd }, true);
3801
+ printData(payload, true);
3775
3802
  return;
3776
3803
  }
3777
- console.log(formatSharedProjectStatus(project));
3804
+ console.log(JSON.stringify(payload, null, 2));
3778
3805
  return;
3779
3806
  }
3780
3807
 
@@ -3794,6 +3821,54 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false
3794
3821
  return;
3795
3822
  }
3796
3823
 
3824
+ if (sub === "tasks") {
3825
+ const tasks = await listSharedTasks(cwd);
3826
+ if (asJson) {
3827
+ printData({ ok: true, tasks }, true);
3828
+ return;
3829
+ }
3830
+ if (!tasks.length) {
3831
+ console.log("No shared-project tasks");
3832
+ return;
3833
+ }
3834
+ for (const task of tasks) {
3835
+ console.log(`${task.id}\t${task.state}\t${task.text}`);
3836
+ }
3837
+ return;
3838
+ }
3839
+
3840
+ if (sub === "task") {
3841
+ const action = String(positional[2] || "").trim().toLowerCase();
3842
+ if (action === "add") {
3843
+ const text = String(positional.slice(3).join(" ") || "").trim();
3844
+ if (!text) {
3845
+ throw new Error("Usage: waterbrother room task add <text>");
3846
+ }
3847
+ const result = await addSharedTask(cwd, text, { actorId: operator.id });
3848
+ if (asJson) {
3849
+ printData({ ok: true, action: "task-add", task: result.task, project: result.project }, true);
3850
+ return;
3851
+ }
3852
+ console.log(`Added shared task [${result.task.id}] ${result.task.text}`);
3853
+ return;
3854
+ }
3855
+ if (action === "move") {
3856
+ const taskId = String(positional[3] || "").trim();
3857
+ const state = String(positional[4] || "").trim().toLowerCase();
3858
+ if (!taskId || !state) {
3859
+ throw new Error("Usage: waterbrother room task move <id> <open|active|blocked|done>");
3860
+ }
3861
+ const result = await moveSharedTask(cwd, taskId, state, { actorId: operator.id });
3862
+ if (asJson) {
3863
+ printData({ ok: true, action: "task-move", task: result.task, project: result.project }, true);
3864
+ return;
3865
+ }
3866
+ console.log(`Moved shared task [${result.task.id}] to ${result.task.state}`);
3867
+ return;
3868
+ }
3869
+ throw new Error("Usage: waterbrother room task add <text>|move <id> <open|active|blocked|done>");
3870
+ }
3871
+
3797
3872
  if (sub === "add") {
3798
3873
  const memberId = String(positional[2] || "").trim();
3799
3874
  const role = String(positional[3] || "editor").trim().toLowerCase();
@@ -3868,7 +3943,7 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false
3868
3943
  return;
3869
3944
  }
3870
3945
 
3871
- throw new Error("Usage: waterbrother room status|members|add <member-id> [owner|editor|observer] [display name]|remove <member-id>|mode <chat|plan|execute>|claim|release");
3946
+ throw new Error("Usage: waterbrother room status|members|add <member-id> [owner|editor|observer] [display name]|remove <member-id>|tasks|task add <text>|task move <id> <open|active|blocked|done>|mode <chat|plan|execute>|claim|release");
3872
3947
  }
3873
3948
 
3874
3949
  async function runMcpCommand(positional, runtime, { cwd, asJson = false } = {}) {
@@ -7689,7 +7764,7 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7689
7764
 
7690
7765
  if (line === "/room") {
7691
7766
  try {
7692
- await runRoomCommand(["room", "status"], { cwd: context.cwd, asJson: false });
7767
+ await runRoomCommand(["room", "status"], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession });
7693
7768
  } catch (error) {
7694
7769
  console.log(`room status failed: ${error instanceof Error ? error.message : String(error)}`);
7695
7770
  }
@@ -7698,13 +7773,22 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7698
7773
 
7699
7774
  if (line === "/room members") {
7700
7775
  try {
7701
- await runRoomCommand(["room", "members"], { cwd: context.cwd, asJson: false });
7776
+ await runRoomCommand(["room", "members"], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession });
7702
7777
  } catch (error) {
7703
7778
  console.log(`room members failed: ${error instanceof Error ? error.message : String(error)}`);
7704
7779
  }
7705
7780
  continue;
7706
7781
  }
7707
7782
 
7783
+ if (line === "/room tasks") {
7784
+ try {
7785
+ await runRoomCommand(["room", "tasks"], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession });
7786
+ } catch (error) {
7787
+ console.log(`room tasks failed: ${error instanceof Error ? error.message : String(error)}`);
7788
+ }
7789
+ continue;
7790
+ }
7791
+
7708
7792
  if (line.startsWith("/room add ")) {
7709
7793
  const raw = line.replace("/room add", "").trim();
7710
7794
  if (!raw) {
@@ -7721,7 +7805,7 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7721
7805
  try {
7722
7806
  const positional = ["room", "add", memberId, role];
7723
7807
  if (displayName) positional.push(...displayName.split(" "));
7724
- await runRoomCommand(positional, { cwd: context.cwd, asJson: false });
7808
+ await runRoomCommand(positional, { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession });
7725
7809
  } catch (error) {
7726
7810
  console.log(`room add failed: ${error instanceof Error ? error.message : String(error)}`);
7727
7811
  }
@@ -7735,13 +7819,42 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7735
7819
  continue;
7736
7820
  }
7737
7821
  try {
7738
- await runRoomCommand(["room", "remove", memberId], { cwd: context.cwd, asJson: false });
7822
+ await runRoomCommand(["room", "remove", memberId], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession });
7739
7823
  } catch (error) {
7740
7824
  console.log(`room remove failed: ${error instanceof Error ? error.message : String(error)}`);
7741
7825
  }
7742
7826
  continue;
7743
7827
  }
7744
7828
 
7829
+ if (line.startsWith("/room task add ")) {
7830
+ const text = line.replace("/room task add", "").trim();
7831
+ if (!text) {
7832
+ console.log("Usage: /room task add <text>");
7833
+ continue;
7834
+ }
7835
+ try {
7836
+ await runRoomCommand(["room", "task", "add", ...text.split(" ")], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession });
7837
+ } catch (error) {
7838
+ console.log(`room task add failed: ${error instanceof Error ? error.message : String(error)}`);
7839
+ }
7840
+ continue;
7841
+ }
7842
+
7843
+ if (line.startsWith("/room task move ")) {
7844
+ const raw = line.replace("/room task move", "").trim();
7845
+ const [taskId, state] = raw.split(/\s+/, 2);
7846
+ if (!taskId || !state) {
7847
+ console.log("Usage: /room task move <id> <open|active|blocked|done>");
7848
+ continue;
7849
+ }
7850
+ try {
7851
+ await runRoomCommand(["room", "task", "move", taskId, state], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession });
7852
+ } catch (error) {
7853
+ console.log(`room task move failed: ${error instanceof Error ? error.message : String(error)}`);
7854
+ }
7855
+ continue;
7856
+ }
7857
+
7745
7858
  if (line.startsWith("/room mode ")) {
7746
7859
  const nextMode = line.replace("/room mode", "").trim().toLowerCase();
7747
7860
  if (!nextMode) {
@@ -7749,7 +7862,7 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7749
7862
  continue;
7750
7863
  }
7751
7864
  try {
7752
- await runRoomCommand(["room", "mode", nextMode], { cwd: context.cwd, asJson: false });
7865
+ await runRoomCommand(["room", "mode", nextMode], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession });
7753
7866
  } catch (error) {
7754
7867
  console.log(`room mode failed: ${error instanceof Error ? error.message : String(error)}`);
7755
7868
  }
@@ -7758,7 +7871,7 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7758
7871
 
7759
7872
  if (line === "/room claim") {
7760
7873
  try {
7761
- await runRoomCommand(["room", "claim"], { cwd: context.cwd, asJson: false });
7874
+ await runRoomCommand(["room", "claim"], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession });
7762
7875
  } catch (error) {
7763
7876
  console.log(`room claim failed: ${error instanceof Error ? error.message : String(error)}`);
7764
7877
  }
@@ -7767,7 +7880,7 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7767
7880
 
7768
7881
  if (line === "/room release") {
7769
7882
  try {
7770
- await runRoomCommand(["room", "release"], { cwd: context.cwd, asJson: false });
7883
+ await runRoomCommand(["room", "release"], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession });
7771
7884
  } catch (error) {
7772
7885
  console.log(`room release failed: ${error instanceof Error ? error.message : String(error)}`);
7773
7886
  }
@@ -9596,7 +9709,7 @@ export async function runCli(argv) {
9596
9709
  }
9597
9710
 
9598
9711
  if (command === "room") {
9599
- await runRoomCommand(positional, { cwd: startupCwd, asJson });
9712
+ await runRoomCommand(positional, { cwd: startupCwd, asJson, runtime });
9600
9713
  return;
9601
9714
  }
9602
9715
 
package/src/gateway.js CHANGED
@@ -8,7 +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, getSharedMember, loadSharedProject, releaseSharedOperator, setSharedRoom, setSharedRoomMode, upsertSharedMember, removeSharedMember } from "./shared-project.js";
11
+ import { addSharedTask, claimSharedOperator, getSharedMember, listSharedTasks, loadSharedProject, moveSharedTask, releaseSharedOperator, setSharedRoom, setSharedRoomMode, upsertSharedMember, removeSharedMember } from "./shared-project.js";
12
12
 
13
13
  const execFileAsync = promisify(execFile);
14
14
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -27,6 +27,7 @@ const TELEGRAM_COMMANDS = [
27
27
  { command: "runtime", description: "Show active runtime status" },
28
28
  { command: "room", description: "Show shared room status" },
29
29
  { command: "members", description: "List shared room members" },
30
+ { command: "tasks", description: "List shared Roundtable tasks" },
30
31
  { command: "mode", description: "Show or set shared room mode" },
31
32
  { command: "claim", description: "Claim operator control for a shared project" },
32
33
  { command: "release", description: "Release operator control for a shared project" },
@@ -201,6 +202,9 @@ function buildRemoteHelp() {
201
202
  "<code>/runtime</code> show active provider/model/runtime state",
202
203
  "<code>/room</code> show shared project room status",
203
204
  "<code>/members</code> list shared project members",
205
+ "<code>/tasks</code> list shared project tasks",
206
+ "<code>/task add &lt;text&gt;</code> add a shared Roundtable task",
207
+ "<code>/task move &lt;id&gt; &lt;open|active|blocked|done&gt;</code> move a shared Roundtable task",
204
208
  "<code>/invite &lt;user-id&gt; [owner|editor|observer]</code> add or update a shared project member",
205
209
  "<code>/remove-member &lt;user-id&gt;</code> remove a shared project member",
206
210
  "<code>/mode</code> or <code>/mode &lt;chat|plan|execute&gt;</code> inspect or change shared room mode",
@@ -270,7 +274,7 @@ function formatSessionListMarkup(currentSessionId, sessions = []) {
270
274
  return lines.join("\n");
271
275
  }
272
276
 
273
- function formatTelegramRoomMarkup(project) {
277
+ function formatTelegramRoomMarkup(project, options = {}) {
274
278
  if (!project?.enabled) {
275
279
  return "<b>Shared room</b>\nThis project is not shared.";
276
280
  }
@@ -284,6 +288,18 @@ function formatTelegramRoomMarkup(project) {
284
288
  const memberLines = members.length
285
289
  ? members.map((member) => `• ${escapeTelegramHtml(member.name || member.id)} <i>(${escapeTelegramHtml(member.role || "editor")})</i>`).join("\n")
286
290
  : "• none";
291
+ const executor = options.executor || {};
292
+ const executorBits = [
293
+ `surface: <code>${escapeTelegramHtml(executor.surface || "telegram")}</code>`,
294
+ `provider: <code>${escapeTelegramHtml(executor.provider || "unknown")}</code>`,
295
+ `model: <code>${escapeTelegramHtml(executor.model || "unknown")}</code>`
296
+ ];
297
+ if (executor.runtimeProfile) {
298
+ executorBits.push(`runtime profile: <code>${escapeTelegramHtml(executor.runtimeProfile)}</code>`);
299
+ }
300
+ if (executor.hostSessionId) {
301
+ executorBits.push(`host session: <code>${escapeTelegramHtml(executor.hostSessionId)}</code>`);
302
+ }
287
303
  return [
288
304
  "<b>Shared room</b>",
289
305
  `project: <code>${escapeTelegramHtml(project.projectName || "")}</code>`,
@@ -291,6 +307,8 @@ function formatTelegramRoomMarkup(project) {
291
307
  `room mode: <code>${escapeTelegramHtml(project.roomMode || "chat")}</code>`,
292
308
  `room: <code>${escapeTelegramHtml(roomLabel)}</code>`,
293
309
  `active operator: <code>${escapeTelegramHtml(active)}</code>`,
310
+ "<b>Executor</b>",
311
+ ...executorBits,
294
312
  "<b>Members</b>",
295
313
  memberLines
296
314
  ].join("\n");
@@ -310,6 +328,45 @@ function formatTelegramMembersMarkup(project) {
310
328
  ].join("\n");
311
329
  }
312
330
 
331
+ function formatTelegramTasksMarkup(tasks = []) {
332
+ if (!tasks.length) {
333
+ return "<b>Shared tasks</b>\n• none";
334
+ }
335
+ return [
336
+ "<b>Shared tasks</b>",
337
+ ...tasks.map((task) => `• <code>${escapeTelegramHtml(task.id)}</code> <i>(${escapeTelegramHtml(task.state)})</i> ${escapeTelegramHtml(task.text)}`)
338
+ ].join("\n");
339
+ }
340
+
341
+ function classifyTelegramGroupIntent(text = "") {
342
+ const normalized = String(text || "").trim();
343
+ const lower = normalized.toLowerCase();
344
+ if (!normalized) return { kind: "chat", reason: "empty" };
345
+ if (/^(hi|hello|hey|yo|thanks|thank you|cool|nice|ok|okay|k)\b/.test(lower)) {
346
+ return { kind: "chat", reason: "casual language" };
347
+ }
348
+ if (
349
+ lower.includes("brainstorm")
350
+ || lower.includes("what do you think")
351
+ || lower.includes("should we")
352
+ || lower.includes("could we")
353
+ || lower.includes("let's think")
354
+ || lower.includes("plan this")
355
+ || lower.includes("ideas for")
356
+ || normalized.endsWith("?")
357
+ ) {
358
+ return { kind: "plan", reason: "planning language" };
359
+ }
360
+ if (
361
+ /^(make|create|build|implement|fix|change|update|add|remove|delete|rename|refactor|write|edit|move|run|search|open|use|switch|generate|ship|deploy|debug|investigate|audit)\b/.test(lower)
362
+ || /[`/].+/.test(normalized)
363
+ || /\b(file|folder|directory|repo|project|code|command|function|component|page|route|api|schema|bug)\b/.test(lower)
364
+ ) {
365
+ return { kind: "execution", reason: "explicit work request" };
366
+ }
367
+ return { kind: "chat", reason: "default non-execution" };
368
+ }
369
+
313
370
  function parseInviteCommand(text) {
314
371
  const parts = String(text || "").trim().split(/\s+/).filter(Boolean);
315
372
  const userId = String(parts[1] || "").trim();
@@ -682,6 +739,19 @@ class TelegramGateway {
682
739
  return { session, project: next };
683
740
  }
684
741
 
742
+ async buildRoomMarkup(message, sessionId) {
743
+ const { project } = await this.bindSharedRoomForMessage(message, sessionId);
744
+ const host = await this.getLiveBridgeHost();
745
+ const executor = {
746
+ surface: host ? "live-tui" : "telegram-fallback",
747
+ provider: this.runtime.provider,
748
+ model: this.runtime.model,
749
+ runtimeProfile: this.channel.defaultRuntimeProfile || this.gateway.defaultRuntimeProfile || "",
750
+ hostSessionId: host?.sessionId || ""
751
+ };
752
+ return project?.enabled ? formatTelegramRoomMarkup(project, { executor }) : "<b>Shared room</b>\nThis project is not shared.";
753
+ }
754
+
685
755
  async ensureSharedOperator(message, sessionId) {
686
756
  const { session, project } = await this.loadSharedProjectForSession(sessionId);
687
757
  if (!project?.enabled) return { ok: true, project: null, session };
@@ -939,8 +1009,7 @@ class TelegramGateway {
939
1009
  }
940
1010
 
941
1011
  if (text === "/room") {
942
- const { project } = await this.bindSharedRoomForMessage(message, sessionId);
943
- await this.sendMessage(message.chat.id, project?.enabled ? formatTelegramRoomMarkup(project) : "<b>Shared room</b>\nThis project is not shared.", message.message_id);
1012
+ await this.sendMessage(message.chat.id, await this.buildRoomMarkup(message, sessionId), message.message_id);
944
1013
  return;
945
1014
  }
946
1015
 
@@ -950,6 +1019,17 @@ class TelegramGateway {
950
1019
  return;
951
1020
  }
952
1021
 
1022
+ if (text === "/tasks") {
1023
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1024
+ if (!project?.enabled) {
1025
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
1026
+ return;
1027
+ }
1028
+ const tasks = await listSharedTasks(session.cwd || this.cwd);
1029
+ await this.sendMessage(message.chat.id, formatTelegramTasksMarkup(tasks), message.message_id);
1030
+ return;
1031
+ }
1032
+
953
1033
  if (text === "/mode") {
954
1034
  const { project } = await this.bindSharedRoomForMessage(message, sessionId);
955
1035
  await this.sendMessage(
@@ -1004,7 +1084,7 @@ class TelegramGateway {
1004
1084
  }
1005
1085
  try {
1006
1086
  const released = await releaseSharedOperator(session.cwd || this.cwd, userId, { actorId: userId });
1007
- await this.sendMessage(message.chat.id, released.activeOperator?.id ? formatTelegramRoomMarkup(released) : "Shared room released.", message.message_id);
1087
+ await this.sendMessage(message.chat.id, released.activeOperator?.id ? await this.buildRoomMarkup(message, sessionId) : "Shared room released.", message.message_id);
1008
1088
  } catch (error) {
1009
1089
  await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
1010
1090
  }
@@ -1064,6 +1144,46 @@ class TelegramGateway {
1064
1144
  return;
1065
1145
  }
1066
1146
 
1147
+ if (text.startsWith("/task add ")) {
1148
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1149
+ if (!project?.enabled) {
1150
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
1151
+ return;
1152
+ }
1153
+ const taskText = text.replace("/task add", "").trim();
1154
+ if (!taskText) {
1155
+ await this.sendMessage(message.chat.id, "Usage: /task add <text>", message.message_id);
1156
+ return;
1157
+ }
1158
+ try {
1159
+ const result = await addSharedTask(session.cwd || this.cwd, taskText, { actorId: userId });
1160
+ await this.sendMessage(message.chat.id, `Added shared task <code>${escapeTelegramHtml(result.task.id)}</code>`, message.message_id);
1161
+ } catch (error) {
1162
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
1163
+ }
1164
+ return;
1165
+ }
1166
+
1167
+ if (text.startsWith("/task move ")) {
1168
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1169
+ if (!project?.enabled) {
1170
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
1171
+ return;
1172
+ }
1173
+ const [taskId, state] = text.replace("/task move", "").trim().split(/\s+/, 2);
1174
+ if (!taskId || !state) {
1175
+ await this.sendMessage(message.chat.id, "Usage: /task move <id> <open|active|blocked|done>", message.message_id);
1176
+ return;
1177
+ }
1178
+ try {
1179
+ const result = await moveSharedTask(session.cwd || this.cwd, taskId, state, { actorId: userId });
1180
+ await this.sendMessage(message.chat.id, `Moved shared task <code>${escapeTelegramHtml(result.task.id)}</code> to <code>${escapeTelegramHtml(result.task.state)}</code>`, message.message_id);
1181
+ } catch (error) {
1182
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
1183
+ }
1184
+ return;
1185
+ }
1186
+
1067
1187
  if (text === "/runtime") {
1068
1188
  const status = await this.runRuntimeStatus();
1069
1189
  await this.sendMessage(message.chat.id, formatRuntimeStatus(status), message.message_id);
@@ -1171,16 +1291,29 @@ class TelegramGateway {
1171
1291
  return;
1172
1292
  }
1173
1293
 
1294
+ const promptText = this.stripBotMention(text);
1295
+ const groupIntent = this.isGroupChat(message) ? classifyTelegramGroupIntent(promptText) : { kind: "execution", reason: "private chat" };
1296
+ if (this.isGroupChat(message) && groupIntent.kind !== "execution") {
1297
+ const planHint = groupIntent.kind === "plan"
1298
+ ? "Planning/brainstorming message detected. No execution will run in the group. Use /task add to capture work, or switch to /mode execute and send an explicit execution request."
1299
+ : "Chat message detected. No execution will run in the group. Use /task add to capture work or send an explicit execution request when you want the bot to act.";
1300
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(planHint), message.message_id, { parseMode: "HTML" });
1301
+ return;
1302
+ }
1303
+
1174
1304
  const stopTyping = await this.startTypingLoop(message.chat.id);
1175
1305
  let previewMessage = null;
1176
1306
  try {
1177
1307
  const operatorGate = await this.ensureSharedOperator(message, sessionId);
1178
1308
  if (!operatorGate.ok) {
1179
- await this.sendMessage(message.chat.id, escapeTelegramHtml(operatorGate.reason || "Shared room is not available."), message.message_id, { parseMode: "HTML" });
1309
+ const gateReason =
1310
+ this.isGroupChat(message) && groupIntent.kind === "execution" && operatorGate.project?.enabled && operatorGate.project?.roomMode !== "execute"
1311
+ ? `Execution request detected, but the shared room is in ${operatorGate.project?.roomMode || "chat"} mode. Switch to /mode execute before asking Waterbrother to change code.`
1312
+ : operatorGate.reason || "Shared room is not available.";
1313
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(gateReason), message.message_id, { parseMode: "HTML" });
1180
1314
  return;
1181
1315
  }
1182
1316
  previewMessage = await this.sendProgressMessage(message.chat.id, message.message_id);
1183
- const promptText = this.stripBotMention(text);
1184
1317
  const content = (await this.runPromptViaBridge(message, sessionId, promptText))
1185
1318
  ?? (await this.runPromptFallback(sessionId, promptText));
1186
1319
  await this.deliverPromptResult(message.chat.id, message.message_id, previewMessage, content);
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
 
6
6
  const SHARED_FILE = path.join(".waterbrother", "shared.json");
7
7
  const ROUNDTABLE_FILE = "ROUNDTABLE.md";
8
+ const TASK_STATES = ["open", "active", "blocked", "done"];
8
9
 
9
10
  function normalizeMember(member = {}) {
10
11
  return {
@@ -25,6 +26,11 @@ function memberRoleWeight(role = "") {
25
26
 
26
27
  function normalizeSharedProject(project = {}, cwd = process.cwd()) {
27
28
  const members = Array.isArray(project.members) ? project.members.map(normalizeMember).filter((item) => item.id) : [];
29
+ const tasks = Array.isArray(project.tasks)
30
+ ? project.tasks
31
+ .map((task) => normalizeSharedTask(task))
32
+ .filter((task) => task.id && task.text)
33
+ : [];
28
34
  const activeOperator = project.activeOperator && typeof project.activeOperator === "object"
29
35
  ? {
30
36
  id: String(project.activeOperator.id || "").trim(),
@@ -49,6 +55,7 @@ function normalizeSharedProject(project = {}, cwd = process.cwd()) {
49
55
  ? String(project.roomMode).trim()
50
56
  : "chat",
51
57
  members,
58
+ tasks,
52
59
  activeOperator: activeOperator?.id ? activeOperator : null,
53
60
  approvalPolicy: String(project.approvalPolicy || "owner").trim() || "owner",
54
61
  createdAt: String(project.createdAt || new Date().toISOString()).trim(),
@@ -56,6 +63,18 @@ function normalizeSharedProject(project = {}, cwd = process.cwd()) {
56
63
  };
57
64
  }
58
65
 
66
+ function normalizeSharedTask(task = {}) {
67
+ return {
68
+ id: String(task.id || `rt_${crypto.randomBytes(3).toString("hex")}`).trim(),
69
+ text: String(task.text || "").trim(),
70
+ state: TASK_STATES.includes(String(task.state || "").trim()) ? String(task.state).trim() : "open",
71
+ createdAt: String(task.createdAt || new Date().toISOString()).trim(),
72
+ updatedAt: String(task.updatedAt || new Date().toISOString()).trim(),
73
+ createdBy: String(task.createdBy || "").trim(),
74
+ assignedTo: String(task.assignedTo || "").trim()
75
+ };
76
+ }
77
+
59
78
  function sharedFilePath(cwd) {
60
79
  return path.join(cwd, SHARED_FILE);
61
80
  }
@@ -90,6 +109,11 @@ function defaultRoundtableContent(project) {
90
109
  const activeOperator = project.activeOperator?.id
91
110
  ? `${project.activeOperator.name || project.activeOperator.id}`
92
111
  : "none";
112
+ const taskLines = TASK_STATES.flatMap((state) => {
113
+ const tasks = (project.tasks || []).filter((task) => task.state === state);
114
+ if (!tasks.length) return [`- ${state}: -`];
115
+ return [`- ${state}:`, ...tasks.map((task) => ` - [${task.id}] ${task.text}`)];
116
+ });
93
117
  return [
94
118
  "# Roundtable",
95
119
  "",
@@ -116,14 +140,35 @@ function defaultRoundtableContent(project) {
116
140
  "-",
117
141
  "",
118
142
  "## Task Queue",
119
- "- open: -",
120
- "- active: -",
121
- "- blocked: -",
122
- "- done: -",
143
+ ...taskLines,
123
144
  ""
124
145
  ].join("\n");
125
146
  }
126
147
 
148
+ function buildRoundtableTaskSection(project) {
149
+ const taskLines = TASK_STATES.flatMap((state) => {
150
+ const tasks = (project.tasks || []).filter((task) => task.state === state);
151
+ if (!tasks.length) return [`- ${state}: -`];
152
+ return [`- ${state}:`, ...tasks.map((task) => ` - [${task.id}] ${task.text}`)];
153
+ });
154
+ return ["## Task Queue", ...taskLines, ""].join("\n");
155
+ }
156
+
157
+ async function syncRoundtableTaskSection(cwd, project) {
158
+ const target = roundtablePath(cwd);
159
+ if (!(await pathExists(target))) return null;
160
+ const current = await fs.readFile(target, "utf8");
161
+ const nextSection = buildRoundtableTaskSection(project);
162
+ let nextContent;
163
+ if (/^## Task Queue\s*$/m.test(current)) {
164
+ nextContent = current.replace(/## Task Queue[\s\S]*$/, nextSection);
165
+ } else {
166
+ nextContent = `${current.trimEnd()}\n\n${nextSection}`;
167
+ }
168
+ await fs.writeFile(target, nextContent.endsWith("\n") ? nextContent : `${nextContent}\n`, "utf8");
169
+ return target;
170
+ }
171
+
127
172
  export async function ensureRoundtable(cwd, project) {
128
173
  const target = roundtablePath(cwd);
129
174
  if (await pathExists(target)) return target;
@@ -156,6 +201,7 @@ export async function saveSharedProject(cwd, project) {
156
201
  }, cwd);
157
202
  await writeJsonAtomically(sharedFilePath(cwd), next);
158
203
  await ensureRoundtable(cwd, next);
204
+ await syncRoundtableTaskSection(cwd, next);
159
205
  return next;
160
206
  }
161
207
 
@@ -247,6 +293,20 @@ function requireOwner(project, actorId = "") {
247
293
  }
248
294
  }
249
295
 
296
+ function requireEditor(project, actorId = "") {
297
+ requireSharedProject(project);
298
+ if (!memberHasAtLeastRole(project, actorId, "editor")) {
299
+ throw new Error("Only a shared-project owner or editor can do that.");
300
+ }
301
+ }
302
+
303
+ function requireMember(project, actorId = "") {
304
+ requireSharedProject(project);
305
+ if (!getSharedMember(project, actorId)) {
306
+ throw new Error("Only a shared-project member can do that.");
307
+ }
308
+ }
309
+
250
310
  export async function listSharedMembers(cwd) {
251
311
  const project = await loadSharedProject(cwd);
252
312
  requireSharedProject(project);
@@ -299,6 +359,56 @@ export async function removeSharedMember(cwd, memberId = "", options = {}) {
299
359
  return next;
300
360
  }
301
361
 
362
+ export async function listSharedTasks(cwd) {
363
+ const project = await loadSharedProject(cwd);
364
+ requireSharedProject(project);
365
+ return project.tasks || [];
366
+ }
367
+
368
+ export async function addSharedTask(cwd, text = "", options = {}) {
369
+ const existing = await loadSharedProject(cwd);
370
+ requireMember(existing, options.actorId);
371
+ const normalizedText = String(text || "").trim();
372
+ if (!normalizedText) throw new Error("task text is required");
373
+ const task = normalizeSharedTask({
374
+ text: normalizedText,
375
+ state: "open",
376
+ createdBy: String(options.actorId || "").trim()
377
+ });
378
+ const next = await saveSharedProject(cwd, {
379
+ ...existing,
380
+ tasks: [...(existing.tasks || []), task]
381
+ });
382
+ await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: task added [${task.id}] ${task.text}`);
383
+ return { project: next, task };
384
+ }
385
+
386
+ export async function moveSharedTask(cwd, taskId = "", state = "open", options = {}) {
387
+ const existing = await loadSharedProject(cwd);
388
+ requireEditor(existing, options.actorId);
389
+ const normalizedId = String(taskId || "").trim();
390
+ const normalizedState = String(state || "").trim().toLowerCase();
391
+ if (!normalizedId) throw new Error("task id is required");
392
+ if (!TASK_STATES.includes(normalizedState)) {
393
+ throw new Error(`Invalid task state. Expected one of ${TASK_STATES.join(", ")}.`);
394
+ }
395
+ const tasks = [...(existing.tasks || [])];
396
+ const index = tasks.findIndex((task) => task.id === normalizedId);
397
+ if (index < 0) throw new Error(`No shared task found for ${normalizedId}`);
398
+ tasks[index] = {
399
+ ...tasks[index],
400
+ state: normalizedState,
401
+ updatedAt: new Date().toISOString(),
402
+ assignedTo: normalizedState === "active" ? String(options.actorId || "").trim() : tasks[index].assignedTo
403
+ };
404
+ const next = await saveSharedProject(cwd, {
405
+ ...existing,
406
+ tasks
407
+ });
408
+ await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: task moved [${tasks[index].id}] -> ${normalizedState}`);
409
+ return { project: next, task: tasks[index] };
410
+ }
411
+
302
412
  export async function claimSharedOperator(cwd, operator = {}) {
303
413
  const existing = await loadSharedProject(cwd);
304
414
  requireSharedProject(existing);
@@ -361,7 +471,8 @@ export function formatSharedProjectStatus(project) {
361
471
  roomMode: project.roomMode,
362
472
  approvalPolicy: project.approvalPolicy,
363
473
  activeOperator: project.activeOperator,
364
- members: project.members
474
+ members: project.members,
475
+ tasks: project.tasks
365
476
  }, null, 2);
366
477
  }
367
478