@tritard/waterbrother 0.16.30 → 0.16.32

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
@@ -265,9 +265,9 @@ Shared project foundation is now live:
265
265
  - enable it with `waterbrother project share`
266
266
  - inspect it with `waterbrother room status`
267
267
  - control conversation vs execution with `waterbrother room mode chat|plan|execute`
268
- - manage collaborators with `waterbrother room members`, `waterbrother room add`, and `waterbrother room remove`
268
+ - manage collaborators with `waterbrother room members`, `waterbrother room invites`, `waterbrother room invite`, `waterbrother room invite accept`, and `waterbrother room remove`
269
269
  - manage the shared backlog with `waterbrother room tasks`, `waterbrother room task add`, and `waterbrother room task move`
270
- - assign and claim shared work with `waterbrother room task assign` and `waterbrother room task claim`
270
+ - assign, claim, and discuss shared work with `waterbrother room task assign`, `waterbrother room task claim`, `waterbrother room task comment`, and `waterbrother room task history`
271
271
  - choose a shared execution preset with `waterbrother room runtime <profile>`
272
272
  - claim or release the shared operator lock with `waterbrother room claim` and `waterbrother room release`
273
273
  - shared project metadata lives in `.waterbrother/shared.json`
@@ -281,7 +281,8 @@ Current Telegram behavior:
281
281
  - pending pairings are explicit and expire automatically after 12 hours unless approved
282
282
  - paired Telegram users drive the same live session and permissions as the terminal operator when the TUI bridge is attached
283
283
  - Telegram now supports remote workspace control with `/cwd`, `/use <path>`, `/desktop`, and `/new-project <name>`
284
- - shared projects now support `/room`, `/members`, `/tasks`, `/mode`, `/claim`, `/release`, `/invite`, `/remove-member`, `/room-runtime`, and `/task ...` from Telegram
284
+ - shared projects now support `/room`, `/members`, `/invites`, `/tasks`, `/mode`, `/claim`, `/release`, `/invite`, `/accept-invite`, `/approve-invite`, `/reject-invite`, `/remove-member`, `/room-runtime`, and `/task ...` from Telegram
285
+ - `/room` now includes pending invite count plus task ownership summaries
285
286
  - shared Telegram execution only runs when the shared room is in `execute` mode
286
287
  - room administration is owner-only, and only owners/editors can hold the operator lock
287
288
  - `/room` status now shows the active executor surface plus provider/model/runtime identity
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.16.30",
3
+ "version": "0.16.32",
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
@@ -59,17 +59,24 @@ import { formatPlanForDisplay } from "./planner.js";
59
59
  import { parseCharterFromGoal, runExperimentLoop, formatExperimentSummary, gitReturnToBranch } from "./experiment.js";
60
60
  import {
61
61
  addSharedTask,
62
+ acceptSharedInvite,
63
+ approveSharedInvite,
62
64
  assignSharedTask,
63
65
  claimSharedOperator,
64
66
  claimSharedTask,
67
+ commentSharedTask,
68
+ createSharedInvite,
65
69
  disableSharedProject,
66
70
  enableSharedProject,
67
71
  formatSharedProjectStatus,
72
+ getSharedTaskHistory,
68
73
  getSharedProjectPaths,
74
+ listSharedInvites,
69
75
  listSharedMembers,
70
76
  listSharedTasks,
71
77
  loadSharedProject,
72
78
  moveSharedTask,
79
+ rejectSharedInvite,
73
80
  releaseSharedOperator,
74
81
  removeSharedMember,
75
82
  setSharedRuntimeProfile,
@@ -158,13 +165,20 @@ const INTERACTIVE_COMMANDS = [
158
165
  { name: "/unshare-project", description: "Disable shared-project mode in the current cwd" },
159
166
  { name: "/room", description: "Show shared room status for the current project" },
160
167
  { name: "/room members", description: "List shared-project members" },
168
+ { name: "/room invites", description: "List pending shared-project invites" },
161
169
  { name: "/room add <id> [owner|editor|observer]", description: "Add or update a shared-project member" },
170
+ { name: "/room invite <id> [owner|editor|observer]", description: "Create a pending shared-project invite" },
171
+ { name: "/room invite approve <invite-id>", description: "Approve a pending shared-project invite" },
172
+ { name: "/room invite accept <invite-id>", description: "Accept your own pending shared-project invite" },
173
+ { name: "/room invite reject <invite-id>", description: "Reject a pending shared-project invite" },
162
174
  { name: "/room remove <id>", description: "Remove a shared-project member" },
163
175
  { name: "/room tasks", description: "List Roundtable tasks for the shared project" },
164
176
  { name: "/room task add <text>", description: "Add a Roundtable task" },
165
177
  { name: "/room task assign <id> <member-id>", description: "Assign a Roundtable task to a shared member" },
166
178
  { name: "/room task claim <id>", description: "Claim a Roundtable task for yourself" },
167
179
  { name: "/room task move <id> <open|active|blocked|done>", description: "Move a Roundtable task between states" },
180
+ { name: "/room task comment <id> <text>", description: "Add a Roundtable task comment" },
181
+ { name: "/room task history <id>", description: "Show Roundtable task history and comments" },
168
182
  { name: "/room runtime", description: "Show the shared room runtime profile" },
169
183
  { name: "/room runtime <name|clear>", description: "Set or clear the shared room runtime profile" },
170
184
  { name: "/room mode <chat|plan|execute>", description: "Set collaboration mode for the shared room" },
@@ -2626,8 +2640,22 @@ function buildRoomStatusPayload(project, runtime = null, currentSession = null)
2626
2640
  if (!project) {
2627
2641
  return { enabled: false };
2628
2642
  }
2643
+ const tasks = Array.isArray(project.tasks) ? project.tasks : [];
2644
+ const taskSummary = {
2645
+ total: tasks.length,
2646
+ byState: {},
2647
+ byAssignee: {}
2648
+ };
2649
+ for (const task of tasks) {
2650
+ const state = String(task?.state || "open");
2651
+ taskSummary.byState[state] = (taskSummary.byState[state] || 0) + 1;
2652
+ const assignee = String(task?.assignedTo || "").trim() || "unassigned";
2653
+ taskSummary.byAssignee[assignee] = (taskSummary.byAssignee[assignee] || 0) + 1;
2654
+ }
2629
2655
  return {
2630
2656
  ...project,
2657
+ pendingInviteCount: Array.isArray(project.pendingInvites) ? project.pendingInvites.length : 0,
2658
+ taskSummary,
2631
2659
  executor: {
2632
2660
  surface: "local-tui",
2633
2661
  roomRuntimeProfile: project.runtimeProfile || "",
@@ -3832,6 +3860,22 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false,
3832
3860
  return;
3833
3861
  }
3834
3862
 
3863
+ if (sub === "invites") {
3864
+ const invites = await listSharedInvites(cwd);
3865
+ if (asJson) {
3866
+ printData({ ok: true, invites }, true);
3867
+ return;
3868
+ }
3869
+ if (!invites.length) {
3870
+ console.log("No pending shared-project invites");
3871
+ return;
3872
+ }
3873
+ for (const invite of invites) {
3874
+ console.log(`${invite.id}\t${invite.memberId}\t${invite.role}\t${invite.memberName || ""}`);
3875
+ }
3876
+ return;
3877
+ }
3878
+
3835
3879
  if (sub === "tasks") {
3836
3880
  const tasks = await listSharedTasks(cwd);
3837
3881
  if (asJson) {
@@ -3904,7 +3948,40 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false,
3904
3948
  console.log(`Claimed shared task [${result.task.id}]`);
3905
3949
  return;
3906
3950
  }
3907
- throw new Error("Usage: waterbrother room task add <text>|assign <id> <member-id>|claim <id>|move <id> <open|active|blocked|done>");
3951
+ if (action === "comment") {
3952
+ const taskId = String(positional[3] || "").trim();
3953
+ const text = String(positional.slice(4).join(" ") || "").trim();
3954
+ if (!taskId || !text) {
3955
+ throw new Error("Usage: waterbrother room task comment <id> <text>");
3956
+ }
3957
+ const result = await commentSharedTask(cwd, taskId, text, { actorId: operator.id, actorName: operator.name });
3958
+ if (asJson) {
3959
+ printData({ ok: true, action: "task-comment", task: result.task, comment: result.comment, project: result.project }, true);
3960
+ return;
3961
+ }
3962
+ console.log(`Commented on shared task [${result.task.id}]`);
3963
+ return;
3964
+ }
3965
+ if (action === "history") {
3966
+ const taskId = String(positional[3] || "").trim();
3967
+ if (!taskId) {
3968
+ throw new Error("Usage: waterbrother room task history <id>");
3969
+ }
3970
+ const result = await getSharedTaskHistory(cwd, taskId, { actorId: operator.id });
3971
+ if (asJson) {
3972
+ printData({ ok: true, action: "task-history", ...result }, true);
3973
+ return;
3974
+ }
3975
+ console.log(`[${result.task.id}] ${result.task.text}`);
3976
+ for (const entry of result.history) {
3977
+ console.log(`history\t${entry.createdAt}\t${entry.type}\t${entry.actorName || entry.actorId || "-"}\t${entry.text}`);
3978
+ }
3979
+ for (const comment of result.comments) {
3980
+ console.log(`comment\t${comment.createdAt}\t${comment.actorName || comment.actorId || "-"}\t${comment.text}`);
3981
+ }
3982
+ return;
3983
+ }
3984
+ throw new Error("Usage: waterbrother room task add <text>|assign <id> <member-id>|claim <id>|move <id> <open|active|blocked|done>|comment <id> <text>|history <id>");
3908
3985
  }
3909
3986
 
3910
3987
  if (sub === "add") {
@@ -3927,6 +4004,45 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false,
3927
4004
  return;
3928
4005
  }
3929
4006
 
4007
+ if (sub === "invite") {
4008
+ const actionOrMemberId = String(positional[2] || "").trim();
4009
+ const maybeAction = actionOrMemberId.toLowerCase();
4010
+ if (maybeAction === "approve" || maybeAction === "reject" || maybeAction === "accept") {
4011
+ const inviteId = String(positional[3] || "").trim();
4012
+ if (!inviteId) {
4013
+ throw new Error(`Usage: waterbrother room invite ${maybeAction} <invite-id>`);
4014
+ }
4015
+ const result = maybeAction === "approve"
4016
+ ? await approveSharedInvite(cwd, inviteId, { actorId: operator.id })
4017
+ : maybeAction === "accept"
4018
+ ? await acceptSharedInvite(cwd, inviteId, { actorId: operator.id, actorName: operator.name })
4019
+ : await rejectSharedInvite(cwd, inviteId, { actorId: operator.id });
4020
+ if (asJson) {
4021
+ printData({ ok: true, action: `invite-${maybeAction}`, invite: result.invite, project: result.project }, true);
4022
+ return;
4023
+ }
4024
+ console.log(`${maybeAction === "approve" ? "Approved" : maybeAction === "accept" ? "Accepted" : "Rejected"} shared invite [${result.invite.id}]`);
4025
+ return;
4026
+ }
4027
+ const memberId = actionOrMemberId;
4028
+ const role = String(positional[3] || "editor").trim().toLowerCase();
4029
+ const name = String(positional.slice(4).join(" ") || memberId).trim();
4030
+ if (!memberId) {
4031
+ throw new Error("Usage: waterbrother room invite <member-id> [owner|editor|observer] [display name]|approve <invite-id>|accept <invite-id>|reject <invite-id>");
4032
+ }
4033
+ const result = await createSharedInvite(
4034
+ cwd,
4035
+ { id: memberId, role, name, paired: true },
4036
+ { actorId: operator.id, actorName: operator.name }
4037
+ );
4038
+ if (asJson) {
4039
+ printData({ ok: true, action: "invite", invite: result.invite, project: result.project }, true);
4040
+ return;
4041
+ }
4042
+ console.log(`Created shared-project invite [${result.invite.id}] for ${memberId} as ${result.invite.role}`);
4043
+ return;
4044
+ }
4045
+
3930
4046
  if (sub === "remove") {
3931
4047
  const memberId = String(positional[2] || "").trim();
3932
4048
  if (!memberId) {
@@ -4009,7 +4125,7 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false,
4009
4125
  return;
4010
4126
  }
4011
4127
 
4012
- throw new Error("Usage: waterbrother room status|members|add <member-id> [owner|editor|observer] [display name]|remove <member-id>|tasks|task add <text>|task assign <id> <member-id>|task claim <id>|task move <id> <open|active|blocked|done>|runtime [<name>|clear]|mode <chat|plan|execute>|claim|release");
4128
+ throw new Error("Usage: waterbrother room status|members|invites|add <member-id> [owner|editor|observer] [display name]|invite <member-id> [owner|editor|observer] [display name]|invite approve <invite-id>|invite accept <invite-id>|invite reject <invite-id>|remove <member-id>|tasks|task add <text>|task assign <id> <member-id>|task claim <id>|task move <id> <open|active|blocked|done>|task comment <id> <text>|task history <id>|runtime [<name>|clear]|mode <chat|plan|execute>|claim|release");
4013
4129
  }
4014
4130
 
4015
4131
  async function runMcpCommand(positional, runtime, { cwd, asJson = false } = {}) {
@@ -7860,6 +7976,15 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7860
7976
  continue;
7861
7977
  }
7862
7978
 
7979
+ if (line === "/room invites") {
7980
+ try {
7981
+ await runRoomCommand(["room", "invites"], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession, agent, context });
7982
+ } catch (error) {
7983
+ console.log(`room invites failed: ${error instanceof Error ? error.message : String(error)}`);
7984
+ }
7985
+ continue;
7986
+ }
7987
+
7863
7988
  if (line === "/room tasks") {
7864
7989
  try {
7865
7990
  await runRoomCommand(["room", "tasks"], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession, agent, context });
@@ -7906,6 +8031,39 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7906
8031
  continue;
7907
8032
  }
7908
8033
 
8034
+ if (line.startsWith("/room invite ")) {
8035
+ const raw = line.replace("/room invite", "").trim();
8036
+ if (!raw) {
8037
+ console.log("Usage: /room invite <member-id> [owner|editor|observer] [display name] | /room invite approve <invite-id> | /room invite accept <invite-id> | /room invite reject <invite-id>");
8038
+ continue;
8039
+ }
8040
+ const parts = raw.split(/\s+/).filter(Boolean);
8041
+ const first = String(parts[0] || "").toLowerCase();
8042
+ try {
8043
+ if (["approve", "accept", "reject"].includes(first)) {
8044
+ const inviteId = String(parts[1] || "").trim();
8045
+ if (!inviteId) {
8046
+ console.log(`Usage: /room invite ${first} <invite-id>`);
8047
+ continue;
8048
+ }
8049
+ await runRoomCommand(["room", "invite", first, inviteId], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession, agent, context });
8050
+ } else {
8051
+ const memberId = parts.shift() || "";
8052
+ let role = "editor";
8053
+ if (parts.length && ["owner", "editor", "observer"].includes(String(parts[0] || "").toLowerCase())) {
8054
+ role = String(parts.shift() || "editor").toLowerCase();
8055
+ }
8056
+ const displayName = parts.join(" ").trim();
8057
+ const positional = ["room", "invite", memberId, role];
8058
+ if (displayName) positional.push(...displayName.split(" "));
8059
+ await runRoomCommand(positional, { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession, agent, context });
8060
+ }
8061
+ } catch (error) {
8062
+ console.log(`room invite failed: ${error instanceof Error ? error.message : String(error)}`);
8063
+ }
8064
+ continue;
8065
+ }
8066
+
7909
8067
  if (line.startsWith("/room task add ")) {
7910
8068
  const text = line.replace("/room task add", "").trim();
7911
8069
  if (!text) {
@@ -7935,6 +8093,36 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
7935
8093
  continue;
7936
8094
  }
7937
8095
 
8096
+ if (line.startsWith("/room task comment ")) {
8097
+ const raw = line.replace("/room task comment", "").trim();
8098
+ const [taskId, ...textParts] = raw.split(/\s+/);
8099
+ const text = textParts.join(" ").trim();
8100
+ if (!taskId || !text) {
8101
+ console.log("Usage: /room task comment <id> <text>");
8102
+ continue;
8103
+ }
8104
+ try {
8105
+ await runRoomCommand(["room", "task", "comment", taskId, ...text.split(" ")], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession, agent, context });
8106
+ } catch (error) {
8107
+ console.log(`room task comment failed: ${error instanceof Error ? error.message : String(error)}`);
8108
+ }
8109
+ continue;
8110
+ }
8111
+
8112
+ if (line.startsWith("/room task history ")) {
8113
+ const taskId = line.replace("/room task history", "").trim();
8114
+ if (!taskId) {
8115
+ console.log("Usage: /room task history <id>");
8116
+ continue;
8117
+ }
8118
+ try {
8119
+ await runRoomCommand(["room", "task", "history", taskId], { cwd: context.cwd, asJson: false, runtime: context.runtime, currentSession, agent, context });
8120
+ } catch (error) {
8121
+ console.log(`room task history failed: ${error instanceof Error ? error.message : String(error)}`);
8122
+ }
8123
+ continue;
8124
+ }
8125
+
7938
8126
  if (line.startsWith("/room task assign ")) {
7939
8127
  const raw = line.replace("/room task assign", "").trim();
7940
8128
  const [taskId, memberId] = raw.split(/\s+/, 2);
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 { addSharedTask, assignSharedTask, claimSharedOperator, claimSharedTask, getSharedMember, listSharedTasks, loadSharedProject, moveSharedTask, releaseSharedOperator, setSharedRoom, setSharedRoomMode, setSharedRuntimeProfile, upsertSharedMember, removeSharedMember } from "./shared-project.js";
11
+ import { acceptSharedInvite, addSharedTask, approveSharedInvite, assignSharedTask, claimSharedOperator, claimSharedTask, commentSharedTask, createSharedInvite, getSharedTaskHistory, listSharedInvites, listSharedTasks, loadSharedProject, moveSharedTask, rejectSharedInvite, releaseSharedOperator, setSharedRoom, setSharedRoomMode, setSharedRuntimeProfile, 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: "invites", description: "List pending shared room invites" },
30
31
  { command: "tasks", description: "List shared Roundtable tasks" },
31
32
  { command: "room_runtime", description: "Show or set shared room runtime profile" },
32
33
  { command: "mode", description: "Show or set shared room mode" },
@@ -208,8 +209,14 @@ function buildRemoteHelp() {
208
209
  "<code>/task assign &lt;id&gt; &lt;member-id&gt;</code> assign a shared task",
209
210
  "<code>/task claim &lt;id&gt;</code> claim a shared task for yourself",
210
211
  "<code>/task move &lt;id&gt; &lt;open|active|blocked|done&gt;</code> move a shared Roundtable task",
212
+ "<code>/task comment &lt;id&gt; &lt;text&gt;</code> add a task comment",
213
+ "<code>/task history &lt;id&gt;</code> show task history and comments",
211
214
  "<code>/room-runtime</code> or <code>/room-runtime &lt;name|clear&gt;</code> inspect or set the shared room runtime profile",
212
- "<code>/invite &lt;user-id&gt; [owner|editor|observer]</code> add or update a shared project member",
215
+ "<code>/invites</code> list pending shared invites",
216
+ "<code>/invite &lt;user-id&gt; [owner|editor|observer]</code> create a pending shared-project invite",
217
+ "<code>/accept-invite &lt;invite-id&gt;</code> accept your own pending invite",
218
+ "<code>/approve-invite &lt;invite-id&gt;</code> approve a pending invite",
219
+ "<code>/reject-invite &lt;invite-id&gt;</code> reject a pending invite",
213
220
  "<code>/remove-member &lt;user-id&gt;</code> remove a shared project member",
214
221
  "<code>/mode</code> or <code>/mode &lt;chat|plan|execute&gt;</code> inspect or change shared room mode",
215
222
  "<code>/claim</code> claim operator control for a shared project",
@@ -293,6 +300,20 @@ function formatTelegramRoomMarkup(project, options = {}) {
293
300
  ? members.map((member) => `• ${escapeTelegramHtml(member.name || member.id)} <i>(${escapeTelegramHtml(member.role || "editor")})</i>`).join("\n")
294
301
  : "• none";
295
302
  const executor = options.executor || {};
303
+ const pendingInviteCount = Array.isArray(project.pendingInvites) ? project.pendingInvites.length : 0;
304
+ const tasks = Array.isArray(project.tasks) ? project.tasks : [];
305
+ const byState = new Map();
306
+ const byAssignee = new Map();
307
+ for (const task of tasks) {
308
+ const state = String(task?.state || "open");
309
+ byState.set(state, (byState.get(state) || 0) + 1);
310
+ const assignee = String(task?.assignedTo || "").trim() || "unassigned";
311
+ byAssignee.set(assignee, (byAssignee.get(assignee) || 0) + 1);
312
+ }
313
+ const ownershipLines = [...byAssignee.entries()]
314
+ .sort((a, b) => b[1] - a[1])
315
+ .slice(0, 4)
316
+ .map(([assignee, count]) => `• <code>${escapeTelegramHtml(assignee)}</code> ${count}`);
296
317
  const executorBits = [
297
318
  `surface: <code>${escapeTelegramHtml(executor.surface || "telegram")}</code>`,
298
319
  `provider: <code>${escapeTelegramHtml(executor.provider || "unknown")}</code>`,
@@ -314,8 +335,14 @@ function formatTelegramRoomMarkup(project, options = {}) {
314
335
  `room mode: <code>${escapeTelegramHtml(project.roomMode || "chat")}</code>`,
315
336
  `room: <code>${escapeTelegramHtml(roomLabel)}</code>`,
316
337
  `active operator: <code>${escapeTelegramHtml(active)}</code>`,
338
+ `pending invites: <code>${pendingInviteCount}</code>`,
317
339
  "<b>Executor</b>",
318
340
  ...executorBits,
341
+ "<b>Task Summary</b>",
342
+ `total: <code>${tasks.length}</code>`,
343
+ ...[...byState.entries()].map(([state, count]) => `${escapeTelegramHtml(state)}: <code>${count}</code>`),
344
+ "<b>Ownership</b>",
345
+ ...(ownershipLines.length ? ownershipLines : ["• none"]),
319
346
  "<b>Members</b>",
320
347
  memberLines
321
348
  ].join("\n");
@@ -335,6 +362,16 @@ function formatTelegramMembersMarkup(project) {
335
362
  ].join("\n");
336
363
  }
337
364
 
365
+ function formatTelegramInvitesMarkup(invites = []) {
366
+ if (!invites.length) {
367
+ return "<b>Pending invites</b>\n• none";
368
+ }
369
+ return [
370
+ "<b>Pending invites</b>",
371
+ ...invites.map((invite) => `• <code>${escapeTelegramHtml(invite.id)}</code> ${escapeTelegramHtml(invite.memberName || invite.memberId)} <i>(${escapeTelegramHtml(invite.role)})</i>`)
372
+ ].join("\n");
373
+ }
374
+
338
375
  function formatTelegramTasksMarkup(tasks = []) {
339
376
  if (!tasks.length) {
340
377
  return "<b>Shared tasks</b>\n• none";
@@ -345,6 +382,35 @@ function formatTelegramTasksMarkup(tasks = []) {
345
382
  ].join("\n");
346
383
  }
347
384
 
385
+ function formatTelegramTaskHistoryMarkup(result) {
386
+ const task = result?.task;
387
+ if (!task) return "No shared task found.";
388
+ const lines = [
389
+ `<b>Task</b> <code>${escapeTelegramHtml(task.id)}</code>`,
390
+ `${escapeTelegramHtml(task.text)}`,
391
+ `state: <code>${escapeTelegramHtml(task.state)}</code>${task.assignedTo ? ` assigned: <code>${escapeTelegramHtml(task.assignedTo)}</code>` : ""}`
392
+ ];
393
+ const history = Array.isArray(result.history) ? result.history : [];
394
+ const comments = Array.isArray(result.comments) ? result.comments : [];
395
+ lines.push("", "<b>History</b>");
396
+ if (history.length) {
397
+ for (const entry of history.slice(-8)) {
398
+ lines.push(`• ${escapeTelegramHtml(entry.createdAt)} ${escapeTelegramHtml(entry.type)} ${escapeTelegramHtml(entry.text)}`);
399
+ }
400
+ } else {
401
+ lines.push("• none");
402
+ }
403
+ lines.push("", "<b>Comments</b>");
404
+ if (comments.length) {
405
+ for (const comment of comments.slice(-8)) {
406
+ lines.push(`• ${escapeTelegramHtml(comment.actorName || comment.actorId || "-")}: ${escapeTelegramHtml(comment.text)}`);
407
+ }
408
+ } else {
409
+ lines.push("• none");
410
+ }
411
+ return lines.join("\n");
412
+ }
413
+
348
414
  function classifyTelegramGroupIntent(text = "") {
349
415
  const normalized = String(text || "").trim();
350
416
  const lower = normalized.toLowerCase();
@@ -1052,6 +1118,21 @@ class TelegramGateway {
1052
1118
  return;
1053
1119
  }
1054
1120
 
1121
+ if (text === "/invites") {
1122
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1123
+ if (!project?.enabled) {
1124
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
1125
+ return;
1126
+ }
1127
+ try {
1128
+ const invites = await listSharedInvites(session.cwd || this.cwd);
1129
+ await this.sendMessage(message.chat.id, formatTelegramInvitesMarkup(invites), message.message_id);
1130
+ } catch (error) {
1131
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
1132
+ }
1133
+ return;
1134
+ }
1135
+
1055
1136
  if (text === "/tasks") {
1056
1137
  const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1057
1138
  if (!project?.enabled) {
@@ -1066,7 +1147,7 @@ class TelegramGateway {
1066
1147
  if (text.startsWith("/task ")) {
1067
1148
  const taskShortcut = text.replace("/task", "").trim().toLowerCase();
1068
1149
  if (taskShortcut === "") {
1069
- await this.sendMessage(message.chat.id, "Usage: /task add <text> | /task assign <id> <member-id> | /task claim <id> | /task move <id> <open|active|blocked|done>", message.message_id);
1150
+ await this.sendMessage(message.chat.id, "Usage: /task add <text> | /task assign <id> <member-id> | /task claim <id> | /task move <id> <open|active|blocked|done> | /task comment <id> <text> | /task history <id>", message.message_id);
1070
1151
  return;
1071
1152
  }
1072
1153
  }
@@ -1179,15 +1260,14 @@ class TelegramGateway {
1179
1260
  return;
1180
1261
  }
1181
1262
  try {
1182
- const next = await upsertSharedMember(
1263
+ const result = await createSharedInvite(
1183
1264
  session.cwd || this.cwd,
1184
1265
  { id: nextUserId, role, name: nextUserId, paired: true },
1185
- { actorId: userId }
1266
+ { actorId: userId, actorName: peer?.username || [message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim() || userId }
1186
1267
  );
1187
- const member = getSharedMember(next, nextUserId);
1188
1268
  await this.sendMessage(
1189
1269
  message.chat.id,
1190
- `Shared member set: <code>${escapeTelegramHtml(member?.id || nextUserId)}</code> <i>(${escapeTelegramHtml(member?.role || role)})</i>`,
1270
+ `Created pending invite <code>${escapeTelegramHtml(result.invite.id)}</code> for <code>${escapeTelegramHtml(result.invite.memberId)}</code> <i>(${escapeTelegramHtml(result.invite.role)})</i>`,
1191
1271
  message.message_id
1192
1272
  );
1193
1273
  } catch (error) {
@@ -1196,6 +1276,69 @@ class TelegramGateway {
1196
1276
  return;
1197
1277
  }
1198
1278
 
1279
+ if (text.startsWith("/approve-invite ")) {
1280
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1281
+ if (!project?.enabled) {
1282
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
1283
+ return;
1284
+ }
1285
+ const inviteId = text.replace("/approve-invite", "").trim();
1286
+ if (!inviteId) {
1287
+ await this.sendMessage(message.chat.id, "Usage: /approve-invite <invite-id>", message.message_id);
1288
+ return;
1289
+ }
1290
+ try {
1291
+ const result = await approveSharedInvite(session.cwd || this.cwd, inviteId, { actorId: userId });
1292
+ await this.sendMessage(message.chat.id, `Approved invite <code>${escapeTelegramHtml(result.invite.id)}</code>`, message.message_id);
1293
+ } catch (error) {
1294
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
1295
+ }
1296
+ return;
1297
+ }
1298
+
1299
+ if (text.startsWith("/accept-invite ")) {
1300
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1301
+ if (!project?.enabled) {
1302
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
1303
+ return;
1304
+ }
1305
+ const inviteId = text.replace("/accept-invite", "").trim();
1306
+ if (!inviteId) {
1307
+ await this.sendMessage(message.chat.id, "Usage: /accept-invite <invite-id>", message.message_id);
1308
+ return;
1309
+ }
1310
+ try {
1311
+ const result = await acceptSharedInvite(session.cwd || this.cwd, inviteId, {
1312
+ actorId: userId,
1313
+ actorName: peer?.username || [message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim() || userId
1314
+ });
1315
+ await this.sendMessage(message.chat.id, `Accepted invite <code>${escapeTelegramHtml(result.invite.id)}</code>`, message.message_id);
1316
+ } catch (error) {
1317
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
1318
+ }
1319
+ return;
1320
+ }
1321
+
1322
+ if (text.startsWith("/reject-invite ")) {
1323
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1324
+ if (!project?.enabled) {
1325
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
1326
+ return;
1327
+ }
1328
+ const inviteId = text.replace("/reject-invite", "").trim();
1329
+ if (!inviteId) {
1330
+ await this.sendMessage(message.chat.id, "Usage: /reject-invite <invite-id>", message.message_id);
1331
+ return;
1332
+ }
1333
+ try {
1334
+ const result = await rejectSharedInvite(session.cwd || this.cwd, inviteId, { actorId: userId });
1335
+ await this.sendMessage(message.chat.id, `Rejected invite <code>${escapeTelegramHtml(result.invite.id)}</code>`, message.message_id);
1336
+ } catch (error) {
1337
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
1338
+ }
1339
+ return;
1340
+ }
1341
+
1199
1342
  if (text.startsWith("/remove-member ")) {
1200
1343
  const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1201
1344
  if (!project?.enabled) {
@@ -1280,6 +1423,51 @@ class TelegramGateway {
1280
1423
  return;
1281
1424
  }
1282
1425
 
1426
+ if (text.startsWith("/task comment ")) {
1427
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1428
+ if (!project?.enabled) {
1429
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
1430
+ return;
1431
+ }
1432
+ const rest = text.replace("/task comment", "").trim();
1433
+ const [taskId, ...textParts] = rest.split(/\s+/);
1434
+ const commentText = textParts.join(" ").trim();
1435
+ if (!taskId || !commentText) {
1436
+ await this.sendMessage(message.chat.id, "Usage: /task comment <id> <text>", message.message_id);
1437
+ return;
1438
+ }
1439
+ try {
1440
+ const result = await commentSharedTask(session.cwd || this.cwd, taskId, commentText, {
1441
+ actorId: userId,
1442
+ actorName: peer?.username || [message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim() || userId
1443
+ });
1444
+ await this.sendMessage(message.chat.id, `Commented on shared task <code>${escapeTelegramHtml(result.task.id)}</code>`, message.message_id);
1445
+ } catch (error) {
1446
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
1447
+ }
1448
+ return;
1449
+ }
1450
+
1451
+ if (text.startsWith("/task history ")) {
1452
+ const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1453
+ if (!project?.enabled) {
1454
+ await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
1455
+ return;
1456
+ }
1457
+ const taskId = text.replace("/task history", "").trim();
1458
+ if (!taskId) {
1459
+ await this.sendMessage(message.chat.id, "Usage: /task history <id>", message.message_id);
1460
+ return;
1461
+ }
1462
+ try {
1463
+ const result = await getSharedTaskHistory(session.cwd || this.cwd, taskId, { actorId: userId });
1464
+ await this.sendMessage(message.chat.id, formatTelegramTaskHistoryMarkup(result), message.message_id);
1465
+ } catch (error) {
1466
+ await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
1467
+ }
1468
+ return;
1469
+ }
1470
+
1283
1471
  if (text.startsWith("/task claim ")) {
1284
1472
  const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
1285
1473
  if (!project?.enabled) {
@@ -7,6 +7,10 @@ const SHARED_FILE = path.join(".waterbrother", "shared.json");
7
7
  const ROUNDTABLE_FILE = "ROUNDTABLE.md";
8
8
  const TASK_STATES = ["open", "active", "blocked", "done"];
9
9
 
10
+ function makeId(prefix = "id") {
11
+ return `${prefix}_${crypto.randomBytes(3).toString("hex")}`;
12
+ }
13
+
10
14
  function normalizeMember(member = {}) {
11
15
  return {
12
16
  id: String(member.id || "").trim(),
@@ -31,6 +35,11 @@ function normalizeSharedProject(project = {}, cwd = process.cwd()) {
31
35
  .map((task) => normalizeSharedTask(task))
32
36
  .filter((task) => task.id && task.text)
33
37
  : [];
38
+ const pendingInvites = Array.isArray(project.pendingInvites)
39
+ ? project.pendingInvites
40
+ .map((invite) => normalizePendingInvite(invite))
41
+ .filter((invite) => invite.id && invite.memberId && invite.status === "pending")
42
+ : [];
34
43
  const activeOperator = project.activeOperator && typeof project.activeOperator === "object"
35
44
  ? {
36
45
  id: String(project.activeOperator.id || "").trim(),
@@ -57,6 +66,7 @@ function normalizeSharedProject(project = {}, cwd = process.cwd()) {
57
66
  runtimeProfile: String(project.runtimeProfile || "").trim(),
58
67
  members,
59
68
  tasks,
69
+ pendingInvites,
60
70
  activeOperator: activeOperator?.id ? activeOperator : null,
61
71
  approvalPolicy: String(project.approvalPolicy || "owner").trim() || "owner",
62
72
  createdAt: String(project.createdAt || new Date().toISOString()).trim(),
@@ -65,14 +75,60 @@ function normalizeSharedProject(project = {}, cwd = process.cwd()) {
65
75
  }
66
76
 
67
77
  function normalizeSharedTask(task = {}) {
78
+ const comments = Array.isArray(task.comments)
79
+ ? task.comments
80
+ .map((comment) => normalizeTaskComment(comment))
81
+ .filter((comment) => comment.id && comment.text)
82
+ : [];
83
+ const history = Array.isArray(task.history)
84
+ ? task.history
85
+ .map((entry) => normalizeTaskHistoryEntry(entry))
86
+ .filter((entry) => entry.id && entry.type)
87
+ : [];
68
88
  return {
69
- id: String(task.id || `rt_${crypto.randomBytes(3).toString("hex")}`).trim(),
89
+ id: String(task.id || makeId("rt")).trim(),
70
90
  text: String(task.text || "").trim(),
71
91
  state: TASK_STATES.includes(String(task.state || "").trim()) ? String(task.state).trim() : "open",
72
92
  createdAt: String(task.createdAt || new Date().toISOString()).trim(),
73
93
  updatedAt: String(task.updatedAt || new Date().toISOString()).trim(),
74
94
  createdBy: String(task.createdBy || "").trim(),
75
- assignedTo: String(task.assignedTo || "").trim()
95
+ assignedTo: String(task.assignedTo || "").trim(),
96
+ comments,
97
+ history
98
+ };
99
+ }
100
+
101
+ function normalizeTaskComment(comment = {}) {
102
+ return {
103
+ id: String(comment.id || makeId("rtc")).trim(),
104
+ text: String(comment.text || "").trim(),
105
+ actorId: String(comment.actorId || "").trim(),
106
+ actorName: String(comment.actorName || "").trim(),
107
+ createdAt: String(comment.createdAt || new Date().toISOString()).trim()
108
+ };
109
+ }
110
+
111
+ function normalizeTaskHistoryEntry(entry = {}) {
112
+ return {
113
+ id: String(entry.id || makeId("rth")).trim(),
114
+ type: String(entry.type || "").trim(),
115
+ text: String(entry.text || "").trim(),
116
+ actorId: String(entry.actorId || "").trim(),
117
+ actorName: String(entry.actorName || "").trim(),
118
+ createdAt: String(entry.createdAt || new Date().toISOString()).trim()
119
+ };
120
+ }
121
+
122
+ function normalizePendingInvite(invite = {}) {
123
+ return {
124
+ id: String(invite.id || makeId("inv")).trim(),
125
+ memberId: String(invite.memberId || invite.userId || "").trim(),
126
+ memberName: String(invite.memberName || invite.name || "").trim(),
127
+ role: ["owner", "editor", "observer"].includes(String(invite.role || "").trim()) ? String(invite.role).trim() : "editor",
128
+ invitedBy: String(invite.invitedBy || "").trim(),
129
+ invitedByName: String(invite.invitedByName || "").trim(),
130
+ status: "pending",
131
+ createdAt: String(invite.createdAt || new Date().toISOString()).trim()
76
132
  };
77
133
  }
78
134
 
@@ -110,6 +166,9 @@ function defaultRoundtableContent(project) {
110
166
  const activeOperator = project.activeOperator?.id
111
167
  ? `${project.activeOperator.name || project.activeOperator.id}`
112
168
  : "none";
169
+ const inviteLines = (project.pendingInvites || []).length
170
+ ? project.pendingInvites.map((invite) => `- [${invite.id}] ${invite.memberName || invite.memberId} (${invite.role}) invited by ${invite.invitedByName || invite.invitedBy || "-"}`).join("\n")
171
+ : "- none";
113
172
  const taskLines = TASK_STATES.flatMap((state) => {
114
173
  const tasks = (project.tasks || []).filter((task) => task.state === state);
115
174
  if (!tasks.length) return [`- ${state}: -`];
@@ -129,6 +188,9 @@ function defaultRoundtableContent(project) {
129
188
  "## Members",
130
189
  memberLines,
131
190
  "",
191
+ "## Pending Invites",
192
+ inviteLines,
193
+ "",
132
194
  "## Current Goal",
133
195
  "-",
134
196
  "",
@@ -156,6 +218,13 @@ function buildRoundtableTaskSection(project) {
156
218
  return ["## Task Queue", ...taskLines, ""].join("\n");
157
219
  }
158
220
 
221
+ function buildRoundtablePendingInviteSection(project) {
222
+ const lines = (project.pendingInvites || []).length
223
+ ? project.pendingInvites.map((invite) => `- [${invite.id}] ${invite.memberName || invite.memberId} (${invite.role}) invited by ${invite.invitedByName || invite.invitedBy || "-"}`)
224
+ : ["- none"];
225
+ return ["## Pending Invites", ...lines, ""].join("\n");
226
+ }
227
+
159
228
  async function syncRoundtableTaskSection(cwd, project) {
160
229
  const target = roundtablePath(cwd);
161
230
  if (!(await pathExists(target))) return null;
@@ -171,6 +240,21 @@ async function syncRoundtableTaskSection(cwd, project) {
171
240
  return target;
172
241
  }
173
242
 
243
+ async function syncRoundtablePendingInviteSection(cwd, project) {
244
+ const target = roundtablePath(cwd);
245
+ if (!(await pathExists(target))) return null;
246
+ const current = await fs.readFile(target, "utf8");
247
+ const nextSection = buildRoundtablePendingInviteSection(project);
248
+ let nextContent;
249
+ if (/^## Pending Invites\s*$/m.test(current)) {
250
+ nextContent = current.replace(/## Pending Invites[\s\S]*?(?=^## |\s*$)/m, `${nextSection}\n`);
251
+ } else {
252
+ nextContent = current.replace(/^## Current Goal/m, `${nextSection}\n## Current Goal`);
253
+ }
254
+ await fs.writeFile(target, nextContent.endsWith("\n") ? nextContent : `${nextContent}\n`, "utf8");
255
+ return target;
256
+ }
257
+
174
258
  export async function ensureRoundtable(cwd, project) {
175
259
  const target = roundtablePath(cwd);
176
260
  if (await pathExists(target)) return target;
@@ -204,6 +288,7 @@ export async function saveSharedProject(cwd, project) {
204
288
  await writeJsonAtomically(sharedFilePath(cwd), next);
205
289
  await ensureRoundtable(cwd, next);
206
290
  await syncRoundtableTaskSection(cwd, next);
291
+ await syncRoundtablePendingInviteSection(cwd, next);
207
292
  return next;
208
293
  }
209
294
 
@@ -330,6 +415,12 @@ export async function listSharedMembers(cwd) {
330
415
  return project.members || [];
331
416
  }
332
417
 
418
+ export async function listSharedInvites(cwd) {
419
+ const project = await loadSharedProject(cwd);
420
+ requireSharedProject(project);
421
+ return project.pendingInvites || [];
422
+ }
423
+
333
424
  export async function upsertSharedMember(cwd, member = {}, options = {}) {
334
425
  const existing = await loadSharedProject(cwd);
335
426
  requireOwner(existing, options.actorId);
@@ -353,6 +444,125 @@ export async function upsertSharedMember(cwd, member = {}, options = {}) {
353
444
  return next;
354
445
  }
355
446
 
447
+ export async function createSharedInvite(cwd, member = {}, options = {}) {
448
+ const existing = await loadSharedProject(cwd);
449
+ requireOwner(existing, options.actorId);
450
+ const inviteMember = normalizeMember(member);
451
+ if (!inviteMember.id) throw new Error("member id is required");
452
+ if (getSharedMember(existing, inviteMember.id)) {
453
+ throw new Error(`Member ${inviteMember.id} is already part of the shared project.`);
454
+ }
455
+ const pendingInvites = Array.isArray(existing.pendingInvites) ? [...existing.pendingInvites] : [];
456
+ const existingInviteIndex = pendingInvites.findIndex((invite) => invite.memberId === inviteMember.id);
457
+ const invite = normalizePendingInvite({
458
+ id: existingInviteIndex >= 0 ? pendingInvites[existingInviteIndex].id : "",
459
+ memberId: inviteMember.id,
460
+ memberName: inviteMember.name || inviteMember.id,
461
+ role: inviteMember.role,
462
+ invitedBy: String(options.actorId || "").trim(),
463
+ invitedByName: String(options.actorName || options.actorId || "").trim()
464
+ });
465
+ if (existingInviteIndex >= 0) pendingInvites[existingInviteIndex] = invite;
466
+ else pendingInvites.push(invite);
467
+ const next = await saveSharedProject(cwd, {
468
+ ...existing,
469
+ pendingInvites
470
+ });
471
+ await appendRoundtableEvent(
472
+ cwd,
473
+ `- ${new Date().toISOString()}: invite created [${invite.id}] for ${invite.memberName || invite.memberId} as ${invite.role}`
474
+ );
475
+ return { project: next, invite };
476
+ }
477
+
478
+ export async function approveSharedInvite(cwd, inviteId = "", options = {}) {
479
+ const existing = await loadSharedProject(cwd);
480
+ requireOwner(existing, options.actorId);
481
+ const normalizedId = String(inviteId || "").trim();
482
+ if (!normalizedId) throw new Error("invite id is required");
483
+ const pendingInvites = Array.isArray(existing.pendingInvites) ? [...existing.pendingInvites] : [];
484
+ const index = pendingInvites.findIndex((invite) => invite.id === normalizedId);
485
+ if (index < 0) throw new Error(`No pending invite found for ${normalizedId}`);
486
+ const invite = pendingInvites[index];
487
+ const members = Array.isArray(existing.members) ? [...existing.members] : [];
488
+ if (!members.some((item) => item.id === invite.memberId)) {
489
+ members.push(normalizeMember({
490
+ id: invite.memberId,
491
+ name: invite.memberName,
492
+ role: invite.role,
493
+ paired: true
494
+ }));
495
+ }
496
+ pendingInvites.splice(index, 1);
497
+ const next = await saveSharedProject(cwd, {
498
+ ...existing,
499
+ members,
500
+ pendingInvites
501
+ });
502
+ await appendRoundtableEvent(
503
+ cwd,
504
+ `- ${new Date().toISOString()}: invite approved [${invite.id}] for ${invite.memberName || invite.memberId}`
505
+ );
506
+ return { project: next, invite };
507
+ }
508
+
509
+ export async function rejectSharedInvite(cwd, inviteId = "", options = {}) {
510
+ const existing = await loadSharedProject(cwd);
511
+ requireOwner(existing, options.actorId);
512
+ const normalizedId = String(inviteId || "").trim();
513
+ if (!normalizedId) throw new Error("invite id is required");
514
+ const pendingInvites = Array.isArray(existing.pendingInvites) ? [...existing.pendingInvites] : [];
515
+ const index = pendingInvites.findIndex((invite) => invite.id === normalizedId);
516
+ if (index < 0) throw new Error(`No pending invite found for ${normalizedId}`);
517
+ const [invite] = pendingInvites.splice(index, 1);
518
+ const next = await saveSharedProject(cwd, {
519
+ ...existing,
520
+ pendingInvites
521
+ });
522
+ await appendRoundtableEvent(
523
+ cwd,
524
+ `- ${new Date().toISOString()}: invite rejected [${invite.id}] for ${invite.memberName || invite.memberId}`
525
+ );
526
+ return { project: next, invite };
527
+ }
528
+
529
+ export async function acceptSharedInvite(cwd, inviteId = "", options = {}) {
530
+ const existing = await loadSharedProject(cwd);
531
+ requireSharedProject(existing);
532
+ const normalizedId = String(inviteId || "").trim();
533
+ const actorId = String(options.actorId || "").trim();
534
+ const actorName = String(options.actorName || options.actorId || "").trim();
535
+ if (!normalizedId) throw new Error("invite id is required");
536
+ if (!actorId) throw new Error("actor id is required");
537
+ const pendingInvites = Array.isArray(existing.pendingInvites) ? [...existing.pendingInvites] : [];
538
+ const index = pendingInvites.findIndex((invite) => invite.id === normalizedId);
539
+ if (index < 0) throw new Error(`No pending invite found for ${normalizedId}`);
540
+ const invite = pendingInvites[index];
541
+ if (invite.memberId !== actorId) {
542
+ throw new Error("Only the invited user can accept this invite.");
543
+ }
544
+ const members = Array.isArray(existing.members) ? [...existing.members] : [];
545
+ if (!members.some((item) => item.id === invite.memberId)) {
546
+ members.push(normalizeMember({
547
+ id: invite.memberId,
548
+ name: actorName || invite.memberName || invite.memberId,
549
+ role: invite.role,
550
+ paired: true
551
+ }));
552
+ }
553
+ pendingInvites.splice(index, 1);
554
+ const next = await saveSharedProject(cwd, {
555
+ ...existing,
556
+ members,
557
+ pendingInvites
558
+ });
559
+ await appendRoundtableEvent(
560
+ cwd,
561
+ `- ${new Date().toISOString()}: invite accepted [${invite.id}] by ${actorName || actorId}`
562
+ );
563
+ return { project: next, invite };
564
+ }
565
+
356
566
  export async function removeSharedMember(cwd, memberId = "", options = {}) {
357
567
  const existing = await loadSharedProject(cwd);
358
568
  requireOwner(existing, options.actorId);
@@ -382,6 +592,15 @@ export async function listSharedTasks(cwd) {
382
592
  return project.tasks || [];
383
593
  }
384
594
 
595
+ function makeTaskHistoryEntry(type = "", text = "", options = {}) {
596
+ return normalizeTaskHistoryEntry({
597
+ type,
598
+ text,
599
+ actorId: String(options.actorId || "").trim(),
600
+ actorName: String(options.actorName || options.actorId || "").trim()
601
+ });
602
+ }
603
+
385
604
  export async function addSharedTask(cwd, text = "", options = {}) {
386
605
  const existing = await loadSharedProject(cwd);
387
606
  requireMember(existing, options.actorId);
@@ -390,7 +609,10 @@ export async function addSharedTask(cwd, text = "", options = {}) {
390
609
  const task = normalizeSharedTask({
391
610
  text: normalizedText,
392
611
  state: "open",
393
- createdBy: String(options.actorId || "").trim()
612
+ createdBy: String(options.actorId || "").trim(),
613
+ history: [
614
+ makeTaskHistoryEntry("created", `Task created: ${normalizedText}`, options)
615
+ ]
394
616
  });
395
617
  const next = await saveSharedProject(cwd, {
396
618
  ...existing,
@@ -416,7 +638,11 @@ export async function moveSharedTask(cwd, taskId = "", state = "open", options =
416
638
  ...tasks[index],
417
639
  state: normalizedState,
418
640
  updatedAt: new Date().toISOString(),
419
- assignedTo: normalizedState === "active" ? String(options.actorId || "").trim() : tasks[index].assignedTo
641
+ assignedTo: normalizedState === "active" ? String(options.actorId || "").trim() : tasks[index].assignedTo,
642
+ history: [
643
+ ...(tasks[index].history || []),
644
+ makeTaskHistoryEntry("state", `Task moved to ${normalizedState}`, options)
645
+ ]
420
646
  };
421
647
  const next = await saveSharedProject(cwd, {
422
648
  ...existing,
@@ -442,7 +668,11 @@ export async function assignSharedTask(cwd, taskId = "", memberId = "", options
442
668
  tasks[index] = {
443
669
  ...tasks[index],
444
670
  assignedTo: normalizedMemberId,
445
- updatedAt: new Date().toISOString()
671
+ updatedAt: new Date().toISOString(),
672
+ history: [
673
+ ...(tasks[index].history || []),
674
+ makeTaskHistoryEntry("assign", `Task assigned to ${normalizedMemberId}`, options)
675
+ ]
446
676
  };
447
677
  const next = await saveSharedProject(cwd, {
448
678
  ...existing,
@@ -465,7 +695,11 @@ export async function claimSharedTask(cwd, taskId = "", options = {}) {
465
695
  ...tasks[index],
466
696
  assignedTo: actorId,
467
697
  state: "active",
468
- updatedAt: new Date().toISOString()
698
+ updatedAt: new Date().toISOString(),
699
+ history: [
700
+ ...(tasks[index].history || []),
701
+ makeTaskHistoryEntry("claim", `Task claimed by ${actorId}`, options)
702
+ ]
469
703
  };
470
704
  const next = await saveSharedProject(cwd, {
471
705
  ...existing,
@@ -475,6 +709,50 @@ export async function claimSharedTask(cwd, taskId = "", options = {}) {
475
709
  return { project: next, task: tasks[index] };
476
710
  }
477
711
 
712
+ export async function commentSharedTask(cwd, taskId = "", text = "", options = {}) {
713
+ const existing = await loadSharedProject(cwd);
714
+ requireMember(existing, options.actorId);
715
+ const normalizedId = String(taskId || "").trim();
716
+ const normalizedText = String(text || "").trim();
717
+ if (!normalizedId) throw new Error("task id is required");
718
+ if (!normalizedText) throw new Error("comment text is required");
719
+ const tasks = [...(existing.tasks || [])];
720
+ const index = tasks.findIndex((task) => task.id === normalizedId);
721
+ if (index < 0) throw new Error(`No shared task found for ${normalizedId}`);
722
+ const comment = normalizeTaskComment({
723
+ text: normalizedText,
724
+ actorId: String(options.actorId || "").trim(),
725
+ actorName: String(options.actorName || options.actorId || "").trim()
726
+ });
727
+ const historyEntry = makeTaskHistoryEntry("comment", `Comment added: ${normalizedText}`, options);
728
+ tasks[index] = {
729
+ ...tasks[index],
730
+ updatedAt: new Date().toISOString(),
731
+ comments: [...(tasks[index].comments || []), comment],
732
+ history: [...(tasks[index].history || []), historyEntry]
733
+ };
734
+ const next = await saveSharedProject(cwd, {
735
+ ...existing,
736
+ tasks
737
+ });
738
+ await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: task comment [${tasks[index].id}] ${normalizedText}`);
739
+ return { project: next, task: tasks[index], comment };
740
+ }
741
+
742
+ export async function getSharedTaskHistory(cwd, taskId = "", options = {}) {
743
+ const existing = await loadSharedProject(cwd);
744
+ requireMember(existing, options.actorId);
745
+ const normalizedId = String(taskId || "").trim();
746
+ if (!normalizedId) throw new Error("task id is required");
747
+ const task = (existing.tasks || []).find((item) => item.id === normalizedId);
748
+ if (!task) throw new Error(`No shared task found for ${normalizedId}`);
749
+ return {
750
+ task,
751
+ comments: task.comments || [],
752
+ history: task.history || []
753
+ };
754
+ }
755
+
478
756
  export async function claimSharedOperator(cwd, operator = {}) {
479
757
  const existing = await loadSharedProject(cwd);
480
758
  requireSharedProject(existing);
@@ -535,9 +813,11 @@ export function formatSharedProjectStatus(project) {
535
813
  room: project.room,
536
814
  mode: project.mode,
537
815
  roomMode: project.roomMode,
816
+ runtimeProfile: project.runtimeProfile,
538
817
  approvalPolicy: project.approvalPolicy,
539
818
  activeOperator: project.activeOperator,
540
819
  members: project.members,
820
+ pendingInvites: project.pendingInvites,
541
821
  tasks: project.tasks
542
822
  }, null, 2);
543
823
  }