@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 +4 -3
- package/package.json +1 -1
- package/src/cli.js +190 -2
- package/src/gateway.js +195 -7
- package/src/shared-project.js +286 -6
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
|
|
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
|
|
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
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
|
-
|
|
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,
|
|
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 <id> <member-id></code> assign a shared task",
|
|
209
210
|
"<code>/task claim <id></code> claim a shared task for yourself",
|
|
210
211
|
"<code>/task move <id> <open|active|blocked|done></code> move a shared Roundtable task",
|
|
212
|
+
"<code>/task comment <id> <text></code> add a task comment",
|
|
213
|
+
"<code>/task history <id></code> show task history and comments",
|
|
211
214
|
"<code>/room-runtime</code> or <code>/room-runtime <name|clear></code> inspect or set the shared room runtime profile",
|
|
212
|
-
"<code>/
|
|
215
|
+
"<code>/invites</code> list pending shared invites",
|
|
216
|
+
"<code>/invite <user-id> [owner|editor|observer]</code> create a pending shared-project invite",
|
|
217
|
+
"<code>/accept-invite <invite-id></code> accept your own pending invite",
|
|
218
|
+
"<code>/approve-invite <invite-id></code> approve a pending invite",
|
|
219
|
+
"<code>/reject-invite <invite-id></code> reject a pending invite",
|
|
213
220
|
"<code>/remove-member <user-id></code> remove a shared project member",
|
|
214
221
|
"<code>/mode</code> or <code>/mode <chat|plan|execute></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
|
|
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
|
-
`
|
|
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) {
|
package/src/shared-project.js
CHANGED
|
@@ -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 ||
|
|
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
|
}
|