@ai-setting/roy-task-plugin 0.4.2 → 0.5.0
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/dist/claude-plugin/.claude-plugin/plugin.json +19 -0
- package/dist/claude-plugin/.mcp.json +6 -0
- package/dist/claude-plugin/agents/roy.md +115 -0
- package/dist/claude-plugin/commands/task-complete.md +25 -0
- package/dist/claude-plugin/commands/task-create.md +19 -0
- package/dist/claude-plugin/commands/task-list.md +17 -0
- package/dist/claude-plugin/commands/task-milestone.md +39 -0
- package/dist/claude-plugin/commands/task-tree.md +20 -0
- package/dist/claude-plugin/commands/task-update.md +26 -0
- package/dist/claude-plugin/hooks/hooks.json +29 -0
- package/dist/claude-plugin/hooks/session-start.mjs +49 -0
- package/dist/claude-plugin/hooks/stop-check.mjs +222 -0
- package/dist/claude-plugin/mcp/bridge.mjs +482 -0
- package/dist/claude-plugin/mcp/roy-task-mcp-server.mjs +281 -0
- package/dist/claude-plugin/mcp/tools.mjs +240 -0
- package/dist/cli.js +484 -272
- package/package.json +3 -2
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge: MCP tool call -> direct SQLite access on roy-agent's task store.
|
|
3
|
+
*
|
|
4
|
+
* ## Why direct SQLite and not the `roy-agent tasks` CLI
|
|
5
|
+
*
|
|
6
|
+
* The CLI was the first idea and it works for most tools — but it **cannot
|
|
7
|
+
* create operation records**: `roy-agent tasks operations <id>` only *reads*
|
|
8
|
+
* them (measured: the subcommand's flags are `--type / --limit / --offset /
|
|
9
|
+
* --json`, there is no `--create`). Since `task_operation_create` is a
|
|
10
|
+
* first-class part of the task lifecycle (milestones are the state
|
|
11
|
+
* dimension of a task), the MCP server cannot go through the CLI alone.
|
|
12
|
+
*
|
|
13
|
+
* So the server talks to the store directly, and this is safe because:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Same database.** `~/.local/share/roy-agent/tasks.db` is the file the
|
|
16
|
+
* roy agent writes and the progress card renders. Writing anywhere else
|
|
17
|
+
* would make Claude's tasks invisible to the card.
|
|
18
|
+
* 2. **`node:sqlite`, not a native addon.** Node 22+ ships SQLite in core.
|
|
19
|
+
* No `better-sqlite3` compile step, no `bun:sqlite` — the same file works
|
|
20
|
+
* under plain node, which is the whole point of the published package.
|
|
21
|
+
* (Measured: `better-sqlite3@11.10.0`'s native module aborts the process
|
|
22
|
+
* under node 24 with `Assertion failed: (env) != nullptr`.)
|
|
23
|
+
* 3. **Schema is stable and read-only by default.** We open the database in
|
|
24
|
+
* read-write mode, but every statement is explicit and parameterised.
|
|
25
|
+
*
|
|
26
|
+
* ## Migration safety
|
|
27
|
+
*
|
|
28
|
+
* We do **not** run migrations. The `task` / `task_operation` tables are
|
|
29
|
+
* created and migrated by roy-agent itself. If a column this server needs is
|
|
30
|
+
* missing, we surface that as a tool error rather than guessing a default.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { DatabaseSync } from "node:sqlite";
|
|
34
|
+
import { mkdirSync } from "node:fs";
|
|
35
|
+
import { dirname, join } from "node:path";
|
|
36
|
+
import { homedir } from "node:os";
|
|
37
|
+
|
|
38
|
+
/** Raised when a tool call cannot be served. */
|
|
39
|
+
export class BridgeError extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "BridgeError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Where roy-agent keeps its task store (mirrors `getDefaultTaskDbPath`). */
|
|
47
|
+
export function defaultTaskDbPath() {
|
|
48
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
49
|
+
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share");
|
|
50
|
+
return join(base, "roy-agent", "tasks.db");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The columns `task` exposes, in the order the CLI's JSON output uses. */
|
|
54
|
+
function rowToTask(row) {
|
|
55
|
+
return {
|
|
56
|
+
id: row.id,
|
|
57
|
+
title: row.title,
|
|
58
|
+
description: row.description ?? "",
|
|
59
|
+
status: row.status,
|
|
60
|
+
priority: row.priority,
|
|
61
|
+
progress: row.progress ?? 0,
|
|
62
|
+
current_status: row.current_status ?? "",
|
|
63
|
+
goals_and_expected_deliverables: row.goals_and_expected_deliverables ?? "",
|
|
64
|
+
parent_task_id: row.parent_task_id ?? null,
|
|
65
|
+
createdAt: toIso(row.created_at),
|
|
66
|
+
updatedAt: toIso(row.updated_at),
|
|
67
|
+
due_date: row.due_date ?? null,
|
|
68
|
+
tags: parseTags(row.tags),
|
|
69
|
+
project_path: row.project_path ?? "",
|
|
70
|
+
context: row.context ?? "",
|
|
71
|
+
type: row.type ?? "normal",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function rowToOperation(row) {
|
|
76
|
+
return {
|
|
77
|
+
id: row.id,
|
|
78
|
+
task_id: row.task_id,
|
|
79
|
+
session_id: row.session_id ?? null,
|
|
80
|
+
milestone_type: row.milestone_type,
|
|
81
|
+
milestone_title: row.milestone_title,
|
|
82
|
+
milestone_description: row.milestone_description ?? "",
|
|
83
|
+
timestamp: toIso(row.timestamp),
|
|
84
|
+
md_path: row.md_path ?? null,
|
|
85
|
+
process_description: row.process_description ?? "",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toIso(value) {
|
|
90
|
+
if (value === null || value === undefined) return new Date().toISOString();
|
|
91
|
+
if (typeof value === "number") return new Date(value).toISOString();
|
|
92
|
+
const asNumber = Number(value);
|
|
93
|
+
if (Number.isFinite(asNumber) && String(value).trim() !== "") {
|
|
94
|
+
return new Date(asNumber).toISOString();
|
|
95
|
+
}
|
|
96
|
+
return String(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseTags(value) {
|
|
100
|
+
if (!value) return [];
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(value);
|
|
103
|
+
return Array.isArray(parsed) ? parsed.map(String) : [];
|
|
104
|
+
} catch {
|
|
105
|
+
return String(value)
|
|
106
|
+
.split(",")
|
|
107
|
+
.map((t) => t.trim())
|
|
108
|
+
.filter(Boolean);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function requireString(args, key) {
|
|
113
|
+
const value = args[key];
|
|
114
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
115
|
+
throw new BridgeError(`missing required string argument "${key}"`);
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function optionalString(args, key) {
|
|
121
|
+
const value = args[key];
|
|
122
|
+
if (value === undefined || value === null) return undefined;
|
|
123
|
+
if (typeof value !== "string") {
|
|
124
|
+
throw new BridgeError(`argument "${key}" must be a string`);
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function optionalNumber(args, key) {
|
|
130
|
+
const value = args[key];
|
|
131
|
+
if (value === undefined || value === null) return undefined;
|
|
132
|
+
const num = typeof value === "number" ? value : Number(value);
|
|
133
|
+
if (!Number.isFinite(num)) {
|
|
134
|
+
throw new BridgeError(`argument "${key}" must be a number`);
|
|
135
|
+
}
|
|
136
|
+
return num;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function optionalStringArray(args, key) {
|
|
140
|
+
const value = args[key];
|
|
141
|
+
if (value === undefined || value === null) return undefined;
|
|
142
|
+
if (!Array.isArray(value)) {
|
|
143
|
+
throw new BridgeError(`argument "${key}" must be an array of strings`);
|
|
144
|
+
}
|
|
145
|
+
return value.map((v) => String(v));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The subset of roy-agent's schema this server needs.
|
|
150
|
+
*
|
|
151
|
+
* We do not run roy-agent's migrations — that would duplicate a versioned
|
|
152
|
+
* migration chain we do not own. Instead we create the tables only when they
|
|
153
|
+
* are **entirely absent**, using the same column set the current roy-agent
|
|
154
|
+
* schema has. If the tables exist we touch nothing, so an existing store is
|
|
155
|
+
* never rewritten by this server.
|
|
156
|
+
*
|
|
157
|
+
* The `id INTEGER PRIMARY KEY AUTOINCREMENT` form matters: `task.id` is the
|
|
158
|
+
* handle every other API (the CLI, the progress card, the roy agent's own
|
|
159
|
+
* tools) uses, and it must keep incrementing across server restarts.
|
|
160
|
+
*/
|
|
161
|
+
const FALLBACK_SCHEMA = `
|
|
162
|
+
CREATE TABLE IF NOT EXISTS task (
|
|
163
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
164
|
+
title TEXT NOT NULL,
|
|
165
|
+
description TEXT,
|
|
166
|
+
status TEXT NOT NULL DEFAULT 'todo',
|
|
167
|
+
priority TEXT NOT NULL DEFAULT 'medium',
|
|
168
|
+
progress INTEGER NOT NULL DEFAULT 0,
|
|
169
|
+
current_status TEXT,
|
|
170
|
+
goals_and_expected_deliverables TEXT,
|
|
171
|
+
parent_task_id INTEGER,
|
|
172
|
+
created_at INTEGER,
|
|
173
|
+
updated_at INTEGER,
|
|
174
|
+
due_date TEXT,
|
|
175
|
+
tags TEXT,
|
|
176
|
+
project_path TEXT,
|
|
177
|
+
context TEXT,
|
|
178
|
+
type TEXT NOT NULL DEFAULT 'normal'
|
|
179
|
+
);
|
|
180
|
+
CREATE TABLE IF NOT EXISTS task_operation (
|
|
181
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
182
|
+
task_id INTEGER,
|
|
183
|
+
session_id TEXT,
|
|
184
|
+
milestone_type TEXT,
|
|
185
|
+
milestone_title TEXT,
|
|
186
|
+
milestone_description TEXT,
|
|
187
|
+
timestamp INTEGER,
|
|
188
|
+
md_path TEXT,
|
|
189
|
+
process_description TEXT
|
|
190
|
+
);
|
|
191
|
+
CREATE INDEX IF NOT EXISTS task_operation_task_idx ON task_operation(task_id);
|
|
192
|
+
`;
|
|
193
|
+
|
|
194
|
+
/** Open (and create if needed) the task store. */
|
|
195
|
+
export function openStore(dbPath = defaultTaskDbPath()) {
|
|
196
|
+
if (dbPath !== ":memory:") {
|
|
197
|
+
try {
|
|
198
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
199
|
+
} catch {
|
|
200
|
+
// best effort — the open below will produce the real error
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const db = new DatabaseSync(dbPath);
|
|
204
|
+
try {
|
|
205
|
+
db.exec(FALLBACK_SCHEMA);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
// An existing store whose schema differs is roy-agent's business, not
|
|
208
|
+
// ours. Surface the problem instead of silently corrupting it.
|
|
209
|
+
process.stderr.write(
|
|
210
|
+
`roy-task mcp server: schema check failed on ${dbPath}: ${err && err.message}\n`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return db;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Serve one tool call.
|
|
218
|
+
*
|
|
219
|
+
* Returns a JSON-serialisable value. Throws `BridgeError` for bad input and
|
|
220
|
+
* plain `Error` for store problems; the caller turns both into MCP errors.
|
|
221
|
+
*/
|
|
222
|
+
export function callTool(db, name, args = {}) {
|
|
223
|
+
switch (name) {
|
|
224
|
+
case "task_tree":
|
|
225
|
+
return taskTree(db, args);
|
|
226
|
+
case "task_create":
|
|
227
|
+
return taskCreate(db, args);
|
|
228
|
+
case "task_get":
|
|
229
|
+
return taskGet(db, args);
|
|
230
|
+
case "task_list":
|
|
231
|
+
return taskList(db, args);
|
|
232
|
+
case "task_update":
|
|
233
|
+
return taskUpdate(db, args);
|
|
234
|
+
case "task_operation_create":
|
|
235
|
+
return taskOperationCreate(db, args);
|
|
236
|
+
case "task_complete":
|
|
237
|
+
return taskComplete(db, args);
|
|
238
|
+
case "task_search":
|
|
239
|
+
return taskSearch(db, args);
|
|
240
|
+
default:
|
|
241
|
+
throw new BridgeError(`unknown tool "${name}"`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function taskTree(db, args) {
|
|
246
|
+
const depth = optionalNumber(args, "depth");
|
|
247
|
+
const rootId = optionalNumber(args, "parent_task_id");
|
|
248
|
+
const rows = db.prepare("SELECT * FROM task ORDER BY id ASC").all();
|
|
249
|
+
const tasks = rows.map(rowToTask);
|
|
250
|
+
const byId = new Map(tasks.map((t) => [t.id, { ...t, children: [] }]));
|
|
251
|
+
const roots = [];
|
|
252
|
+
for (const node of byId.values()) {
|
|
253
|
+
const parent = node.parent_task_id ? byId.get(node.parent_task_id) : undefined;
|
|
254
|
+
if (parent) parent.children.push(node);
|
|
255
|
+
else roots.push(node);
|
|
256
|
+
}
|
|
257
|
+
const scope = rootId !== undefined ? byId.get(rootId) : undefined;
|
|
258
|
+
const selected = scope ? [scope] : roots;
|
|
259
|
+
const limited =
|
|
260
|
+
depth === undefined || depth < 0 ? selected : pruneDepth(selected, depth);
|
|
261
|
+
return { total: tasks.length, roots: limited.length, tasks: limited };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function pruneDepth(nodes, depth) {
|
|
265
|
+
if (depth <= 0) return nodes.map((n) => ({ ...n, children: [] }));
|
|
266
|
+
return nodes.map((n) => ({ ...n, children: pruneDepth(n.children, depth - 1) }));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function taskCreate(db, args) {
|
|
270
|
+
const title = requireString(args, "title");
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
const description = optionalString(args, "description") ?? "";
|
|
273
|
+
const goals = optionalString(args, "goals_and_expected_deliverables") ?? "";
|
|
274
|
+
const priority = optionalString(args, "priority") ?? "medium";
|
|
275
|
+
const type = optionalString(args, "type") ?? "normal";
|
|
276
|
+
const parentId = optionalNumber(args, "parent_task_id") ?? null;
|
|
277
|
+
const projectPath = optionalString(args, "project_path") ?? "";
|
|
278
|
+
const context = optionalString(args, "context") ?? "";
|
|
279
|
+
const tags = optionalStringArray(args, "tags") ?? [];
|
|
280
|
+
|
|
281
|
+
const info = db
|
|
282
|
+
.prepare(
|
|
283
|
+
`INSERT INTO task
|
|
284
|
+
(title, description, status, priority, progress, current_status,
|
|
285
|
+
goals_and_expected_deliverables, parent_task_id, created_at, updated_at,
|
|
286
|
+
tags, project_path, context, type)
|
|
287
|
+
VALUES (?, ?, 'todo', ?, 0, '', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
288
|
+
)
|
|
289
|
+
.run(
|
|
290
|
+
title,
|
|
291
|
+
description,
|
|
292
|
+
priority,
|
|
293
|
+
goals,
|
|
294
|
+
parentId,
|
|
295
|
+
now,
|
|
296
|
+
now,
|
|
297
|
+
JSON.stringify(tags),
|
|
298
|
+
projectPath,
|
|
299
|
+
context,
|
|
300
|
+
type,
|
|
301
|
+
);
|
|
302
|
+
const id = Number(info.lastInsertRowid);
|
|
303
|
+
const created = db.prepare("SELECT * FROM task WHERE id = ?").get(id);
|
|
304
|
+
return rowToTask(created);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function taskGet(db, args) {
|
|
308
|
+
const id = optionalNumber(args, "id");
|
|
309
|
+
if (id === undefined) throw new BridgeError('missing required number argument "id"');
|
|
310
|
+
const row = db.prepare("SELECT * FROM task WHERE id = ?").get(id);
|
|
311
|
+
if (!row) throw new BridgeError(`task ${id} not found`);
|
|
312
|
+
const task = rowToTask(row);
|
|
313
|
+
const ops = db
|
|
314
|
+
.prepare("SELECT * FROM task_operation WHERE task_id = ? ORDER BY id ASC")
|
|
315
|
+
.all(id)
|
|
316
|
+
.map(rowToOperation);
|
|
317
|
+
return { ...task, operations: ops };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function taskList(db, args) {
|
|
321
|
+
const clauses = [];
|
|
322
|
+
const params = [];
|
|
323
|
+
const status = optionalString(args, "status");
|
|
324
|
+
if (status) {
|
|
325
|
+
clauses.push("status = ?");
|
|
326
|
+
params.push(status);
|
|
327
|
+
}
|
|
328
|
+
const priority = optionalString(args, "priority");
|
|
329
|
+
if (priority) {
|
|
330
|
+
clauses.push("priority = ?");
|
|
331
|
+
params.push(priority);
|
|
332
|
+
}
|
|
333
|
+
const type = optionalString(args, "type");
|
|
334
|
+
if (type) {
|
|
335
|
+
clauses.push("type = ?");
|
|
336
|
+
params.push(type);
|
|
337
|
+
}
|
|
338
|
+
const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
339
|
+
const total = db.prepare(`SELECT COUNT(*) AS c FROM task${where}`).get(...params).c;
|
|
340
|
+
const limit = optionalNumber(args, "limit") ?? 20;
|
|
341
|
+
const offset = optionalNumber(args, "offset") ?? 0;
|
|
342
|
+
const rows = db
|
|
343
|
+
.prepare(`SELECT * FROM task${where} ORDER BY id DESC LIMIT ? OFFSET ?`)
|
|
344
|
+
.all(...params, limit, offset);
|
|
345
|
+
return {
|
|
346
|
+
total,
|
|
347
|
+
count: rows.length,
|
|
348
|
+
truncated: total > rows.length,
|
|
349
|
+
limit,
|
|
350
|
+
offset,
|
|
351
|
+
tasks: rows.map(rowToTask),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function taskUpdate(db, args) {
|
|
356
|
+
const id = optionalNumber(args, "id");
|
|
357
|
+
if (id === undefined) throw new BridgeError('missing required number argument "id"');
|
|
358
|
+
const existing = db.prepare("SELECT * FROM task WHERE id = ?").get(id);
|
|
359
|
+
if (!existing) throw new BridgeError(`task ${id} not found`);
|
|
360
|
+
|
|
361
|
+
const sets = [];
|
|
362
|
+
const params = [];
|
|
363
|
+
const push = (col, value) => {
|
|
364
|
+
sets.push(`${col} = ?`);
|
|
365
|
+
params.push(value);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const status = optionalString(args, "status");
|
|
369
|
+
if (status !== undefined) push("status", status);
|
|
370
|
+
const progress = optionalNumber(args, "progress");
|
|
371
|
+
if (progress !== undefined) push("progress", progress);
|
|
372
|
+
const currentStatus = optionalString(args, "current_status");
|
|
373
|
+
if (currentStatus !== undefined) push("current_status", currentStatus);
|
|
374
|
+
const description = optionalString(args, "description");
|
|
375
|
+
if (description !== undefined) push("description", description);
|
|
376
|
+
const goals = optionalString(args, "goals_and_expected_deliverables");
|
|
377
|
+
if (goals !== undefined) push("goals_and_expected_deliverables", goals);
|
|
378
|
+
const priority = optionalString(args, "priority");
|
|
379
|
+
if (priority !== undefined) push("priority", priority);
|
|
380
|
+
const type = optionalString(args, "type");
|
|
381
|
+
if (type !== undefined) push("type", type);
|
|
382
|
+
push("updated_at", Date.now());
|
|
383
|
+
|
|
384
|
+
params.push(id);
|
|
385
|
+
db.prepare(`UPDATE task SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
386
|
+
return rowToTask(db.prepare("SELECT * FROM task WHERE id = ?").get(id));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function taskOperationCreate(db, args) {
|
|
390
|
+
const taskId = optionalNumber(args, "task_id");
|
|
391
|
+
if (taskId === undefined) {
|
|
392
|
+
throw new BridgeError('missing required number argument "task_id"');
|
|
393
|
+
}
|
|
394
|
+
const exists = db.prepare("SELECT id FROM task WHERE id = ?").get(taskId);
|
|
395
|
+
if (!exists) throw new BridgeError(`task ${taskId} not found`);
|
|
396
|
+
|
|
397
|
+
const milestoneType = requireString(args, "milestone_type");
|
|
398
|
+
const milestoneTitle = requireString(args, "milestone_title");
|
|
399
|
+
const milestoneDescription = optionalString(args, "milestone_description") ?? "";
|
|
400
|
+
const processDescription = optionalString(args, "process_description") ?? null;
|
|
401
|
+
const sessionId = optionalString(args, "session_id") ?? null;
|
|
402
|
+
|
|
403
|
+
// The 100-150 char rule is roy-agent's, not ours — but the CLI validates it
|
|
404
|
+
// on the way in, so the MCP tool has to as well or the two paths diverge.
|
|
405
|
+
if (processDescription !== null) {
|
|
406
|
+
const len = processDescription.length;
|
|
407
|
+
if (len > 0 && (len < 100 || len > 150)) {
|
|
408
|
+
throw new BridgeError(
|
|
409
|
+
`process_description must be 100-150 characters (got ${len})`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const info = db
|
|
415
|
+
.prepare(
|
|
416
|
+
`INSERT INTO task_operation
|
|
417
|
+
(task_id, session_id, milestone_type, milestone_title,
|
|
418
|
+
milestone_description, timestamp, md_path, process_description)
|
|
419
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)`,
|
|
420
|
+
)
|
|
421
|
+
.run(
|
|
422
|
+
taskId,
|
|
423
|
+
sessionId,
|
|
424
|
+
milestoneType,
|
|
425
|
+
milestoneTitle,
|
|
426
|
+
milestoneDescription,
|
|
427
|
+
Date.now(),
|
|
428
|
+
processDescription,
|
|
429
|
+
);
|
|
430
|
+
const row = db
|
|
431
|
+
.prepare("SELECT * FROM task_operation WHERE id = ?")
|
|
432
|
+
.get(Number(info.lastInsertRowid));
|
|
433
|
+
return rowToOperation(row);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function taskComplete(db, args) {
|
|
437
|
+
const id = optionalNumber(args, "id");
|
|
438
|
+
if (id === undefined) throw new BridgeError('missing required number argument "id"');
|
|
439
|
+
const existing = db.prepare("SELECT * FROM task WHERE id = ?").get(id);
|
|
440
|
+
if (!existing) throw new BridgeError(`task ${id} not found`);
|
|
441
|
+
|
|
442
|
+
const deliverable = optionalString(args, "deliverable_storage_path");
|
|
443
|
+
const now = Date.now();
|
|
444
|
+
const sets = ["status = ?", "progress = 100", "updated_at = ?"];
|
|
445
|
+
const params = ["completed", now];
|
|
446
|
+
if (deliverable) {
|
|
447
|
+
// Same convention roy-agent uses: the deliverable lives in the task's
|
|
448
|
+
// context JSON under `deliverable_storage_path`.
|
|
449
|
+
let context = {};
|
|
450
|
+
try {
|
|
451
|
+
context = existing.context ? JSON.parse(existing.context) : {};
|
|
452
|
+
} catch {
|
|
453
|
+
context = {};
|
|
454
|
+
}
|
|
455
|
+
context.deliverable_storage_path = deliverable;
|
|
456
|
+
sets.push("context = ?");
|
|
457
|
+
params.push(JSON.stringify(context));
|
|
458
|
+
}
|
|
459
|
+
params.push(id);
|
|
460
|
+
db.prepare(`UPDATE task SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
461
|
+
return rowToTask(db.prepare("SELECT * FROM task WHERE id = ?").get(id));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function taskSearch(db, args) {
|
|
465
|
+
const keywords = optionalStringArray(args, "keywords");
|
|
466
|
+
if (!keywords || keywords.length === 0) {
|
|
467
|
+
throw new BridgeError('missing required array argument "keywords"');
|
|
468
|
+
}
|
|
469
|
+
const limit = optionalNumber(args, "limit") ?? 20;
|
|
470
|
+
const where = keywords
|
|
471
|
+
.map(() => "(title LIKE ? OR description LIKE ? OR goals_and_expected_deliverables LIKE ? OR tags LIKE ?)")
|
|
472
|
+
.join(" AND ");
|
|
473
|
+
const params = [];
|
|
474
|
+
for (const kw of keywords) {
|
|
475
|
+
const like = `%${kw}%`;
|
|
476
|
+
params.push(like, like, like, like);
|
|
477
|
+
}
|
|
478
|
+
const rows = db
|
|
479
|
+
.prepare(`SELECT * FROM task WHERE ${where} ORDER BY id DESC LIMIT ?`)
|
|
480
|
+
.all(...params, limit);
|
|
481
|
+
return { total: rows.length, count: rows.length, tasks: rows.map(rowToTask) };
|
|
482
|
+
}
|