@tritard/waterbrother 0.16.26 → 0.16.28
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 +2 -1
- package/package.json +1 -1
- package/src/cli.js +96 -1
- package/src/gateway.js +66 -1
- package/src/shared-project.js +116 -5
package/README.md
CHANGED
|
@@ -266,6 +266,7 @@ Shared project foundation is now live:
|
|
|
266
266
|
- inspect it with `waterbrother room status`
|
|
267
267
|
- control conversation vs execution with `waterbrother room mode chat|plan|execute`
|
|
268
268
|
- manage collaborators with `waterbrother room members`, `waterbrother room add`, and `waterbrother room remove`
|
|
269
|
+
- manage the shared backlog with `waterbrother room tasks`, `waterbrother room task add`, and `waterbrother room task move`
|
|
269
270
|
- claim or release the shared operator lock with `waterbrother room claim` and `waterbrother room release`
|
|
270
271
|
- shared project metadata lives in `.waterbrother/shared.json`
|
|
271
272
|
- human collaboration notes live in `ROUNDTABLE.md`
|
|
@@ -278,7 +279,7 @@ Current Telegram behavior:
|
|
|
278
279
|
- pending pairings are explicit and expire automatically after 12 hours unless approved
|
|
279
280
|
- paired Telegram users drive the same live session and permissions as the terminal operator when the TUI bridge is attached
|
|
280
281
|
- Telegram now supports remote workspace control with `/cwd`, `/use <path>`, `/desktop`, and `/new-project <name>`
|
|
281
|
-
- shared projects now support `/room`, `/members`, `/mode`, `/claim`, `/release`, `/invite`,
|
|
282
|
+
- shared projects now support `/room`, `/members`, `/tasks`, `/mode`, `/claim`, `/release`, `/invite`, `/remove-member`, and `/task ...` from Telegram
|
|
282
283
|
- shared Telegram execution only runs when the shared room is in `execute` mode
|
|
283
284
|
- room administration is owner-only, and only owners/editors can hold the operator lock
|
|
284
285
|
- in Telegram groups, Waterbrother only responds when directly targeted: slash commands, `@botname` mentions, or replies to a bot message
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -58,13 +58,16 @@ import { scanForInitiatives, formatInitiatives, buildInitiativeFixPrompt } from
|
|
|
58
58
|
import { formatPlanForDisplay } from "./planner.js";
|
|
59
59
|
import { parseCharterFromGoal, runExperimentLoop, formatExperimentSummary, gitReturnToBranch } from "./experiment.js";
|
|
60
60
|
import {
|
|
61
|
+
addSharedTask,
|
|
61
62
|
claimSharedOperator,
|
|
62
63
|
disableSharedProject,
|
|
63
64
|
enableSharedProject,
|
|
64
65
|
formatSharedProjectStatus,
|
|
65
66
|
getSharedProjectPaths,
|
|
66
67
|
listSharedMembers,
|
|
68
|
+
listSharedTasks,
|
|
67
69
|
loadSharedProject,
|
|
70
|
+
moveSharedTask,
|
|
68
71
|
releaseSharedOperator,
|
|
69
72
|
removeSharedMember,
|
|
70
73
|
setSharedRoomMode,
|
|
@@ -154,6 +157,9 @@ const INTERACTIVE_COMMANDS = [
|
|
|
154
157
|
{ name: "/room members", description: "List shared-project members" },
|
|
155
158
|
{ name: "/room add <id> [owner|editor|observer]", description: "Add or update a shared-project member" },
|
|
156
159
|
{ name: "/room remove <id>", description: "Remove a shared-project member" },
|
|
160
|
+
{ name: "/room tasks", description: "List Roundtable tasks for the shared project" },
|
|
161
|
+
{ name: "/room task add <text>", description: "Add a Roundtable task" },
|
|
162
|
+
{ name: "/room task move <id> <open|active|blocked|done>", description: "Move a Roundtable task between states" },
|
|
157
163
|
{ name: "/room mode <chat|plan|execute>", description: "Set collaboration mode for the shared room" },
|
|
158
164
|
{ name: "/room claim", description: "Claim operator control for the shared room" },
|
|
159
165
|
{ name: "/room release", description: "Release operator control for the shared room" },
|
|
@@ -281,6 +287,9 @@ Usage:
|
|
|
281
287
|
waterbrother room members
|
|
282
288
|
waterbrother room add <member-id> [owner|editor|observer]
|
|
283
289
|
waterbrother room remove <member-id>
|
|
290
|
+
waterbrother room tasks
|
|
291
|
+
waterbrother room task add <text>
|
|
292
|
+
waterbrother room task move <id> <open|active|blocked|done>
|
|
284
293
|
waterbrother room mode <chat|plan|execute>
|
|
285
294
|
waterbrother room claim
|
|
286
295
|
waterbrother room release
|
|
@@ -3794,6 +3803,54 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false
|
|
|
3794
3803
|
return;
|
|
3795
3804
|
}
|
|
3796
3805
|
|
|
3806
|
+
if (sub === "tasks") {
|
|
3807
|
+
const tasks = await listSharedTasks(cwd);
|
|
3808
|
+
if (asJson) {
|
|
3809
|
+
printData({ ok: true, tasks }, true);
|
|
3810
|
+
return;
|
|
3811
|
+
}
|
|
3812
|
+
if (!tasks.length) {
|
|
3813
|
+
console.log("No shared-project tasks");
|
|
3814
|
+
return;
|
|
3815
|
+
}
|
|
3816
|
+
for (const task of tasks) {
|
|
3817
|
+
console.log(`${task.id}\t${task.state}\t${task.text}`);
|
|
3818
|
+
}
|
|
3819
|
+
return;
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
if (sub === "task") {
|
|
3823
|
+
const action = String(positional[2] || "").trim().toLowerCase();
|
|
3824
|
+
if (action === "add") {
|
|
3825
|
+
const text = String(positional.slice(3).join(" ") || "").trim();
|
|
3826
|
+
if (!text) {
|
|
3827
|
+
throw new Error("Usage: waterbrother room task add <text>");
|
|
3828
|
+
}
|
|
3829
|
+
const result = await addSharedTask(cwd, text, { actorId: operator.id });
|
|
3830
|
+
if (asJson) {
|
|
3831
|
+
printData({ ok: true, action: "task-add", task: result.task, project: result.project }, true);
|
|
3832
|
+
return;
|
|
3833
|
+
}
|
|
3834
|
+
console.log(`Added shared task [${result.task.id}] ${result.task.text}`);
|
|
3835
|
+
return;
|
|
3836
|
+
}
|
|
3837
|
+
if (action === "move") {
|
|
3838
|
+
const taskId = String(positional[3] || "").trim();
|
|
3839
|
+
const state = String(positional[4] || "").trim().toLowerCase();
|
|
3840
|
+
if (!taskId || !state) {
|
|
3841
|
+
throw new Error("Usage: waterbrother room task move <id> <open|active|blocked|done>");
|
|
3842
|
+
}
|
|
3843
|
+
const result = await moveSharedTask(cwd, taskId, state, { actorId: operator.id });
|
|
3844
|
+
if (asJson) {
|
|
3845
|
+
printData({ ok: true, action: "task-move", task: result.task, project: result.project }, true);
|
|
3846
|
+
return;
|
|
3847
|
+
}
|
|
3848
|
+
console.log(`Moved shared task [${result.task.id}] to ${result.task.state}`);
|
|
3849
|
+
return;
|
|
3850
|
+
}
|
|
3851
|
+
throw new Error("Usage: waterbrother room task add <text>|move <id> <open|active|blocked|done>");
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3797
3854
|
if (sub === "add") {
|
|
3798
3855
|
const memberId = String(positional[2] || "").trim();
|
|
3799
3856
|
const role = String(positional[3] || "editor").trim().toLowerCase();
|
|
@@ -3868,7 +3925,7 @@ async function runRoomCommand(positional, { cwd = process.cwd(), asJson = false
|
|
|
3868
3925
|
return;
|
|
3869
3926
|
}
|
|
3870
3927
|
|
|
3871
|
-
throw new Error("Usage: waterbrother room status|members|add <member-id> [owner|editor|observer] [display name]|remove <member-id>|mode <chat|plan|execute>|claim|release");
|
|
3928
|
+
throw new Error("Usage: waterbrother room status|members|add <member-id> [owner|editor|observer] [display name]|remove <member-id>|tasks|task add <text>|task move <id> <open|active|blocked|done>|mode <chat|plan|execute>|claim|release");
|
|
3872
3929
|
}
|
|
3873
3930
|
|
|
3874
3931
|
async function runMcpCommand(positional, runtime, { cwd, asJson = false } = {}) {
|
|
@@ -7705,6 +7762,15 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
|
|
|
7705
7762
|
continue;
|
|
7706
7763
|
}
|
|
7707
7764
|
|
|
7765
|
+
if (line === "/room tasks") {
|
|
7766
|
+
try {
|
|
7767
|
+
await runRoomCommand(["room", "tasks"], { cwd: context.cwd, asJson: false });
|
|
7768
|
+
} catch (error) {
|
|
7769
|
+
console.log(`room tasks failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7770
|
+
}
|
|
7771
|
+
continue;
|
|
7772
|
+
}
|
|
7773
|
+
|
|
7708
7774
|
if (line.startsWith("/room add ")) {
|
|
7709
7775
|
const raw = line.replace("/room add", "").trim();
|
|
7710
7776
|
if (!raw) {
|
|
@@ -7742,6 +7808,35 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
|
|
|
7742
7808
|
continue;
|
|
7743
7809
|
}
|
|
7744
7810
|
|
|
7811
|
+
if (line.startsWith("/room task add ")) {
|
|
7812
|
+
const text = line.replace("/room task add", "").trim();
|
|
7813
|
+
if (!text) {
|
|
7814
|
+
console.log("Usage: /room task add <text>");
|
|
7815
|
+
continue;
|
|
7816
|
+
}
|
|
7817
|
+
try {
|
|
7818
|
+
await runRoomCommand(["room", "task", "add", ...text.split(" ")], { cwd: context.cwd, asJson: false });
|
|
7819
|
+
} catch (error) {
|
|
7820
|
+
console.log(`room task add failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7821
|
+
}
|
|
7822
|
+
continue;
|
|
7823
|
+
}
|
|
7824
|
+
|
|
7825
|
+
if (line.startsWith("/room task move ")) {
|
|
7826
|
+
const raw = line.replace("/room task move", "").trim();
|
|
7827
|
+
const [taskId, state] = raw.split(/\s+/, 2);
|
|
7828
|
+
if (!taskId || !state) {
|
|
7829
|
+
console.log("Usage: /room task move <id> <open|active|blocked|done>");
|
|
7830
|
+
continue;
|
|
7831
|
+
}
|
|
7832
|
+
try {
|
|
7833
|
+
await runRoomCommand(["room", "task", "move", taskId, state], { cwd: context.cwd, asJson: false });
|
|
7834
|
+
} catch (error) {
|
|
7835
|
+
console.log(`room task move failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
7836
|
+
}
|
|
7837
|
+
continue;
|
|
7838
|
+
}
|
|
7839
|
+
|
|
7745
7840
|
if (line.startsWith("/room mode ")) {
|
|
7746
7841
|
const nextMode = line.replace("/room mode", "").trim().toLowerCase();
|
|
7747
7842
|
if (!nextMode) {
|
package/src/gateway.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createSession, listSessions, loadSession, saveSession } from "./session
|
|
|
8
8
|
import { DEFAULT_PENDING_PAIRING_TTL_MINUTES, loadGatewayBridge, loadGatewayState, prunePendingPairings, saveGatewayBridge, saveGatewayState } from "./gateway-state.js";
|
|
9
9
|
import { getGatewayStatus, getChannelSpec } from "./channels.js";
|
|
10
10
|
import { canonicalizeLoosePath } from "./path-utils.js";
|
|
11
|
-
import { claimSharedOperator, getSharedMember, loadSharedProject, releaseSharedOperator, setSharedRoom, setSharedRoomMode, upsertSharedMember, removeSharedMember } from "./shared-project.js";
|
|
11
|
+
import { addSharedTask, claimSharedOperator, getSharedMember, listSharedTasks, loadSharedProject, moveSharedTask, releaseSharedOperator, setSharedRoom, setSharedRoomMode, upsertSharedMember, removeSharedMember } from "./shared-project.js";
|
|
12
12
|
|
|
13
13
|
const execFileAsync = promisify(execFile);
|
|
14
14
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -27,6 +27,7 @@ const TELEGRAM_COMMANDS = [
|
|
|
27
27
|
{ command: "runtime", description: "Show active runtime status" },
|
|
28
28
|
{ command: "room", description: "Show shared room status" },
|
|
29
29
|
{ command: "members", description: "List shared room members" },
|
|
30
|
+
{ command: "tasks", description: "List shared Roundtable tasks" },
|
|
30
31
|
{ command: "mode", description: "Show or set shared room mode" },
|
|
31
32
|
{ command: "claim", description: "Claim operator control for a shared project" },
|
|
32
33
|
{ command: "release", description: "Release operator control for a shared project" },
|
|
@@ -201,6 +202,9 @@ function buildRemoteHelp() {
|
|
|
201
202
|
"<code>/runtime</code> show active provider/model/runtime state",
|
|
202
203
|
"<code>/room</code> show shared project room status",
|
|
203
204
|
"<code>/members</code> list shared project members",
|
|
205
|
+
"<code>/tasks</code> list shared project tasks",
|
|
206
|
+
"<code>/task add <text></code> add a shared Roundtable task",
|
|
207
|
+
"<code>/task move <id> <open|active|blocked|done></code> move a shared Roundtable task",
|
|
204
208
|
"<code>/invite <user-id> [owner|editor|observer]</code> add or update a shared project member",
|
|
205
209
|
"<code>/remove-member <user-id></code> remove a shared project member",
|
|
206
210
|
"<code>/mode</code> or <code>/mode <chat|plan|execute></code> inspect or change shared room mode",
|
|
@@ -310,6 +314,16 @@ function formatTelegramMembersMarkup(project) {
|
|
|
310
314
|
].join("\n");
|
|
311
315
|
}
|
|
312
316
|
|
|
317
|
+
function formatTelegramTasksMarkup(tasks = []) {
|
|
318
|
+
if (!tasks.length) {
|
|
319
|
+
return "<b>Shared tasks</b>\n• none";
|
|
320
|
+
}
|
|
321
|
+
return [
|
|
322
|
+
"<b>Shared tasks</b>",
|
|
323
|
+
...tasks.map((task) => `• <code>${escapeTelegramHtml(task.id)}</code> <i>(${escapeTelegramHtml(task.state)})</i> ${escapeTelegramHtml(task.text)}`)
|
|
324
|
+
].join("\n");
|
|
325
|
+
}
|
|
326
|
+
|
|
313
327
|
function parseInviteCommand(text) {
|
|
314
328
|
const parts = String(text || "").trim().split(/\s+/).filter(Boolean);
|
|
315
329
|
const userId = String(parts[1] || "").trim();
|
|
@@ -950,6 +964,17 @@ class TelegramGateway {
|
|
|
950
964
|
return;
|
|
951
965
|
}
|
|
952
966
|
|
|
967
|
+
if (text === "/tasks") {
|
|
968
|
+
const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
969
|
+
if (!project?.enabled) {
|
|
970
|
+
await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const tasks = await listSharedTasks(session.cwd || this.cwd);
|
|
974
|
+
await this.sendMessage(message.chat.id, formatTelegramTasksMarkup(tasks), message.message_id);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
|
|
953
978
|
if (text === "/mode") {
|
|
954
979
|
const { project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
955
980
|
await this.sendMessage(
|
|
@@ -1064,6 +1089,46 @@ class TelegramGateway {
|
|
|
1064
1089
|
return;
|
|
1065
1090
|
}
|
|
1066
1091
|
|
|
1092
|
+
if (text.startsWith("/task add ")) {
|
|
1093
|
+
const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
1094
|
+
if (!project?.enabled) {
|
|
1095
|
+
await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const taskText = text.replace("/task add", "").trim();
|
|
1099
|
+
if (!taskText) {
|
|
1100
|
+
await this.sendMessage(message.chat.id, "Usage: /task add <text>", message.message_id);
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
try {
|
|
1104
|
+
const result = await addSharedTask(session.cwd || this.cwd, taskText, { actorId: userId });
|
|
1105
|
+
await this.sendMessage(message.chat.id, `Added shared task <code>${escapeTelegramHtml(result.task.id)}</code>`, message.message_id);
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
|
|
1108
|
+
}
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (text.startsWith("/task move ")) {
|
|
1113
|
+
const { session, project } = await this.bindSharedRoomForMessage(message, sessionId);
|
|
1114
|
+
if (!project?.enabled) {
|
|
1115
|
+
await this.sendMessage(message.chat.id, "This project is not shared.", message.message_id);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const [taskId, state] = text.replace("/task move", "").trim().split(/\s+/, 2);
|
|
1119
|
+
if (!taskId || !state) {
|
|
1120
|
+
await this.sendMessage(message.chat.id, "Usage: /task move <id> <open|active|blocked|done>", message.message_id);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
try {
|
|
1124
|
+
const result = await moveSharedTask(session.cwd || this.cwd, taskId, state, { actorId: userId });
|
|
1125
|
+
await this.sendMessage(message.chat.id, `Moved shared task <code>${escapeTelegramHtml(result.task.id)}</code> to <code>${escapeTelegramHtml(result.task.state)}</code>`, message.message_id);
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
await this.sendMessage(message.chat.id, escapeTelegramHtml(error instanceof Error ? error.message : String(error)), message.message_id);
|
|
1128
|
+
}
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1067
1132
|
if (text === "/runtime") {
|
|
1068
1133
|
const status = await this.runRuntimeStatus();
|
|
1069
1134
|
await this.sendMessage(message.chat.id, formatRuntimeStatus(status), message.message_id);
|
package/src/shared-project.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
|
|
6
6
|
const SHARED_FILE = path.join(".waterbrother", "shared.json");
|
|
7
7
|
const ROUNDTABLE_FILE = "ROUNDTABLE.md";
|
|
8
|
+
const TASK_STATES = ["open", "active", "blocked", "done"];
|
|
8
9
|
|
|
9
10
|
function normalizeMember(member = {}) {
|
|
10
11
|
return {
|
|
@@ -25,6 +26,11 @@ function memberRoleWeight(role = "") {
|
|
|
25
26
|
|
|
26
27
|
function normalizeSharedProject(project = {}, cwd = process.cwd()) {
|
|
27
28
|
const members = Array.isArray(project.members) ? project.members.map(normalizeMember).filter((item) => item.id) : [];
|
|
29
|
+
const tasks = Array.isArray(project.tasks)
|
|
30
|
+
? project.tasks
|
|
31
|
+
.map((task) => normalizeSharedTask(task))
|
|
32
|
+
.filter((task) => task.id && task.text)
|
|
33
|
+
: [];
|
|
28
34
|
const activeOperator = project.activeOperator && typeof project.activeOperator === "object"
|
|
29
35
|
? {
|
|
30
36
|
id: String(project.activeOperator.id || "").trim(),
|
|
@@ -49,6 +55,7 @@ function normalizeSharedProject(project = {}, cwd = process.cwd()) {
|
|
|
49
55
|
? String(project.roomMode).trim()
|
|
50
56
|
: "chat",
|
|
51
57
|
members,
|
|
58
|
+
tasks,
|
|
52
59
|
activeOperator: activeOperator?.id ? activeOperator : null,
|
|
53
60
|
approvalPolicy: String(project.approvalPolicy || "owner").trim() || "owner",
|
|
54
61
|
createdAt: String(project.createdAt || new Date().toISOString()).trim(),
|
|
@@ -56,6 +63,18 @@ function normalizeSharedProject(project = {}, cwd = process.cwd()) {
|
|
|
56
63
|
};
|
|
57
64
|
}
|
|
58
65
|
|
|
66
|
+
function normalizeSharedTask(task = {}) {
|
|
67
|
+
return {
|
|
68
|
+
id: String(task.id || `rt_${crypto.randomBytes(3).toString("hex")}`).trim(),
|
|
69
|
+
text: String(task.text || "").trim(),
|
|
70
|
+
state: TASK_STATES.includes(String(task.state || "").trim()) ? String(task.state).trim() : "open",
|
|
71
|
+
createdAt: String(task.createdAt || new Date().toISOString()).trim(),
|
|
72
|
+
updatedAt: String(task.updatedAt || new Date().toISOString()).trim(),
|
|
73
|
+
createdBy: String(task.createdBy || "").trim(),
|
|
74
|
+
assignedTo: String(task.assignedTo || "").trim()
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
function sharedFilePath(cwd) {
|
|
60
79
|
return path.join(cwd, SHARED_FILE);
|
|
61
80
|
}
|
|
@@ -90,6 +109,11 @@ function defaultRoundtableContent(project) {
|
|
|
90
109
|
const activeOperator = project.activeOperator?.id
|
|
91
110
|
? `${project.activeOperator.name || project.activeOperator.id}`
|
|
92
111
|
: "none";
|
|
112
|
+
const taskLines = TASK_STATES.flatMap((state) => {
|
|
113
|
+
const tasks = (project.tasks || []).filter((task) => task.state === state);
|
|
114
|
+
if (!tasks.length) return [`- ${state}: -`];
|
|
115
|
+
return [`- ${state}:`, ...tasks.map((task) => ` - [${task.id}] ${task.text}`)];
|
|
116
|
+
});
|
|
93
117
|
return [
|
|
94
118
|
"# Roundtable",
|
|
95
119
|
"",
|
|
@@ -116,14 +140,35 @@ function defaultRoundtableContent(project) {
|
|
|
116
140
|
"-",
|
|
117
141
|
"",
|
|
118
142
|
"## Task Queue",
|
|
119
|
-
|
|
120
|
-
"- active: -",
|
|
121
|
-
"- blocked: -",
|
|
122
|
-
"- done: -",
|
|
143
|
+
...taskLines,
|
|
123
144
|
""
|
|
124
145
|
].join("\n");
|
|
125
146
|
}
|
|
126
147
|
|
|
148
|
+
function buildRoundtableTaskSection(project) {
|
|
149
|
+
const taskLines = TASK_STATES.flatMap((state) => {
|
|
150
|
+
const tasks = (project.tasks || []).filter((task) => task.state === state);
|
|
151
|
+
if (!tasks.length) return [`- ${state}: -`];
|
|
152
|
+
return [`- ${state}:`, ...tasks.map((task) => ` - [${task.id}] ${task.text}`)];
|
|
153
|
+
});
|
|
154
|
+
return ["## Task Queue", ...taskLines, ""].join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function syncRoundtableTaskSection(cwd, project) {
|
|
158
|
+
const target = roundtablePath(cwd);
|
|
159
|
+
if (!(await pathExists(target))) return null;
|
|
160
|
+
const current = await fs.readFile(target, "utf8");
|
|
161
|
+
const nextSection = buildRoundtableTaskSection(project);
|
|
162
|
+
let nextContent;
|
|
163
|
+
if (/^## Task Queue\s*$/m.test(current)) {
|
|
164
|
+
nextContent = current.replace(/## Task Queue[\s\S]*$/, nextSection);
|
|
165
|
+
} else {
|
|
166
|
+
nextContent = `${current.trimEnd()}\n\n${nextSection}`;
|
|
167
|
+
}
|
|
168
|
+
await fs.writeFile(target, nextContent.endsWith("\n") ? nextContent : `${nextContent}\n`, "utf8");
|
|
169
|
+
return target;
|
|
170
|
+
}
|
|
171
|
+
|
|
127
172
|
export async function ensureRoundtable(cwd, project) {
|
|
128
173
|
const target = roundtablePath(cwd);
|
|
129
174
|
if (await pathExists(target)) return target;
|
|
@@ -156,6 +201,7 @@ export async function saveSharedProject(cwd, project) {
|
|
|
156
201
|
}, cwd);
|
|
157
202
|
await writeJsonAtomically(sharedFilePath(cwd), next);
|
|
158
203
|
await ensureRoundtable(cwd, next);
|
|
204
|
+
await syncRoundtableTaskSection(cwd, next);
|
|
159
205
|
return next;
|
|
160
206
|
}
|
|
161
207
|
|
|
@@ -247,6 +293,20 @@ function requireOwner(project, actorId = "") {
|
|
|
247
293
|
}
|
|
248
294
|
}
|
|
249
295
|
|
|
296
|
+
function requireEditor(project, actorId = "") {
|
|
297
|
+
requireSharedProject(project);
|
|
298
|
+
if (!memberHasAtLeastRole(project, actorId, "editor")) {
|
|
299
|
+
throw new Error("Only a shared-project owner or editor can do that.");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function requireMember(project, actorId = "") {
|
|
304
|
+
requireSharedProject(project);
|
|
305
|
+
if (!getSharedMember(project, actorId)) {
|
|
306
|
+
throw new Error("Only a shared-project member can do that.");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
250
310
|
export async function listSharedMembers(cwd) {
|
|
251
311
|
const project = await loadSharedProject(cwd);
|
|
252
312
|
requireSharedProject(project);
|
|
@@ -299,6 +359,56 @@ export async function removeSharedMember(cwd, memberId = "", options = {}) {
|
|
|
299
359
|
return next;
|
|
300
360
|
}
|
|
301
361
|
|
|
362
|
+
export async function listSharedTasks(cwd) {
|
|
363
|
+
const project = await loadSharedProject(cwd);
|
|
364
|
+
requireSharedProject(project);
|
|
365
|
+
return project.tasks || [];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function addSharedTask(cwd, text = "", options = {}) {
|
|
369
|
+
const existing = await loadSharedProject(cwd);
|
|
370
|
+
requireMember(existing, options.actorId);
|
|
371
|
+
const normalizedText = String(text || "").trim();
|
|
372
|
+
if (!normalizedText) throw new Error("task text is required");
|
|
373
|
+
const task = normalizeSharedTask({
|
|
374
|
+
text: normalizedText,
|
|
375
|
+
state: "open",
|
|
376
|
+
createdBy: String(options.actorId || "").trim()
|
|
377
|
+
});
|
|
378
|
+
const next = await saveSharedProject(cwd, {
|
|
379
|
+
...existing,
|
|
380
|
+
tasks: [...(existing.tasks || []), task]
|
|
381
|
+
});
|
|
382
|
+
await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: task added [${task.id}] ${task.text}`);
|
|
383
|
+
return { project: next, task };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function moveSharedTask(cwd, taskId = "", state = "open", options = {}) {
|
|
387
|
+
const existing = await loadSharedProject(cwd);
|
|
388
|
+
requireEditor(existing, options.actorId);
|
|
389
|
+
const normalizedId = String(taskId || "").trim();
|
|
390
|
+
const normalizedState = String(state || "").trim().toLowerCase();
|
|
391
|
+
if (!normalizedId) throw new Error("task id is required");
|
|
392
|
+
if (!TASK_STATES.includes(normalizedState)) {
|
|
393
|
+
throw new Error(`Invalid task state. Expected one of ${TASK_STATES.join(", ")}.`);
|
|
394
|
+
}
|
|
395
|
+
const tasks = [...(existing.tasks || [])];
|
|
396
|
+
const index = tasks.findIndex((task) => task.id === normalizedId);
|
|
397
|
+
if (index < 0) throw new Error(`No shared task found for ${normalizedId}`);
|
|
398
|
+
tasks[index] = {
|
|
399
|
+
...tasks[index],
|
|
400
|
+
state: normalizedState,
|
|
401
|
+
updatedAt: new Date().toISOString(),
|
|
402
|
+
assignedTo: normalizedState === "active" ? String(options.actorId || "").trim() : tasks[index].assignedTo
|
|
403
|
+
};
|
|
404
|
+
const next = await saveSharedProject(cwd, {
|
|
405
|
+
...existing,
|
|
406
|
+
tasks
|
|
407
|
+
});
|
|
408
|
+
await appendRoundtableEvent(cwd, `- ${new Date().toISOString()}: task moved [${tasks[index].id}] -> ${normalizedState}`);
|
|
409
|
+
return { project: next, task: tasks[index] };
|
|
410
|
+
}
|
|
411
|
+
|
|
302
412
|
export async function claimSharedOperator(cwd, operator = {}) {
|
|
303
413
|
const existing = await loadSharedProject(cwd);
|
|
304
414
|
requireSharedProject(existing);
|
|
@@ -361,7 +471,8 @@ export function formatSharedProjectStatus(project) {
|
|
|
361
471
|
roomMode: project.roomMode,
|
|
362
472
|
approvalPolicy: project.approvalPolicy,
|
|
363
473
|
activeOperator: project.activeOperator,
|
|
364
|
-
members: project.members
|
|
474
|
+
members: project.members,
|
|
475
|
+
tasks: project.tasks
|
|
365
476
|
}, null, 2);
|
|
366
477
|
}
|
|
367
478
|
|