@ai-setting/roy-task-plugin 0.7.2 → 0.7.4
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/agents/roy.md +21 -16
- package/dist/claude-plugin/hooks/stop-check.mjs +7 -1
- package/dist/claude-plugin/mcp/bridge.mjs +30 -35
- package/dist/claude-plugin/mcp/roy-task-mcp-server.mjs +31 -7
- package/dist/claude-plugin/mcp/task-notify-hooks.mjs +303 -0
- package/dist/cli.js +1114 -368
- package/dist/index.js +184 -184
- package/dist/index.js.map +20 -19
- package/package.json +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: roy
|
|
3
3
|
description: Roy — task-as-first-class-citizen agent. Uses the roy-agent task lifecycle (task_tree → task_create → task_update → task_operation_create → task_complete) so every piece of work is tracked in the local task store.
|
|
4
|
-
tools: Read, Write, Edit, Bash, Grep, Glob, TodoWrite, WebFetch, WebSearch
|
|
4
|
+
tools: Read, Write, Edit, Bash, Grep, Glob, TodoWrite, WebFetch, WebSearch, mcp__roy-task__task_create, mcp__roy-task__task_update, mcp__roy-task__task_operation_create, mcp__roy-task__task_complete, mcp__roy-task__task_get, mcp__roy-task__task_list, mcp__roy-task__task_tree, mcp__roy-task__task_search
|
|
5
5
|
model: inherit
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -89,23 +89,28 @@ if it is not in the task store, it is not tracked.
|
|
|
89
89
|
|
|
90
90
|
## Tool surface on Claude Code
|
|
91
91
|
|
|
92
|
-
The
|
|
93
|
-
|
|
94
|
-
natively. The mapping is 1:1 — same store, same ids, same semantics:
|
|
92
|
+
The `task_*` tools are served by the **`roy-task` MCP server** this runtime
|
|
93
|
+
loads. Call them directly:
|
|
95
94
|
|
|
96
|
-
| lifecycle step |
|
|
95
|
+
| lifecycle step | tool |
|
|
97
96
|
|---|---|
|
|
98
|
-
| `task_tree(depth=1)` | `
|
|
99
|
-
| `task_create` | `
|
|
100
|
-
| `task_update` | `
|
|
101
|
-
| `task_operation_create` | `
|
|
102
|
-
| `task_complete` | `
|
|
103
|
-
| `task_get` | `
|
|
104
|
-
| `task_list` | `
|
|
105
|
-
| `task_search` | `
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
97
|
+
| `task_tree(depth=1)` | `mcp__roy-task__task_tree` |
|
|
98
|
+
| `task_create` | `mcp__roy-task__task_create` |
|
|
99
|
+
| `task_update` | `mcp__roy-task__task_update` |
|
|
100
|
+
| `task_operation_create` | `mcp__roy-task__task_operation_create` |
|
|
101
|
+
| `task_complete` | `mcp__roy-task__task_complete` |
|
|
102
|
+
| `task_get` | `mcp__roy-task__task_get` |
|
|
103
|
+
| `task_list` | `mcp__roy-task__task_list` |
|
|
104
|
+
| `task_search` | `mcp__roy-task__task_search` |
|
|
105
|
+
|
|
106
|
+
Same store, same ids, same semantics as the opencode tools — the daemon's
|
|
107
|
+
progress card renders the task these tools create.
|
|
108
|
+
|
|
109
|
+
⚠️ **Do not** shell out to the `roy-agent tasks` CLI as a substitute.
|
|
110
|
+
Measured: it has no way to write a milestone (its `operations <id>` subcommand
|
|
111
|
+
is a *reader*), and it targets a different database than this server. Use the
|
|
112
|
+
MCP tools above; if they are genuinely unavailable, say so in your answer
|
|
113
|
+
instead of silently skipping the lifecycle.
|
|
109
114
|
|
|
110
115
|
## Style
|
|
111
116
|
|
|
@@ -176,7 +176,13 @@ async function main() {
|
|
|
176
176
|
|
|
177
177
|
let backend;
|
|
178
178
|
try {
|
|
179
|
-
|
|
179
|
+
// Task #4692: createServer is async (it loads core's hook modules before
|
|
180
|
+
// the first request can fire them). Without the await, `server` is a
|
|
181
|
+
// Promise and `server.backend` is undefined — the open-task check would
|
|
182
|
+
// silently degrade to "approve" and the Stop discipline would die.
|
|
183
|
+
const server = await createServer(
|
|
184
|
+
process.env.ROY_TASK_DB_PATH || defaultTaskDbPath(),
|
|
185
|
+
);
|
|
180
186
|
// The server owns the store; the backend is what the hook needs.
|
|
181
187
|
backend = server.backend;
|
|
182
188
|
} catch (err) {
|
|
@@ -1,38 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bridge: roy's task store + roy's MCP server, wired together.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* A thin adapter (Task #4619): store location + binding to roy's
|
|
5
|
+
* semantics + transport. Every semantic decision lives in core, once.
|
|
6
|
+
* (Before #4619 this was 482 lines of raw SQL re-implementing roy.)
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
* `taskUpdate`, `taskOperationCreate`, `taskComplete`, `taskSearch` — with
|
|
11
|
-
* **zero** imports from `@ai-setting/roy-agent-core`.
|
|
8
|
+
* Task #4692 adds a fourth, opt-in responsibility: firing core's
|
|
9
|
+
* `LarkCliTaskNotifyHook` around each tool call (see
|
|
10
|
+
* `task-notify-hooks.mjs`) so `task_create` opens a Feishu card,
|
|
11
|
+
* `task_update` patches it, `task_complete` closes it.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
* 869-line `facade.ts`), except it hid inside a `.mjs` file, so the reviews
|
|
15
|
-
* that counted TypeScript lines never saw it.
|
|
16
|
-
*
|
|
17
|
-
* ## What it is now
|
|
18
|
-
*
|
|
19
|
-
* A thin adapter with exactly three responsibilities:
|
|
20
|
-
*
|
|
21
|
-
* 1. **where** the store lives (roy-agent's own `tasks.db`, not the
|
|
22
|
-
* plugin's private one — otherwise Claude would create tasks the
|
|
23
|
-
* progress card can never see);
|
|
24
|
-
* 2. **binding** that store to roy's semantics (`TaskStoreBackend` +
|
|
25
|
-
* `createSQLiteTaskPort`, both from core);
|
|
26
|
-
* 3. **transport** — handing core's MCP server the bytes.
|
|
27
|
-
*
|
|
28
|
-
* Every semantic decision (tree shape, depth, `total`, deliverable
|
|
29
|
-
* merging, milestone filtering, search filters) lives in core, once.
|
|
30
|
-
*
|
|
31
|
-
* ## Runtime
|
|
32
|
-
*
|
|
33
|
-
* Plain node (`#!/usr/bin/env node` in the server entry) — hence core's
|
|
34
|
-
* `node:sqlite` support (no native `better-sqlite3` needed) and core's
|
|
35
|
-
* `createCompatibleDatabase` compat shim.
|
|
13
|
+
* Plain node runtime — core's `node:sqlite` (no native better-sqlite3).
|
|
36
14
|
*/
|
|
37
15
|
|
|
38
16
|
import { createTaskMcpServer } from "@ai-setting/roy-agent-core/env/task/mcp";
|
|
@@ -43,6 +21,7 @@ import {
|
|
|
43
21
|
import { SQLiteTaskStore } from "@ai-setting/roy-agent-core/env/task/storage";
|
|
44
22
|
import { homedir } from "node:os";
|
|
45
23
|
import { join } from "node:path";
|
|
24
|
+
import { createNotifyDispatcher } from "./task-notify-hooks.mjs";
|
|
46
25
|
|
|
47
26
|
/** Raised when a tool call cannot be served. */
|
|
48
27
|
export class BridgeError extends Error {
|
|
@@ -62,11 +41,15 @@ export function defaultTaskDbPath() {
|
|
|
62
41
|
/**
|
|
63
42
|
* Build the MCP server over roy's own task store.
|
|
64
43
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
44
|
+
* Task #4692: when `ROY_TASK_NOTIFY_ENABLED=1`, the same core hook the
|
|
45
|
+
* act pipeline uses is registered on `globalHookManager`, and every tool
|
|
46
|
+
* call runs inside the injected EnvContext — so `task_create` now opens
|
|
47
|
+
* a card in Feishu, `task_update` patches it, `task_complete` closes it.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} dbPath task store path
|
|
50
|
+
* @param {{ cardNotifier?: object }} deps test seams (fake notifier)
|
|
68
51
|
*/
|
|
69
|
-
export function createServer(dbPath = defaultTaskDbPath()) {
|
|
52
|
+
export async function createServer(dbPath = defaultTaskDbPath(), deps = {}) {
|
|
70
53
|
const store = new SQLiteTaskStore(dbPath);
|
|
71
54
|
const backend = new TaskStoreBackend(createSQLiteTaskPort(store));
|
|
72
55
|
const server = createTaskMcpServer(backend, {
|
|
@@ -74,10 +57,20 @@ export function createServer(dbPath = defaultTaskDbPath()) {
|
|
|
74
57
|
version: "1.0.0",
|
|
75
58
|
sessionId: process.env.ROY_TASK_SESSION_ID || "claude-mcp",
|
|
76
59
|
});
|
|
60
|
+
|
|
61
|
+
// Task #4692: fire the task:after.* hooks around every tool call. The
|
|
62
|
+
// wiring lives in task-notify-hooks.mjs; core owns the hook itself.
|
|
63
|
+
const notify = await createNotifyDispatcher({ backend, deps });
|
|
64
|
+
const callToolWithHooks = async (name, args = {}) => {
|
|
65
|
+
const result = await server.callTool(name, args);
|
|
66
|
+
await notify.fire(name, args, result);
|
|
67
|
+
return result;
|
|
68
|
+
};
|
|
69
|
+
|
|
77
70
|
// The hooks (stop-check) need the *backend*, not the MCP surface: they
|
|
78
71
|
// ask "is there an open task?" — a task-semantics question, answered by
|
|
79
72
|
// core's backend, not by the JSON-RPC layer.
|
|
80
|
-
return { ...server, backend, store };
|
|
73
|
+
return { ...server, backend, store, callTool: callToolWithHooks };
|
|
81
74
|
}
|
|
82
75
|
|
|
83
76
|
/**
|
|
@@ -93,6 +86,8 @@ export async function callTool(server, name, args = {}) {
|
|
|
93
86
|
throw new BridgeError("tools/call requires a tool name");
|
|
94
87
|
}
|
|
95
88
|
try {
|
|
89
|
+
// Task #4692: `createServer` wraps callTool with the hook firing;
|
|
90
|
+
// a bare core server (no wrapper) still works.
|
|
96
91
|
return await server.callTool(name, args);
|
|
97
92
|
} catch (err) {
|
|
98
93
|
if (err instanceof BridgeError) throw err;
|
|
@@ -83,7 +83,11 @@ setStdoutStrictMode(true);
|
|
|
83
83
|
* the same server answered perfectly over a hand-rolled pipe.
|
|
84
84
|
*/
|
|
85
85
|
function send(message) {
|
|
86
|
-
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
const ok = process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
|
|
88
|
+
if (ok) resolve();
|
|
89
|
+
else process.stdout.once("drain", resolve);
|
|
90
|
+
});
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
function sendResult(id, result) {
|
|
@@ -246,11 +250,14 @@ const SERVER_INFO = { name: SERVER_NAME, version: SERVER_VERSION };
|
|
|
246
250
|
const SERVER_CAPABILITIES = { tools: { listChanged: false } };
|
|
247
251
|
|
|
248
252
|
/** Read newline-delimited JSON-RPC from stdin and answer on stdout. */
|
|
249
|
-
export function main() {
|
|
253
|
+
export async function main() {
|
|
254
|
+
const pending = new Set();
|
|
250
255
|
const dbPath = process.env.ROY_TASK_DB_PATH || defaultTaskDbPath();
|
|
251
256
|
let server;
|
|
252
257
|
try {
|
|
253
|
-
|
|
258
|
+
// Task #4692: createServer is async (it loads core's hook modules
|
|
259
|
+
// before the first request can fire them).
|
|
260
|
+
server = await createServer(dbPath);
|
|
254
261
|
} catch (err) {
|
|
255
262
|
process.stderr.write(
|
|
256
263
|
`roy-task mcp server: cannot open task store at ${dbPath}: ${err && err.message}\n`,
|
|
@@ -277,9 +284,9 @@ export function main() {
|
|
|
277
284
|
return;
|
|
278
285
|
}
|
|
279
286
|
|
|
280
|
-
Promise.resolve(handleRequest(request, server))
|
|
281
|
-
.then((response) => {
|
|
282
|
-
if (response) send(response);
|
|
287
|
+
const done = Promise.resolve(handleRequest(request, server))
|
|
288
|
+
.then(async (response) => {
|
|
289
|
+
if (response) await send(response);
|
|
283
290
|
})
|
|
284
291
|
.catch((err) => {
|
|
285
292
|
sendError(
|
|
@@ -288,9 +295,26 @@ export function main() {
|
|
|
288
295
|
err && err.message ? err.message : String(err),
|
|
289
296
|
);
|
|
290
297
|
});
|
|
298
|
+
pending.add(done);
|
|
299
|
+
done.finally(() => pending.delete(done));
|
|
291
300
|
});
|
|
292
301
|
|
|
293
|
-
|
|
302
|
+
// Task #4666: stdin EOF must not race the last response out.
|
|
303
|
+
//
|
|
304
|
+
// `process.exit(0)` from a `close` handler runs while big tool
|
|
305
|
+
// responses (a 45 KB `task_list` over the real DB) are still in
|
|
306
|
+
// node's stdout buffer — the client then sees a truncated JSON
|
|
307
|
+
// line (measured: `Unterminated string ... at position 50539`).
|
|
308
|
+
// Wait for every in-flight request to finish writing *and* for
|
|
309
|
+
// stdout to drain before exiting.
|
|
310
|
+
rl.on("close", async () => {
|
|
311
|
+
while (pending.size > 0) {
|
|
312
|
+
await Promise.all([...pending]);
|
|
313
|
+
}
|
|
314
|
+
await new Promise((resolve) => {
|
|
315
|
+
if (process.stdout.writableLength === 0) resolve();
|
|
316
|
+
else process.stdout.once("drain", resolve);
|
|
317
|
+
});
|
|
294
318
|
process.exit(0);
|
|
295
319
|
});
|
|
296
320
|
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task #4692 — the MCP server's task:after.* hook wiring.
|
|
3
|
+
*
|
|
4
|
+
* ## What this file is
|
|
5
|
+
*
|
|
6
|
+
* The "install core's Feishu card hooks into the MCP server
|
|
7
|
+
* process" half of the bridge. Kept in its own module so
|
|
8
|
+
* `bridge.mjs` stays a thin adapter (the < 140-line guard in
|
|
9
|
+
* `claude-bridge-thin.test.ts` pins that).
|
|
10
|
+
*
|
|
11
|
+
* Everything semantic lives in core: the hook
|
|
12
|
+
* (`LarkCliTaskNotifyHook`), the card renderer
|
|
13
|
+
* (`LarkCardPatchNotifier`), the card state store, the hook
|
|
14
|
+
* manager and the ALS EnvContext. This module only wires them —
|
|
15
|
+
* the user's architecture: "飞书插件机制就在roy-agent上,mcp server
|
|
16
|
+
* 就复用就好呀".
|
|
17
|
+
*
|
|
18
|
+
* ## The three pieces
|
|
19
|
+
*
|
|
20
|
+
* 1. **opt-in** — `notifyEnabled()`: off unless
|
|
21
|
+
* `ROY_TASK_NOTIFY_ENABLED=1` (existing deployments see zero
|
|
22
|
+
* change);
|
|
23
|
+
* 2. **destination** — `buildNotifyEnvContext()`: the hook reads
|
|
24
|
+
* the card's chat from the ALS EnvContext
|
|
25
|
+
* (`replyChannel.chatId/profile`), which is empty inside the
|
|
26
|
+
* MCP server process, so it is injected from
|
|
27
|
+
* `ROY_TASK_NOTIFY_CHAT_ID` / `ROY_TASK_NOTIFY_PROFILE` (env,
|
|
28
|
+
* because the server inherits the daemon's env — no argv
|
|
29
|
+
* changes, no TOML escaping);
|
|
30
|
+
* 3. **firing** — `hookPointsForTool()` + `buildHookData()`:
|
|
31
|
+
* map a completed tool call to the hook point(s) it implies,
|
|
32
|
+
* with context data shaped exactly like `TaskComponent`'s
|
|
33
|
+
* (the hook reads `task` / `changes` / `operation`).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Should the MCP server install the Feishu card hooks?
|
|
38
|
+
*/
|
|
39
|
+
export function notifyEnabled() {
|
|
40
|
+
const v = process.env.ROY_TASK_NOTIFY_ENABLED;
|
|
41
|
+
return v === "1" || v === "true";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The EnvContext the hooks read for the card's destination.
|
|
46
|
+
*
|
|
47
|
+
* When no chat id is configured the context carries no
|
|
48
|
+
* replyChannel and the hook falls back to the task's own
|
|
49
|
+
* per-task group (same rule as the act pipeline).
|
|
50
|
+
*/
|
|
51
|
+
export function buildNotifyEnvContext(contextMod) {
|
|
52
|
+
const chatId = process.env.ROY_TASK_NOTIFY_CHAT_ID;
|
|
53
|
+
const profile = process.env.ROY_TASK_NOTIFY_PROFILE;
|
|
54
|
+
return contextMod.createEnvContext({
|
|
55
|
+
envEvent: { source: "lark", type: "mcp-server" },
|
|
56
|
+
sourceId: "roy-task-mcp",
|
|
57
|
+
userId: "mcp-agent",
|
|
58
|
+
...(chatId ? { replyChannel: { type: "lark", chatId, profile } } : {}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Map a tool call to the hook point(s) it semantically completed.
|
|
64
|
+
*/
|
|
65
|
+
export function hookPointsForTool(name) {
|
|
66
|
+
switch (name) {
|
|
67
|
+
case "task_create":
|
|
68
|
+
return ["task:after.create"];
|
|
69
|
+
case "task_update":
|
|
70
|
+
return ["task:after.update"];
|
|
71
|
+
case "task_complete":
|
|
72
|
+
return ["task:after.complete"];
|
|
73
|
+
case "task_operation_create":
|
|
74
|
+
return ["task:operation:after.create"];
|
|
75
|
+
default:
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Install the core `LarkCliTaskNotifyHook` on the global hook manager.
|
|
82
|
+
*
|
|
83
|
+
* @returns the installed hook, or null when notify is disabled.
|
|
84
|
+
*/
|
|
85
|
+
export async function installTaskNotifyHooks({ hookMod, pluginsMod, cardNotifier }) {
|
|
86
|
+
if (!notifyEnabled()) return null;
|
|
87
|
+
const hook = pluginsMod.createLarkCliTaskNotifyHook({
|
|
88
|
+
...(cardNotifier ? { cardNotifier } : {}),
|
|
89
|
+
});
|
|
90
|
+
for (const hp of [
|
|
91
|
+
"task:after.create",
|
|
92
|
+
"task:after.update",
|
|
93
|
+
"task:after.complete",
|
|
94
|
+
"task:operation:after.create",
|
|
95
|
+
]) {
|
|
96
|
+
// Idempotent: a second createServer() in the same process must not
|
|
97
|
+
// stack another copy of the hook (DS CV MINOR-5). The hook manager
|
|
98
|
+
// keys hooks by name, so removing the previous one first keeps the
|
|
99
|
+
// registration count at exactly 1.
|
|
100
|
+
hookMod.globalHookManager.unregister(hp, hook.name);
|
|
101
|
+
hookMod.globalHookManager.register(hp, hook);
|
|
102
|
+
}
|
|
103
|
+
return hook;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* How long the card hooks may take before the tool call stops waiting.
|
|
108
|
+
*
|
|
109
|
+
* The same deadline the daemon's own card port uses (15s): a stuck
|
|
110
|
+
* lark-cli (network hang, profile permission prompt) must never make an
|
|
111
|
+
* MCP tool call hang forever — the agent would see "the tool never
|
|
112
|
+
* returns" (DS CV MAJOR-4).
|
|
113
|
+
*/
|
|
114
|
+
const HOOK_DEADLINE_MS = 15_000;
|
|
115
|
+
|
|
116
|
+
/** Resolve `promise`, or give up after `ms` (the caller swallows the rejection). */
|
|
117
|
+
function withDeadline(promise, ms) {
|
|
118
|
+
return Promise.race([
|
|
119
|
+
promise,
|
|
120
|
+
new Promise((_resolve, reject) => {
|
|
121
|
+
const timer = setTimeout(
|
|
122
|
+
() => reject(new Error(`hook deadline exceeded (${ms}ms)`)),
|
|
123
|
+
ms,
|
|
124
|
+
);
|
|
125
|
+
// Never keep the process alive for the timer.
|
|
126
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
127
|
+
}),
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the notifier that fires the task:after.* hooks around tool calls.
|
|
133
|
+
*
|
|
134
|
+
* Returns a no-op dispatcher when the notify env is unset, so the caller
|
|
135
|
+
* can always use one code path.
|
|
136
|
+
*
|
|
137
|
+
* @param {{ backend: object, deps: { cardNotifier?: object } }} input
|
|
138
|
+
*/
|
|
139
|
+
export async function createNotifyDispatcher({ backend, deps = {} }) {
|
|
140
|
+
if (!notifyEnabled()) {
|
|
141
|
+
return { fire: async () => {} };
|
|
142
|
+
}
|
|
143
|
+
const [hookMod, pluginsMod, contextMod] = await Promise.all([
|
|
144
|
+
import("@ai-setting/roy-agent-core/env/hook"),
|
|
145
|
+
import("@ai-setting/roy-agent-core/env/task/plugins"),
|
|
146
|
+
import("@ai-setting/roy-agent-core/env/context"),
|
|
147
|
+
]);
|
|
148
|
+
const notifyHook = await installTaskNotifyHooks({
|
|
149
|
+
hookMod,
|
|
150
|
+
pluginsMod,
|
|
151
|
+
...(deps.cardNotifier ? { cardNotifier: deps.cardNotifier } : {}),
|
|
152
|
+
});
|
|
153
|
+
const envCtx = buildNotifyEnvContext(contextMod);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read the task the tool just touched, so the card shows the REAL
|
|
157
|
+
* fields (priority / type / project_path / goals) instead of values
|
|
158
|
+
* guessed from the call arguments (DS CV MAJOR-3).
|
|
159
|
+
*/
|
|
160
|
+
const readTask = async (name, args, result) => {
|
|
161
|
+
const id =
|
|
162
|
+
name === "task_create"
|
|
163
|
+
? taskIdFromResult(result)
|
|
164
|
+
: typeof args.id === "number"
|
|
165
|
+
? args.id
|
|
166
|
+
: Number(args.task_id) || taskIdFromResult(result);
|
|
167
|
+
if (!id) return undefined;
|
|
168
|
+
try {
|
|
169
|
+
return (await backend.getTask(id)) ?? undefined;
|
|
170
|
+
} catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
async fire(name, args, result) {
|
|
177
|
+
const points = hookPointsForTool(name);
|
|
178
|
+
if (!notifyHook || !envCtx || points.length === 0) return;
|
|
179
|
+
try {
|
|
180
|
+
const task = await readTask(name, args, result);
|
|
181
|
+
await withDeadline(
|
|
182
|
+
contextMod.runWithEnvContextAsync(envCtx, async () => {
|
|
183
|
+
for (const hp of points) {
|
|
184
|
+
// globalHookManager.execute isolates hook errors
|
|
185
|
+
// (safeExecute), so a broken lark-cli can never fail
|
|
186
|
+
// the tool call.
|
|
187
|
+
await hookMod.globalHookManager.execute(
|
|
188
|
+
hp,
|
|
189
|
+
buildHookData(name, args, result, task),
|
|
190
|
+
{},
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}),
|
|
194
|
+
HOOK_DEADLINE_MS,
|
|
195
|
+
);
|
|
196
|
+
} catch {
|
|
197
|
+
// Hook failure never breaks the tool call.
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** "Task created successfully: #42 - title" → 42. */
|
|
204
|
+
export function taskIdFromResult(result) {
|
|
205
|
+
const m = typeof result === "string" ? /#(\d+)/.exec(result) : null;
|
|
206
|
+
return m ? Number(m[1]) : 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function str(v) {
|
|
210
|
+
return v === undefined || v === null ? "" : String(v);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function sessionId() {
|
|
214
|
+
return process.env.ROY_TASK_SESSION_ID || "claude-mcp";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Build the hook context data for a completed tool call.
|
|
219
|
+
*
|
|
220
|
+
* The card hook reads `task` / `changes` / `operation` from
|
|
221
|
+
* `ctx.data`; the tool result is a human string, so the data is
|
|
222
|
+
* rebuilt from the call's own arguments (the same values the
|
|
223
|
+
* backend just persisted).
|
|
224
|
+
*/
|
|
225
|
+
export function buildHookData(name, args, result, task) {
|
|
226
|
+
switch (name) {
|
|
227
|
+
case "task_create":
|
|
228
|
+
return {
|
|
229
|
+
// The REAL task, read back from the store (DS CV MAJOR-3: building
|
|
230
|
+
// it from the call arguments faked priority/type/project_path, and
|
|
231
|
+
// the card then showed those wrong values).
|
|
232
|
+
task: task ?? fallbackTask(taskIdFromResult(result), args),
|
|
233
|
+
sessionId: sessionId(),
|
|
234
|
+
tagService: undefined,
|
|
235
|
+
};
|
|
236
|
+
case "task_update":
|
|
237
|
+
case "task_complete": {
|
|
238
|
+
const changes =
|
|
239
|
+
name === "task_complete"
|
|
240
|
+
? { status: "completed", progress: 100 }
|
|
241
|
+
: {
|
|
242
|
+
...(args.status !== undefined ? { status: str(args.status) } : {}),
|
|
243
|
+
...(args.progress !== undefined ? { progress: args.progress } : {}),
|
|
244
|
+
...(args.current_status !== undefined
|
|
245
|
+
? { current_status: str(args.current_status) }
|
|
246
|
+
: {}),
|
|
247
|
+
...(args.title !== undefined ? { title: str(args.title) } : {}),
|
|
248
|
+
...(args.description !== undefined
|
|
249
|
+
? { description: str(args.description) }
|
|
250
|
+
: {}),
|
|
251
|
+
};
|
|
252
|
+
return {
|
|
253
|
+
task: task ?? fallbackTask(taskIdFromResult(result), args),
|
|
254
|
+
changes,
|
|
255
|
+
...(name === "task_complete"
|
|
256
|
+
? { terminalStatus: "completed", sessionId: sessionId() }
|
|
257
|
+
: {}),
|
|
258
|
+
tagService: undefined,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
case "task_operation_create":
|
|
262
|
+
return {
|
|
263
|
+
operation: {
|
|
264
|
+
id: 0,
|
|
265
|
+
taskId: typeof args.task_id === "number" ? args.task_id : Number(args.task_id) || 0,
|
|
266
|
+
sessionId: sessionId(),
|
|
267
|
+
milestoneType: str(args.milestone_type ?? args.action_type ?? "progress"),
|
|
268
|
+
milestoneTitle: str(args.milestone_title ?? args.action_title ?? ""),
|
|
269
|
+
milestoneDescription: str(args.milestone_description ?? args.action_description ?? ""),
|
|
270
|
+
processDescription: str(args.process_description ?? ""),
|
|
271
|
+
timestamp: new Date().toISOString(),
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
default:
|
|
275
|
+
return {};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The last-resort task shape, used only when the store could not be read.
|
|
281
|
+
*
|
|
282
|
+
* Every field is left empty rather than faked: core's `mergeTaskMeta`
|
|
283
|
+
* skips empty strings, so an empty field keeps the card's original value
|
|
284
|
+
* instead of overwriting it with a lie (DS CV MAJOR-3).
|
|
285
|
+
*/
|
|
286
|
+
function fallbackTask(id, args) {
|
|
287
|
+
return {
|
|
288
|
+
id,
|
|
289
|
+
title: str(args.title ?? ""),
|
|
290
|
+
description: "",
|
|
291
|
+
status: "",
|
|
292
|
+
priority: "",
|
|
293
|
+
type: "",
|
|
294
|
+
progress: 0,
|
|
295
|
+
current_status: "",
|
|
296
|
+
goals_and_expected_deliverables: "",
|
|
297
|
+
parent_task_id: args.parent_task_id,
|
|
298
|
+
createdAt: new Date().toISOString(),
|
|
299
|
+
updatedAt: new Date().toISOString(),
|
|
300
|
+
project_path: "",
|
|
301
|
+
context: str(args.context),
|
|
302
|
+
};
|
|
303
|
+
}
|