@ai-setting/roy-task-plugin 0.7.10 → 0.7.12
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 +85 -8
- package/dist/claude-plugin/mcp/bridge.mjs +27 -8
- package/dist/claude-plugin/mcp/goal-check.mjs +452 -0
- package/dist/cli.js +160 -7
- package/dist/index.js +484 -243
- package/dist/index.js.map +14 -13
- package/package.json +2 -2
|
@@ -62,13 +62,12 @@ the user sees.
|
|
|
62
62
|
|
|
63
63
|
4. TRACK
|
|
64
64
|
- Update progress (0-100%) as the work advances
|
|
65
|
-
- Update status: todo → active
|
|
65
|
+
- Update status: todo → active
|
|
66
66
|
- Record `current_status` with human-readable progress
|
|
67
67
|
|
|
68
68
|
5. RECORD — operation records (see Operation Discipline below)
|
|
69
69
|
|
|
70
|
-
6. COMPLETE — `task_complete` after item-by-item verification
|
|
71
|
-
(see Complete Discipline below)
|
|
70
|
+
6. COMPLETE — 收尾:用 `task_complete` after item-by-item verification
|
|
72
71
|
|
|
73
72
|
### Goals 子项拆分规则
|
|
74
73
|
|
|
@@ -142,6 +141,7 @@ stage is reached, **not after the fact** (not in a batch at the end).
|
|
|
142
141
|
|
|
143
142
|
### Task Complete — 核实义务
|
|
144
143
|
|
|
144
|
+
|
|
145
145
|
Before calling `task_complete`, you MUST:
|
|
146
146
|
|
|
147
147
|
1. Read the task's `goal` (description) and `delivery` (goals_and_expected_deliverables) fields.
|
|
@@ -158,12 +158,19 @@ Before calling `task_complete`, you MUST:
|
|
|
158
158
|
`[TASK_COMPLETION_HINT]`.
|
|
159
159
|
- **If you receive a [TASK_COMPLETION_HINT]**, you MUST verify it
|
|
160
160
|
(核实义务): re-read the task via `task_get`, then do exactly one of:
|
|
161
|
-
(a) 确认未完成 →
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
(a) 确认未完成 → **重新委托**(用你的委托工具 + 同一 sub-session 续跑,
|
|
162
|
+
在指令里写明"目标尚未达成,需要补 X/Y/Z"),由 sub-agent 用
|
|
163
|
+
`task_update(status=active)` 重开并继续推进;若你**自己就是执行方**
|
|
164
|
+
(有 `task_update` 权限),则直接 `task_update(status=todo)` 重开;或
|
|
165
|
+
(b) 确认已完成 → **用你手上有权限的写工具**落一条 `decision` 类操作记录
|
|
166
|
+
(`task_operation_create`,milestone_type=decision,milestone_title=<一行结论>)
|
|
167
|
+
说明子项实际已达成(说明理由)。若你没有该工具的权限(验收方场景),
|
|
168
|
+
就通过你的**委托工具**让 sub-agent 代写这条记录,或在 `task_complete`
|
|
169
|
+
的 `deliverable_storage_path` / 最终汇报里明确写出核实结论与证据。
|
|
166
170
|
Never ignore it (不允许无视).
|
|
171
|
+
⚠️ **核实义务的两条路径都依赖写权限**(`task_update` / `task_operation_create`)。
|
|
172
|
+
若这些工具对你**不可用**(如验收方不持有执行类工具),必须**改用委托**把动作
|
|
173
|
+
交回给有权限的 sub-agent 执行 —— 不要试图调用不可用的工具(会被拦掉并浪费一轮迭代)。
|
|
167
174
|
- If the goal and delivery mismatch **without** a hint, either:
|
|
168
175
|
(a) extend the task via `task_update`, or
|
|
169
176
|
(b) record the discrepancy via `task_operation_create`
|
|
@@ -221,6 +228,76 @@ if it is not in the task store, it is not tracked.
|
|
|
221
228
|
- Report the task id and its state whenever you finish a step.
|
|
222
229
|
- If a task is blocked, mark it (`task_update(status="paused")`) and record a
|
|
223
230
|
`problem` milestone explaining the blocker instead of going silent.
|
|
231
|
+
|
|
232
|
+
## Milestone discipline (Claude runtime)
|
|
233
|
+
|
|
234
|
+
The task card renders **your** milestones. A run that calls tools but
|
|
235
|
+
records nothing leaves the card empty, and the user sees a failed task with
|
|
236
|
+
no trace of the work. Measured failure: 67 tool calls, 2 automatic hooks,
|
|
237
|
+
0 real milestones, then the run was killed and the card froze in a failed
|
|
238
|
+
state. Read this section as binding.
|
|
239
|
+
|
|
240
|
+
### Record per stage, immediately
|
|
241
|
+
|
|
242
|
+
- Every time you finish a **阶段** — a sub-goal, an investigation, a file
|
|
243
|
+
written, a test that passed, a decision, a blocker — call
|
|
244
|
+
`task_operation_create` **立即**, in that same turn.
|
|
245
|
+
- Do **not** batch. A milestone written after six tool calls reads as
|
|
246
|
+
retrospective filler; the point is that the card shows the work *as it
|
|
247
|
+
happens*.
|
|
248
|
+
- If a single tool call produced a stage-level result, that call is
|
|
249
|
+
followed by exactly one milestone — no more, no less.
|
|
250
|
+
- Long-running work (> 5 minutes) still records as it goes, exactly as the
|
|
251
|
+
shared contract requires.
|
|
252
|
+
|
|
253
|
+
### The shape of a milestone
|
|
254
|
+
|
|
255
|
+
`milestone_title` — a **短语**, ≤ 20 字, verb-first, naming what was
|
|
256
|
+
locked down. Not a sentence, not a label like "progress".
|
|
257
|
+
|
|
258
|
+
- ✅ `泄漏源锁定`, `测试全绿`, `缓存层落地`, `回滚路径确认`
|
|
259
|
+
- ❌ `进度更新`, `继续处理`, `做了一些工作`, `阶段完成`
|
|
260
|
+
|
|
261
|
+
Those ❌ examples are **空壳** milestones: they carry no information about
|
|
262
|
+
what changed, so they are worse than useless — they make the card look
|
|
263
|
+
busy while hiding the work. Never write a title like those.
|
|
264
|
+
|
|
265
|
+
`milestone_description` — a dense **技术叙述** of the state you reached.
|
|
266
|
+
Name the concrete things, so a reader can reconstruct the work without
|
|
267
|
+
re-running it:
|
|
268
|
+
|
|
269
|
+
- the **文件路径** you wrote or read
|
|
270
|
+
- the **函数名** / symbol you changed or traced
|
|
271
|
+
- the **命令** you ran and its result
|
|
272
|
+
- the numbers: pass/fail counts, line counts, exit codes, sizes, timings
|
|
273
|
+
|
|
274
|
+
Example (title + description):
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
milestone_title: "泄漏源锁定"
|
|
278
|
+
milestone_description: "test_workflow_error_handling 的生成器被放弃 → registry 由 GC 延迟回收;泄漏路径锁定在 WorkflowRegistry.dispose(),packages/core/src/registry.ts:412"
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`process_description` (100–500 字符) — how it was reached (**如何达到**):
|
|
282
|
+
the strategy and the tool steps. It must **not** repeat the description —
|
|
283
|
+
the description is the *state*, the process is the *route*. Repeating
|
|
284
|
+
one in the other is a second ❌.
|
|
285
|
+
|
|
286
|
+
### Tool-call budget
|
|
287
|
+
|
|
288
|
+
If you have made several **连续的工具调用** without recording a
|
|
289
|
+
milestone, the next action is `task_operation_create` — not another
|
|
290
|
+
tool call. Two, three tool calls in a row is normal exploration; a long
|
|
291
|
+
streak without a milestone is the failure mode measured above. When the
|
|
292
|
+
streak is getting long, write down what you have already established, then
|
|
293
|
+
continue.
|
|
294
|
+
|
|
295
|
+
### What good looks like
|
|
296
|
+
|
|
297
|
+
For a task with 3–4 stages you should end up with **3–4 hand-written
|
|
298
|
+
milestones** in the card — not 2 automatic hooks and nothing else. The
|
|
299
|
+
automatic `收到消息` / `Task 完成` hooks are not a substitute for yours.
|
|
300
|
+
|
|
224
301
|
## Tool surface on Claude Code
|
|
225
302
|
|
|
226
303
|
The `task_*` tools are served by the **`roy-task` MCP server** this runtime
|
|
@@ -21,7 +21,8 @@ import {
|
|
|
21
21
|
import { SQLiteTaskStore } from "@ai-setting/roy-agent-core/env/task/storage";
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
|
-
import { createNotifyDispatcher } from "./task-notify-hooks.mjs";
|
|
24
|
+
import { createNotifyDispatcher, taskIdFromResult } from "./task-notify-hooks.mjs";
|
|
25
|
+
import { withGoalCheck } from "./goal-check.mjs";
|
|
25
26
|
|
|
26
27
|
/** Raised when a tool call cannot be served. */
|
|
27
28
|
export class BridgeError extends Error {
|
|
@@ -46,8 +47,16 @@ export function defaultTaskDbPath() {
|
|
|
46
47
|
* call runs inside the injected EnvContext — so `task_create` now opens
|
|
47
48
|
* a card in Feishu, `task_update` patches it, `task_complete` closes it.
|
|
48
49
|
*
|
|
50
|
+
* Task #5035: the **goal check**. A `TaskStoreBackend` has no hook chain, so
|
|
51
|
+
* core's `task:after.complete` judgement never ran in this process — an agent
|
|
52
|
+
* calling its own `task_complete` got no acceptance at all. `task_complete`
|
|
53
|
+
* now runs the judge right after the completion (see `goal-check.mjs`): a
|
|
54
|
+
* FAIL appends `[TASK_COMPLETION_HINT]` to the **same** tool result the agent
|
|
55
|
+
* is already reading, so it can verify and redo in the next turn. Completion
|
|
56
|
+
* semantics are untouched (advisory only, fail-open).
|
|
57
|
+
*
|
|
49
58
|
* @param {string} dbPath task store path
|
|
50
|
-
* @param {{ cardNotifier?: object }} deps test seams
|
|
59
|
+
* @param {{ cardNotifier?: object, decisionPort?: object, goalCheckTimeoutMs?: number }} deps test seams
|
|
51
60
|
*/
|
|
52
61
|
export async function createServer(dbPath = defaultTaskDbPath(), deps = {}) {
|
|
53
62
|
const store = new SQLiteTaskStore(dbPath);
|
|
@@ -61,16 +70,26 @@ export async function createServer(dbPath = defaultTaskDbPath(), deps = {}) {
|
|
|
61
70
|
// Task #4692: fire the task:after.* hooks around every tool call. The
|
|
62
71
|
// wiring lives in task-notify-hooks.mjs; core owns the hook itself.
|
|
63
72
|
const notify = await createNotifyDispatcher({ backend, deps });
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
|
|
74
|
+
// Task #5035: after `task_complete` succeeds, run core's goal check and
|
|
75
|
+
// hand a FAIL back to the agent on the SAME tool result. The rule lives in
|
|
76
|
+
// goal-check.mjs — this adapter only supplies the seams.
|
|
77
|
+
const run = withGoalCheck({
|
|
78
|
+
callTool: async (name, args) => {
|
|
79
|
+
const result = await server.callTool(name, args);
|
|
80
|
+
await notify.fire(name, args, result);
|
|
81
|
+
return result;
|
|
82
|
+
},
|
|
83
|
+
backend,
|
|
84
|
+
taskIdFromResult,
|
|
85
|
+
deps,
|
|
86
|
+
sessionId: process.env.ROY_TASK_SESSION_ID || "claude-mcp",
|
|
87
|
+
});
|
|
69
88
|
|
|
70
89
|
// The hooks (stop-check) need the *backend*, not the MCP surface: they
|
|
71
90
|
// ask "is there an open task?" — a task-semantics question, answered by
|
|
72
91
|
// core's backend, not by the JSON-RPC layer.
|
|
73
|
-
return { ...server, backend, store, callTool:
|
|
92
|
+
return { ...server, backend, store, callTool: run };
|
|
74
93
|
}
|
|
75
94
|
|
|
76
95
|
/**
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task #5035 — the MCP server's **goal check** wiring.
|
|
3
|
+
*
|
|
4
|
+
* ## The hole this closes
|
|
5
|
+
*
|
|
6
|
+
* Core's completion judgement (`LayaCompletionPlugin`, the "goal vs.
|
|
7
|
+
* deliverables" judge) is registered by roy-agent **inside its own process**
|
|
8
|
+
* (`environment.ts:490` → `registerLayaCompletionTaskHook(taskComponent)`),
|
|
9
|
+
* on `TaskComponent`'s hook chain (`task:after.complete`).
|
|
10
|
+
*
|
|
11
|
+
* The plugin's MCP server has no `TaskComponent`: it binds core's task tools
|
|
12
|
+
* to a `TaskStoreBackend` (`TaskBackend`). A `TaskBackend` has **no hook
|
|
13
|
+
* chain**, so `task:after.complete` never fires and the judge never runs.
|
|
14
|
+
*
|
|
15
|
+
* Measured consequence: an agent driving the lifecycle over MCP calls **its
|
|
16
|
+
* own** `task_complete` and gets `Task completed: #N` back with no
|
|
17
|
+
* acceptance at all.
|
|
18
|
+
*
|
|
19
|
+
* ## What this module does (and deliberately does not do)
|
|
20
|
+
*
|
|
21
|
+
* The judgement is **not** handed to another actor. The daemon stays out of
|
|
22
|
+
* acceptance entirely, the agent keeps its own `task_complete` tool, its
|
|
23
|
+
* prompt and tool set are untouched. The judge is wired onto the tool call
|
|
24
|
+
* the agent already makes:
|
|
25
|
+
*
|
|
26
|
+
* ```
|
|
27
|
+
* agent → task_complete (unchanged MCP tool)
|
|
28
|
+
* └─ backend.completeTask (completion semantics: unchanged)
|
|
29
|
+
* └─ runGoalCheck() (this module)
|
|
30
|
+
* ├─ PASS → result returned as-is
|
|
31
|
+
* └─ FAIL → hint in task.context.completion_hint
|
|
32
|
+
* + "[TASK_COMPLETION_HINT]" appended to the SAME
|
|
33
|
+
* tool result, so the agent sees it in this turn
|
|
34
|
+
* and can redo the work, then complete again
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* ## Why an adapter, not a TaskComponent
|
|
38
|
+
*
|
|
39
|
+
* The judge calls 4 methods (`listOperations` / `updateTask` /
|
|
40
|
+
* `createOperation` / `updateTaskContext`). `TaskBackend` has the first three
|
|
41
|
+
* and **no** `updateTaskContext` — it only has `updateTask({ context })`,
|
|
42
|
+
* a full-column replace. So the adapter synthesises the missing method by
|
|
43
|
+
* **read-merge-write**, which keeps `updateTaskContext`'s semantics (merge,
|
|
44
|
+
* never clobber) and, crucially, avoids the nested-`updateTask` side effects
|
|
45
|
+
* the judge was written to avoid (see `laya-completion-plugin.ts:684`, Task
|
|
46
|
+
* #4776 review MINOR-1).
|
|
47
|
+
*
|
|
48
|
+
* ## Cross-repo note
|
|
49
|
+
*
|
|
50
|
+
* Everything semantic lives in core (`@ai-setting/roy-agent-core/env/task/
|
|
51
|
+
* plugins`): the plugin class, the hint renderer, the threshold, the anchor
|
|
52
|
+
* token. This module only adapts shapes and timing — the same discipline as
|
|
53
|
+
* `task-notify-hooks.mjs` (Task #4692), and for the same reason: the plugin
|
|
54
|
+
* must not carry a second copy of roy's semantics.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** The hint anchor, re-exported from core (single source of truth). */
|
|
58
|
+
export const GOAL_CHECK_HINT_ANCHOR = "TASK_COMPLETION_HINT";
|
|
59
|
+
|
|
60
|
+
/** How long the judge may take before the tool call stops waiting. */
|
|
61
|
+
const DEFAULT_GOAL_CHECK_TIMEOUT_MS = 15_000;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Is the goal check enabled for this process?
|
|
65
|
+
*
|
|
66
|
+
* **On by default.** The whole point of Task #5035 is that an MCP-driven task
|
|
67
|
+
* gets accepted; an off-by-default referee would reproduce the hole. An
|
|
68
|
+
* operator can opt out with `ROY_TASK_GOAL_CHECK_ENABLED=0` (tests use it, and
|
|
69
|
+
* an air-gapped host with no decision endpoint can too — though the judge is
|
|
70
|
+
* fail-open anyway, so opting out is rarely needed).
|
|
71
|
+
*/
|
|
72
|
+
export function goalCheckEnabled() {
|
|
73
|
+
const v = process.env.ROY_TASK_GOAL_CHECK_ENABLED;
|
|
74
|
+
if (v === undefined || v === "") return true;
|
|
75
|
+
return !(v === "0" || v === "false");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The judge deadline (ms). */
|
|
79
|
+
export function goalCheckTimeoutMs() {
|
|
80
|
+
const raw = process.env.ROY_TASK_GOAL_CHECK_TIMEOUT_MS;
|
|
81
|
+
const n = raw === undefined ? NaN : Number(raw);
|
|
82
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_GOAL_CHECK_TIMEOUT_MS;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Adapt a `TaskBackend` to the 4-method shape core's judge expects.
|
|
87
|
+
*
|
|
88
|
+
* The first three pass straight through. `updateTaskContext` is synthesised
|
|
89
|
+
* with a read-merge-write, because:
|
|
90
|
+
*
|
|
91
|
+
* 1. `TaskBackend` has no such method;
|
|
92
|
+
* 2. `updateTask({ context })` **replaces** the whole JSON column, so a
|
|
93
|
+
* blind write would silently drop `deliverable_storage_path`,
|
|
94
|
+
* `project_path`, and every other key the task already carried.
|
|
95
|
+
*
|
|
96
|
+
* @param backend core `TaskBackend` (i.e. `TaskStoreBackend`)
|
|
97
|
+
* @param {{ completedTask?: object }} options
|
|
98
|
+
* `completedTask` enables the **operation-chain correction** (see
|
|
99
|
+
* `hideAutoCompletedOperation`): the MCP host runs the judge *after*
|
|
100
|
+
* `backend.completeTask()` returned, while core's own AFTER_COMPLETE fires
|
|
101
|
+
* *inside* the update (before the `completed` operation is written). Core's
|
|
102
|
+
* judge was trained on the latter shape, so passing it the former would be
|
|
103
|
+
* an out-of-distribution input — measured: the same task scores
|
|
104
|
+
* FAIL@0.982 (core shape) vs PASS@0.124 (post-write shape).
|
|
105
|
+
*/
|
|
106
|
+
export function adaptTaskBackendForJudge(backend, options = {}) {
|
|
107
|
+
const readContext = async (taskId) => {
|
|
108
|
+
const task = await backend.getTask(taskId);
|
|
109
|
+
const raw = task && task.context;
|
|
110
|
+
if (!raw || typeof raw !== "string") return {};
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(raw);
|
|
113
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
114
|
+
} catch {
|
|
115
|
+
/* a corrupt context must not block the merge — start from {} */
|
|
116
|
+
}
|
|
117
|
+
return {};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const listOperations = async (opts) => {
|
|
121
|
+
const ops = await backend.listOperations(opts);
|
|
122
|
+
return options.completedTask
|
|
123
|
+
? hideAutoCompletedOperation(ops, options.completedTask)
|
|
124
|
+
: ops;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
listOperations,
|
|
129
|
+
updateTask: (id, input) => backend.updateTask(id, input),
|
|
130
|
+
createOperation: (input) => backend.createOperation(input),
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Merge the given JSON **into** the task's existing context.
|
|
134
|
+
*
|
|
135
|
+
* Mirrors `TaskComponent.updateTaskContext` (merge semantics), implemented
|
|
136
|
+
* on top of the backend primitive without firing the nested-update side
|
|
137
|
+
* effects the judge's own comments warn about.
|
|
138
|
+
*
|
|
139
|
+
* @param {number|string} taskId
|
|
140
|
+
* @param {string} contextJson the full next context (what the judge passes)
|
|
141
|
+
*/
|
|
142
|
+
async updateTaskContext(taskId, contextJson) {
|
|
143
|
+
const current = await readContext(taskId);
|
|
144
|
+
let incoming = {};
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(contextJson);
|
|
147
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) incoming = parsed;
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined; // unparseable → do nothing (the judge treats no-op as fine)
|
|
150
|
+
}
|
|
151
|
+
const merged = { ...current, ...incoming };
|
|
152
|
+
return backend.updateTask(taskId, { context: JSON.stringify(merged) });
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Restore the operation-chain **shape core's judge was trained on**.
|
|
159
|
+
*
|
|
160
|
+
* `TaskStoreBackend.completeTask` writes a `completed` operation
|
|
161
|
+
* (`milestoneTitle: "Completed: <title>"`, description = the deliverable line
|
|
162
|
+
* or `"Task completed"`) **after** it fires nothing — whereas `TaskComponent`
|
|
163
|
+
* fires `task:after.complete` *before* writing that same operation (see the
|
|
164
|
+
* timing note in `laya-completion-plugin.ts`: 「AFTER_COMPLETE 触发时
|
|
165
|
+
* `completed` operation 尚未写入」).
|
|
166
|
+
*
|
|
167
|
+
* This host can only judge after the write, so the auto-generated record is
|
|
168
|
+
* removed to reproduce the in-core view bit for bit. Measured cost of *not*
|
|
169
|
+
* doing it: the same task judged FAIL_INCOMPLETE @0.982 (core shape) versus
|
|
170
|
+
* PASS_COMPLETED @0.124 (post-write shape) — i.e. the trailing `Completed: …`
|
|
171
|
+
* / `Task completed` pair reads as "done" to the model and poisons the gate.
|
|
172
|
+
*
|
|
173
|
+
* Only the **one** record `completeTask` itself produces is removed: a
|
|
174
|
+
* `completed` operation the *agent* wrote earlier is real evidence and stays.
|
|
175
|
+
*
|
|
176
|
+
* @param {Array<object>} operations operations as returned by the backend
|
|
177
|
+
* @param {{ title?: string }} completedTask the task `completeTask` returned
|
|
178
|
+
*/
|
|
179
|
+
export function hideAutoCompletedOperation(operations, completedTask) {
|
|
180
|
+
if (!Array.isArray(operations) || operations.length === 0) return operations;
|
|
181
|
+
const expected = `Completed: ${completedTask?.title ?? ""}`;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A task can be completed **more than once** (the hint round trip: FAIL →
|
|
185
|
+
* the agent redoes the work → `task_update(todo)` → `task_complete` again),
|
|
186
|
+
* and each `completeTask` writes its own record. But only the record from
|
|
187
|
+
* **this** attempt does not exist from core's point of view — an earlier
|
|
188
|
+
* attempt's record is genuine history that core *would* have seen.
|
|
189
|
+
*
|
|
190
|
+
* So exactly **one** record is hidden: the newest auto record. Measured why
|
|
191
|
+
* the distinction matters:
|
|
192
|
+
*
|
|
193
|
+
* | chain on the retry | verdict |
|
|
194
|
+
* |---|---|
|
|
195
|
+
* | prior `Completed:` kept, current hidden | PASS @0.93 |
|
|
196
|
+
* | **all** `Completed:` records hidden | FAIL @0.22–0.83 (unstable) |
|
|
197
|
+
*
|
|
198
|
+
* i.e. hiding history makes the model disbelieve an all-done checklist.
|
|
199
|
+
*
|
|
200
|
+
* `TaskBackend.listOperations` may return either direction, so "newest" is
|
|
201
|
+
* decided by timestamp, not by array position.
|
|
202
|
+
*/
|
|
203
|
+
const isAuto = (op) => {
|
|
204
|
+
if (!op) return false;
|
|
205
|
+
if (String(op.milestoneTitle ?? "") !== expected) return false;
|
|
206
|
+
const desc = String(op.milestoneDescription ?? "");
|
|
207
|
+
return (
|
|
208
|
+
desc === "Task completed" ||
|
|
209
|
+
desc.startsWith("Deliverable:") ||
|
|
210
|
+
desc.startsWith("交付物:") ||
|
|
211
|
+
desc.startsWith("交付物:")
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
let newestIdx = -1;
|
|
216
|
+
let newestTs = -Infinity;
|
|
217
|
+
for (let i = 0; i < operations.length; i += 1) {
|
|
218
|
+
if (!isAuto(operations[i])) continue;
|
|
219
|
+
const ts = new Date(operations[i]?.timestamp ?? 0).getTime();
|
|
220
|
+
const cmp = Number.isFinite(ts) ? ts : -Infinity;
|
|
221
|
+
if (cmp >= newestTs) {
|
|
222
|
+
newestTs = cmp;
|
|
223
|
+
newestIdx = i;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (newestIdx < 0) return operations;
|
|
227
|
+
return operations.filter((_, i) => i !== newestIdx);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Run the goal check for one completed task.
|
|
232
|
+
*
|
|
233
|
+
* **Never throws** and never changes completion semantics — the judge is
|
|
234
|
+
* advisory (core's own contract: AFTER_COMPLETE fires *after* the task is
|
|
235
|
+
* already `completed`; a hint can only be appended, never a rollback).
|
|
236
|
+
*
|
|
237
|
+
* @param {{ decisionPort?: object, timeoutMs?: number, logger?: object }} deps
|
|
238
|
+
* @param {object} backend core `TaskBackend`
|
|
239
|
+
* @param {object} completedTask the task returned by `backend.completeTask`
|
|
240
|
+
* @param {{ sessionId?: string }} options
|
|
241
|
+
* @returns {Promise<string|undefined>} the hint text when the judge said FAIL
|
|
242
|
+
*/
|
|
243
|
+
export async function runGoalCheck(deps, backend, completedTask, options = {}) {
|
|
244
|
+
if (!goalCheckEnabled()) return undefined;
|
|
245
|
+
if (!completedTask || !completedTask.id) return undefined;
|
|
246
|
+
|
|
247
|
+
const log = deps.logger ?? silentLogger();
|
|
248
|
+
let hint;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const pluginsMod = await import("@ai-setting/roy-agent-core/env/task/plugins");
|
|
252
|
+
|
|
253
|
+
// ⚠️ The goal-check API is new in core (Task #5035). A plugin installed
|
|
254
|
+
// against an OLDER published core simply does not have it — that must
|
|
255
|
+
// degrade to "no judge", not to a crash on every completion.
|
|
256
|
+
// (`bun run core:sync` pins the version this repo is developed against.)
|
|
257
|
+
if (typeof pluginsMod.LayaCompletionPlugin !== "function") {
|
|
258
|
+
log.warn(
|
|
259
|
+
`[goal-check] core has no LayaCompletionPlugin export (needs roy-agent-core with Task #5035) — skipping`,
|
|
260
|
+
);
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const adapter = adaptTaskBackendForJudge(backend, { completedTask });
|
|
265
|
+
|
|
266
|
+
// ⚠️ Inject the decision port when the host supplied one (tests / a host
|
|
267
|
+
// that resolves auth itself). Otherwise let core resolve it from
|
|
268
|
+
// `~/.roy-agent/auth.json` (providers["laya-goal-decision"]) exactly as
|
|
269
|
+
// roy-agent does in its own process.
|
|
270
|
+
const plugin = deps.decisionPort
|
|
271
|
+
? new pluginsMod.LayaCompletionPlugin({ decisionPort: deps.decisionPort })
|
|
272
|
+
: new pluginsMod.LayaCompletionPlugin({
|
|
273
|
+
...(deps.timeoutMs ? { timeoutMs: deps.timeoutMs } : {}),
|
|
274
|
+
...(deps.authPath ? { authPath: deps.authPath } : {}),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// The plugin reads the hint it wrote through `ctx.task`; it also needs
|
|
278
|
+
// `terminalStatus` so its own "only completed" filter passes.
|
|
279
|
+
const ctx = {
|
|
280
|
+
task: completedTask,
|
|
281
|
+
changes: { status: "completed", progress: 100 },
|
|
282
|
+
terminalStatus: "completed",
|
|
283
|
+
sessionId: options.sessionId ?? "mcp-goal-check",
|
|
284
|
+
tagService: undefined,
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// ⚠️ Read the verdict back by **comparing before/after**, not by
|
|
288
|
+
// "is a hint present". A task can be completed several times (FAIL →
|
|
289
|
+
// redo → complete again): the FIRST attempt's hint stays in
|
|
290
|
+
// `task.context.completion_hint` forever, so a presence test reports a
|
|
291
|
+
// stale FAIL on every later PASS (measured: the retry ATTACHED AN OLD
|
|
292
|
+
// HINT even though this round's verdict was PASS_COMPLETED @0.95).
|
|
293
|
+
const hintBefore = await readCompletionHint(backend, completedTask.id);
|
|
294
|
+
|
|
295
|
+
await withDeadline(
|
|
296
|
+
plugin.execute(ctx, adapter),
|
|
297
|
+
deps.timeoutMs ?? goalCheckTimeoutMs(),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const hintAfter = await readCompletionHint(backend, completedTask.id);
|
|
301
|
+
if (hintAfter && hintAfter !== hintBefore) hint = hintAfter;
|
|
302
|
+
if (hint) {
|
|
303
|
+
log.info(`[goal-check] task #${completedTask.id}: FAIL — hint attached to tool result`);
|
|
304
|
+
} else {
|
|
305
|
+
log.debug?.(`[goal-check] task #${completedTask.id}: PASS (no hint)`);
|
|
306
|
+
}
|
|
307
|
+
} catch (err) {
|
|
308
|
+
// fail-open: the judge is advisory, so a broken judge is a silent judge.
|
|
309
|
+
log.warn(
|
|
310
|
+
`[goal-check] task #${completedTask?.id} judgement skipped (fail-open): ${
|
|
311
|
+
err && err.message ? err.message : String(err)
|
|
312
|
+
}`,
|
|
313
|
+
);
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return hint;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Wrap a tool caller so `task_complete` also runs the goal check.
|
|
322
|
+
*
|
|
323
|
+
* This is the **whole** wiring, kept here rather than in `bridge.mjs` so the
|
|
324
|
+
* bridge stays the thin transport adapter `claude-bridge-thin.test.ts` pins
|
|
325
|
+
* (< 140 lines). The bridge contributes one call; every rule about *when* and
|
|
326
|
+
* *how* the judge runs lives in this module.
|
|
327
|
+
*
|
|
328
|
+
* `callTool` is the bare MCP tool executor (already bound to the backend).
|
|
329
|
+
* The returned function has the same signature and behaviour, plus: on a
|
|
330
|
+
* successful `task_complete` whose verdict is FAIL, the hint is appended to
|
|
331
|
+
* the result the agent reads.
|
|
332
|
+
*
|
|
333
|
+
* ⚠️ Everything here is advisory and runs **after** the tool succeeded. The
|
|
334
|
+
* completion is already committed, so any failure inside the check must leave
|
|
335
|
+
* that result untouched — a referee that cannot read the file must not void
|
|
336
|
+
* the score. The whole block, including the `getTask` read, is wrapped
|
|
337
|
+
* (DS CV MINOR-1: `getTask` used to sit outside `runGoalCheck`'s try/catch,
|
|
338
|
+
* so a read error surfaced as a failed `task_complete`).
|
|
339
|
+
*
|
|
340
|
+
* @param {object} input
|
|
341
|
+
* @param {(name: string, args?: object) => Promise<unknown>} input.callTool
|
|
342
|
+
* @param {object} input.backend core `TaskBackend`
|
|
343
|
+
* @param {(result: unknown) => number} input.taskIdFromResult
|
|
344
|
+
* @param {object} [input.deps] decisionPort / goalCheckTimeoutMs / logger seams
|
|
345
|
+
* @param {string} [input.sessionId]
|
|
346
|
+
* @returns {(name: string, args?: object) => Promise<unknown>}
|
|
347
|
+
*/
|
|
348
|
+
export function withGoalCheck({ callTool, backend, taskIdFromResult, deps = {}, sessionId }) {
|
|
349
|
+
return async (name, args = {}) => {
|
|
350
|
+
const result = await callTool(name, args);
|
|
351
|
+
// Only `task_complete`: the judge is defined on `task:after.complete`, and
|
|
352
|
+
// running it on any other tool would judge half-finished work.
|
|
353
|
+
if (name !== "task_complete") return result;
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const taskId =
|
|
357
|
+
typeof args.id === "number" ? args.id : Number(args.task_id) || taskIdFromResult(result);
|
|
358
|
+
if (!taskId) return result;
|
|
359
|
+
const completed = await backend.getTask(taskId);
|
|
360
|
+
const hint = await runGoalCheck(
|
|
361
|
+
{
|
|
362
|
+
...(deps.decisionPort ? { decisionPort: deps.decisionPort } : {}),
|
|
363
|
+
...(deps.goalCheckTimeoutMs ? { timeoutMs: deps.goalCheckTimeoutMs } : {}),
|
|
364
|
+
logger: deps.logger,
|
|
365
|
+
},
|
|
366
|
+
backend,
|
|
367
|
+
completed,
|
|
368
|
+
{ sessionId: sessionId ?? "mcp-goal-check" },
|
|
369
|
+
);
|
|
370
|
+
return attachHintToResult(result, hint);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
process.stderr.write(
|
|
373
|
+
`roy-task mcp goal-check: wiring failed (fail-open): ${
|
|
374
|
+
err && err.message ? err.message : String(err)
|
|
375
|
+
}\n`,
|
|
376
|
+
);
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Append the hint to a completed tool's result text.
|
|
384
|
+
*
|
|
385
|
+
* The MCP tool result is a plain string (`Task completed: #N - title…`). The
|
|
386
|
+
* hint rides on the **same** result so the agent sees it without a second
|
|
387
|
+
* round trip — core's prompt/tool copy already instructs it to verify a
|
|
388
|
+
* `[TASK_COMPLETION_HINT]` when it appears.
|
|
389
|
+
*
|
|
390
|
+
* @param {unknown} result the value the tool returned
|
|
391
|
+
* @param {string|undefined} hint
|
|
392
|
+
*/
|
|
393
|
+
export function attachHintToResult(result, hint) {
|
|
394
|
+
if (!hint) return result;
|
|
395
|
+
const suffix = `\n\n${hint}`;
|
|
396
|
+
if (typeof result === "string") return result + suffix;
|
|
397
|
+
// Non-string results (objects) — add a dedicated field instead of
|
|
398
|
+
// stringifying the whole payload (the agent reads both).
|
|
399
|
+
if (result && typeof result === "object") {
|
|
400
|
+
return { ...result, completion_hint: hint };
|
|
401
|
+
}
|
|
402
|
+
return String(result ?? "") + suffix;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Read `task.context.completion_hint` (the judge's FAIL channel).
|
|
407
|
+
*
|
|
408
|
+
* @returns {Promise<string|undefined>}
|
|
409
|
+
*/
|
|
410
|
+
async function readCompletionHint(backend, taskId) {
|
|
411
|
+
try {
|
|
412
|
+
const task = await backend.getTask(taskId);
|
|
413
|
+
const raw = task && task.context;
|
|
414
|
+
if (!raw || typeof raw !== "string") return undefined;
|
|
415
|
+
const parsed = JSON.parse(raw);
|
|
416
|
+
if (parsed && typeof parsed === "object" && typeof parsed.completion_hint === "string") {
|
|
417
|
+
return parsed.completion_hint;
|
|
418
|
+
}
|
|
419
|
+
} catch {
|
|
420
|
+
/* a corrupt context is "no hint" — the judge has already written its op */
|
|
421
|
+
}
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Resolve or give up after `ms`; never keeps the process alive for the timer. */
|
|
426
|
+
function withDeadline(promise, ms) {
|
|
427
|
+
return Promise.race([
|
|
428
|
+
promise,
|
|
429
|
+
new Promise((_resolve, reject) => {
|
|
430
|
+
const timer = setTimeout(
|
|
431
|
+
() => reject(new Error(`goal-check deadline exceeded (${ms}ms)`)),
|
|
432
|
+
ms,
|
|
433
|
+
);
|
|
434
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
435
|
+
}),
|
|
436
|
+
]);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The default logger.
|
|
441
|
+
*
|
|
442
|
+
* stdout is the MCP protocol channel (`setStdoutStrictMode(true)` in
|
|
443
|
+
* `roy-task-mcp-server.mjs`), so diagnostics must go to stderr or they
|
|
444
|
+
* desynchronise the JSON-RPC stream and the client drops the server.
|
|
445
|
+
*/
|
|
446
|
+
function silentLogger() {
|
|
447
|
+
return {
|
|
448
|
+
info: (m) => process.stderr.write(`roy-task mcp goal-check: ${m}\n`),
|
|
449
|
+
warn: (m) => process.stderr.write(`roy-task mcp goal-check: ${m}\n`),
|
|
450
|
+
debug: () => {},
|
|
451
|
+
};
|
|
452
|
+
}
|