@ai-setting/roy-task-plugin 0.7.11 → 0.7.13

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.
@@ -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 → completed
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 (see Complete Discipline below)
72
71
 
73
72
  ### Goals 子项拆分规则
74
73
 
@@ -158,12 +157,19 @@ Before calling `task_complete`, you MUST:
158
157
  `[TASK_COMPLETION_HINT]`.
159
158
  - **If you receive a [TASK_COMPLETION_HINT]**, you MUST verify it
160
159
  (核实义务): re-read the task via `task_get`, then do exactly one of:
161
- (a) 确认未完成 → `task_update(task_id=<id>, status=todo)` to **reopen**
162
- the task and continue the work; or
163
- (b) 确认已完成 → `task_operation_create` (milestone_type=decision,
164
- milestone_title=<一行结论>) **state why** the sub-items are actually
165
- met (说明理由).
160
+ (a) 确认未完成 → **重新委托**(用你的委托工具 + 同一 sub-session 续跑,
161
+ 在指令里写明"目标尚未达成,需要补 X/Y/Z"),由 sub-agent 用
162
+ `task_update(status=active)` 重开并继续推进;若你**自己就是执行方**
163
+ (有 `task_update` 权限),则直接 `task_update(status=todo)` 重开;或
164
+ (b) 确认已完成 → **用你手上有权限的写工具**落一条 `decision` 类操作记录
165
+ (`task_operation_create`,milestone_type=decision,milestone_title=<一行结论>)
166
+ 说明子项实际已达成(说明理由)。若你没有该工具的权限(验收方场景),
167
+ 就通过你的**委托工具**让 sub-agent 代写这条记录,或在 `task_complete`
168
+ 的 `deliverable_storage_path` / 最终汇报里明确写出核实结论与证据。
166
169
  Never ignore it (不允许无视).
170
+ ⚠️ **核实义务的两条路径都依赖写权限**(`task_update` / `task_operation_create`)。
171
+ 若这些工具对你**不可用**(如验收方不持有执行类工具),必须**改用委托**把动作
172
+ 交回给有权限的 sub-agent 执行 —— 不要试图调用不可用的工具(会被拦掉并浪费一轮迭代)。
167
173
  - If the goal and delivery mismatch **without** a hint, either:
168
174
  (a) extend the task via `task_update`, or
169
175
  (b) record the discrepancy via `task_operation_create`
@@ -221,6 +227,76 @@ if it is not in the task store, it is not tracked.
221
227
  - Report the task id and its state whenever you finish a step.
222
228
  - If a task is blocked, mark it (`task_update(status="paused")`) and record a
223
229
  `problem` milestone explaining the blocker instead of going silent.
230
+
231
+ ## Milestone discipline (Claude runtime)
232
+
233
+ The task card renders **your** milestones. A run that calls tools but
234
+ records nothing leaves the card empty, and the user sees a failed task with
235
+ no trace of the work. Measured failure: 67 tool calls, 2 automatic hooks,
236
+ 0 real milestones, then the run was killed and the card froze in a failed
237
+ state. Read this section as binding.
238
+
239
+ ### Record per stage, immediately
240
+
241
+ - Every time you finish a **阶段** — a sub-goal, an investigation, a file
242
+ written, a test that passed, a decision, a blocker — call
243
+ `task_operation_create` **立即**, in that same turn.
244
+ - Do **not** batch. A milestone written after six tool calls reads as
245
+ retrospective filler; the point is that the card shows the work *as it
246
+ happens*.
247
+ - If a single tool call produced a stage-level result, that call is
248
+ followed by exactly one milestone — no more, no less.
249
+ - Long-running work (> 5 minutes) still records as it goes, exactly as the
250
+ shared contract requires.
251
+
252
+ ### The shape of a milestone
253
+
254
+ `milestone_title` — a **短语**, ≤ 20 字, verb-first, naming what was
255
+ locked down. Not a sentence, not a label like "progress".
256
+
257
+ - ✅ `泄漏源锁定`, `测试全绿`, `缓存层落地`, `回滚路径确认`
258
+ - ❌ `进度更新`, `继续处理`, `做了一些工作`, `阶段完成`
259
+
260
+ Those ❌ examples are **空壳** milestones: they carry no information about
261
+ what changed, so they are worse than useless — they make the card look
262
+ busy while hiding the work. Never write a title like those.
263
+
264
+ `milestone_description` — a dense **技术叙述** of the state you reached.
265
+ Name the concrete things, so a reader can reconstruct the work without
266
+ re-running it:
267
+
268
+ - the **文件路径** you wrote or read
269
+ - the **函数名** / symbol you changed or traced
270
+ - the **命令** you ran and its result
271
+ - the numbers: pass/fail counts, line counts, exit codes, sizes, timings
272
+
273
+ Example (title + description):
274
+
275
+ ```
276
+ milestone_title: "泄漏源锁定"
277
+ milestone_description: "test_workflow_error_handling 的生成器被放弃 → registry 由 GC 延迟回收;泄漏路径锁定在 WorkflowRegistry.dispose(),packages/core/src/registry.ts:412"
278
+ ```
279
+
280
+ `process_description` (100–500 字符) — how it was reached (**如何达到**):
281
+ the strategy and the tool steps. It must **not** repeat the description —
282
+ the description is the *state*, the process is the *route*. Repeating
283
+ one in the other is a second ❌.
284
+
285
+ ### Tool-call budget
286
+
287
+ If you have made several **连续的工具调用** without recording a
288
+ milestone, the next action is `task_operation_create` — not another
289
+ tool call. Two, three tool calls in a row is normal exploration; a long
290
+ streak without a milestone is the failure mode measured above. When the
291
+ streak is getting long, write down what you have already established, then
292
+ continue.
293
+
294
+ ### What good looks like
295
+
296
+ For a task with 3–4 stages you should end up with **3–4 hand-written
297
+ milestones** in the card — not 2 automatic hooks and nothing else. The
298
+ automatic `收到消息` / `Task 完成` hooks are not a substitute for yours.
299
+
224
300
  ## Tool surface on Claude Code
225
301
 
226
302
  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 (fake notifier)
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
- const callToolWithHooks = async (name, args = {}) => {
65
- const result = await server.callTool(name, args);
66
- await notify.fire(name, args, result);
67
- return result;
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: callToolWithHooks };
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
+ }