toolroll 0.9.42 → 0.9.44
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/CHANGELOG.md +37 -0
- package/dist/action-ledger.d.ts +5 -2
- package/dist/action-ledger.js +13 -3
- package/dist/chat-actions.js +1 -1
- package/dist/chat-approval.d.ts +45 -0
- package/dist/chat-approval.js +81 -0
- package/dist/chat-channel.js +5 -0
- package/dist/chat-decide.d.ts +18 -6
- package/dist/chat-decide.js +54 -20
- package/dist/chat-flow.js +2 -2
- package/dist/cli-http.d.ts +64 -0
- package/dist/cli-http.js +143 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +8 -1
- package/dist/contracts/cli.d.ts +13 -0
- package/dist/contracts/cli.js +7 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/gateway-tools.d.ts +82 -0
- package/dist/contracts/gateway-tools.js +33 -0
- package/dist/contracts/lead-tools.d.ts +11 -0
- package/dist/contracts/lead-tools.js +2 -0
- package/dist/desktop-update.d.ts +3 -1
- package/dist/desktop-update.js +7 -6
- package/dist/flow-engine.d.ts +2 -0
- package/dist/flow-engine.js +10 -2
- package/dist/flows-cli.d.ts +28 -0
- package/dist/flows-cli.js +43 -1
- package/dist/guides.js +2 -0
- package/dist/mate-contract.js +1 -1
- package/dist/mate-tools.js +43 -1
- package/dist/mcp-core.d.ts +110 -0
- package/dist/mcp-core.js +311 -0
- package/dist/mcp-http.d.ts +26 -0
- package/dist/mcp-http.js +151 -0
- package/dist/mcp-person.d.ts +31 -0
- package/dist/mcp-person.js +96 -0
- package/dist/mcp.d.ts +1 -8
- package/dist/mcp.js +21 -322
- package/dist/operate-remote-arguments.d.ts +36 -0
- package/dist/operate-remote-arguments.js +185 -0
- package/dist/operate-remote.d.ts +86 -0
- package/dist/operate-remote.js +125 -0
- package/dist/operate.d.ts +21 -1
- package/dist/operate.js +229 -15
- package/dist/process-recovery-eligibility.d.ts +1 -1
- package/dist/remote-command.d.ts +45 -0
- package/dist/remote-command.js +124 -0
- package/dist/remote-exec.d.ts +18 -0
- package/dist/remote-exec.js +241 -0
- package/dist/remote-run.d.ts +16 -0
- package/dist/remote-run.js +13 -0
- package/dist/scope.d.ts +4 -2
- package/dist/scope.js +10 -0
- package/dist/serve.d.ts +7 -0
- package/dist/serve.js +75 -2
- package/dist/store.d.ts +23 -2
- package/dist/store.js +76 -9
- package/dist/surface.d.ts +10 -3
- package/dist/surface.js +71 -3
- package/dist/team-cli.d.ts +19 -0
- package/dist/team-cli.js +20 -3
- package/dist/teammate-work.js +1 -1
- package/dist/telegram-flow.d.ts +41 -1
- package/dist/telegram-flow.js +96 -12
- package/dist/telegram.js +27 -3
- package/dist/toolroll-update.js +4 -1
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import { dirname, join } from 'node:path';
|
|
2
1
|
import { statSync } from 'node:fs';
|
|
3
|
-
import { repositoryContextRead } from './repository-context.js';
|
|
4
|
-
import { ASSIGNMENT_TOOLS, assignmentForCoordinator } from "./assignment-adapters.js";
|
|
5
|
-
import { parseContract, toModelSchema } from "./contracts/contract.js";
|
|
6
|
-
import { GATEWAY_TOOL_INPUTS, GATEWAY_TOOL_OUTPUTS } from "./contracts/gateway-tools.js";
|
|
7
|
-
import { reportToolOutput } from "./contracts/lead-tools.js";
|
|
8
2
|
/**
|
|
9
|
-
* `toolroll mcp` — the MCP stdio
|
|
3
|
+
* `toolroll mcp` — the MCP stdio adapter (MCP gateway spec v6). What a
|
|
4
|
+
* call means lives in the transport-free core (mcp-core.ts); this file owns
|
|
5
|
+
* the stdio transport, era pinning and the connection's lifecycle.
|
|
6
|
+
*
|
|
10
7
|
*
|
|
11
8
|
* Zero-dep JSON-RPC 2.0 over stdio, newline-delimited; stdout carries
|
|
12
9
|
* protocol bytes ONLY (logs go to stderr); clean EOF is clean shutdown.
|
|
@@ -31,207 +28,9 @@ import { reportToolOutput } from "./contracts/lead-tools.js";
|
|
|
31
28
|
* refusals are successful `tools/call` results with `isError: true`.
|
|
32
29
|
*/
|
|
33
30
|
import { PACKAGE_VERSION } from "./version.js";
|
|
34
|
-
import { authenticateCoordinator
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
export const MODERN = "2026-07-28";
|
|
38
|
-
export const LEGACY = "2025-11-25";
|
|
39
|
-
/** The modern revision namespaces its per-request metadata. */
|
|
40
|
-
export const META_VERSION = "io.modelcontextprotocol/protocolVersion";
|
|
41
|
-
export const META_SERVER = "io.modelcontextprotocol/serverInfo";
|
|
42
|
-
export const META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
|
|
43
|
-
/** The revision's own error code for an unsupported protocol version. */
|
|
44
|
-
export const UNSUPPORTED_VERSION = -32022;
|
|
45
|
-
const MAX_REQUEST = 256 * 1024;
|
|
46
|
-
const MAX_DEPTH = 32;
|
|
47
|
-
function depthOf(value) {
|
|
48
|
-
// The byte cap bounds the work list. Neither nesting nor array width
|
|
49
|
-
// becomes a JavaScript call stack or a spread argument list.
|
|
50
|
-
const pending = [{ value, depth: 0 }];
|
|
51
|
-
let maximum = 0;
|
|
52
|
-
while (pending.length > 0) {
|
|
53
|
-
const current = pending.pop();
|
|
54
|
-
if (current.depth > MAX_DEPTH)
|
|
55
|
-
return current.depth;
|
|
56
|
-
maximum = Math.max(maximum, current.depth);
|
|
57
|
-
if (current.value !== null && typeof current.value === "object") {
|
|
58
|
-
const children = Array.isArray(current.value) ? current.value : Object.values(current.value);
|
|
59
|
-
for (const child of children)
|
|
60
|
-
pending.push({ value: child, depth: current.depth + 1 });
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return maximum;
|
|
64
|
-
}
|
|
65
|
-
const CONTRACT_GUIDE = [
|
|
66
|
-
"Toolroll MCP contract.",
|
|
67
|
-
"You hold a coordinator credential: you may read inside your repo allowlist, file proposals, and record assignment ownership and receipt checks. Ownership and receipt checks grant no execution or approval authority.",
|
|
68
|
-
"A filed proposal is an ordinary unapproved task: it is quarantined from planning, claiming, and running until the operator signs its scope in a password ceremony that shows them who asked. Modes never auto-admit coordinator filings.",
|
|
69
|
-
"file_proposal requires an idempotency_key (8-64 printable chars, unique per request): replaying the same key+request returns the original task; the same key with a different request refuses. deliverable 'report' files a scout task: a read-only investigation whose only output is a report on the task page, never a branch.",
|
|
70
|
-
"You are rate-limited per hour and capped on outstanding unapproved filings. Refusals say the limit and the road in words.",
|
|
71
|
-
"recap, list_decisions, get_decision, and queue are the plane's own read queries (shared with the operator's mate). get_decision shows each option's consequence, never the builder's recommendation.",
|
|
72
|
-
"propose_next, propose_reserve, propose_hold, propose_unhold, propose_scope, propose_cancel, and propose_answer write a PROPOSAL row and nothing else: an approver confirms it on the console or the CLI, and a stale one refuses there. Proposals share your hourly filing rate and hold at most 20 pending per credential; they expire after seven days.",
|
|
73
|
-
"Approve, steer, answer, pick, mint, configure, merge: those verbs do not exist on this surface, by construction.",
|
|
74
|
-
"Claim one assignment, then follow list_assignment_updates with a saved cursor; inspect get_assignment when a current result or decision needs attention. claim_assignment records its lead; acknowledge_assignment records that lead's check of an exact ready receipt. Neither operation grants authority, accepts proof, answers decisions, approves work, publishes, or deploys.",
|
|
75
|
-
].join("\n");
|
|
76
|
-
/** An assignment tool: the adapter reads its call with the same schema `toolroll assignment` does. */
|
|
77
|
-
const assignmentHandler = (operation) => (ctx, args) => {
|
|
78
|
-
const result = assignmentForCoordinator(ctx.store, ctx.token, operation, args, ctx.now, ctx.evidenceRoot);
|
|
79
|
-
return result.ok ? { ok: true, body: result.body } : { ok: false, message: `${result.reason}: ${result.message}` };
|
|
80
|
-
};
|
|
81
|
-
const assignmentTool = (name) => {
|
|
82
|
-
const spec = ASSIGNMENT_TOOLS.find(one => one.name === name);
|
|
83
|
-
return { description: spec.description, handle: assignmentHandler(spec.operation) };
|
|
84
|
-
};
|
|
85
|
-
/** A propose_* tool: it writes one proposal row through the coordinator door. */
|
|
86
|
-
const proposeTool = (kind, description) => ({
|
|
87
|
-
description,
|
|
88
|
-
handle: (ctx, args) => {
|
|
89
|
-
const outcome = proposeAsCoordinator(ctx.store, ctx.token, kind, args, ctx.now, { readDecisions: ctx.readDecisions });
|
|
90
|
-
if (!outcome.ok)
|
|
91
|
-
return { ok: false, message: outcome.message };
|
|
92
|
-
return { ok: true, body: { proposal: outcome.id, kind: outcome.kind, awaiting: outcome.awaiting } };
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
const HANDLERS = {
|
|
96
|
-
get_project_context: {
|
|
97
|
-
description: 'Read bounded source excerpts or advisory static import impact in an admitted project. Falls back to text search without an index.',
|
|
98
|
-
handle: (ctx, args) => {
|
|
99
|
-
const repo = args.repo;
|
|
100
|
-
if (!ctx.who.repos.includes(repo) || ctx.enrolled !== null && !ctx.enrolled.includes(repo))
|
|
101
|
-
return { ok: false, message: 'That project is outside your access.' };
|
|
102
|
-
return { ok: true, body: repositoryContextRead({ repo, query: args.query, mode: args.mode === 'impact' ? 'impact' : 'search', audience: 'lead',
|
|
103
|
-
...(ctx.evidenceRoot === undefined ? {} : { cacheRoot: join(dirname(ctx.evidenceRoot), 'repository-context') }) }) };
|
|
104
|
-
},
|
|
105
|
-
},
|
|
106
|
-
get_assignment: assignmentTool("get_assignment"),
|
|
107
|
-
list_assignment_updates: assignmentTool("list_assignment_updates"),
|
|
108
|
-
claim_assignment: assignmentTool("claim_assignment"),
|
|
109
|
-
acknowledge_assignment: assignmentTool("acknowledge_assignment"),
|
|
110
|
-
get_assignment_brief: assignmentTool("get_assignment_brief"),
|
|
111
|
-
get_assignment_inbox: assignmentTool("get_assignment_inbox"),
|
|
112
|
-
acknowledge_assignment_delivery: assignmentTool("acknowledge_assignment_delivery"),
|
|
113
|
-
status: {
|
|
114
|
-
description: "The plane's liveness facts over your repo allowlist: what waits on the operator, what runs, what finished in the last 24h.",
|
|
115
|
-
handle: ctx => ({ ok: true, body: statusFor(ctx.store, ctx.who, ctx.now) }),
|
|
116
|
-
},
|
|
117
|
-
list_tasks: {
|
|
118
|
-
description: "Tasks in your repo allowlist. Filter by state and repo; cursor-paginated, stable order.",
|
|
119
|
-
handle: (ctx, args) => {
|
|
120
|
-
const filter = {};
|
|
121
|
-
if (args.state !== undefined)
|
|
122
|
-
filter.state = args.state;
|
|
123
|
-
if (args.repo !== undefined)
|
|
124
|
-
filter.repo = args.repo;
|
|
125
|
-
if (args.cursor !== undefined)
|
|
126
|
-
filter.cursor = args.cursor;
|
|
127
|
-
if (args.limit !== undefined)
|
|
128
|
-
filter.limit = args.limit;
|
|
129
|
-
return { ok: true, body: listTasksFor(ctx.store, ctx.who, filter, ctx.now) };
|
|
130
|
-
},
|
|
131
|
-
},
|
|
132
|
-
get_task: {
|
|
133
|
-
description: "One task's deliverable (branch or report), scope standing, filer provenance, attempt ledger, and — for a finished scout — the report's title, summary, and follow-ups. A ref outside your allowlist answers not-found.",
|
|
134
|
-
handle: (ctx, args) => {
|
|
135
|
-
const detail = taskDetailFor(ctx.store, ctx.who, args.ref, ctx.evidenceRoot, ctx.now);
|
|
136
|
-
if (detail === null)
|
|
137
|
-
return { ok: false, message: `not-found: no task \`${args.ref}\` in your repositories` };
|
|
138
|
-
return { ok: true, body: detail };
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
list_repos: {
|
|
142
|
-
description: "The repositories your credential may see and file into, with their operating-mode standing.",
|
|
143
|
-
handle: ctx => {
|
|
144
|
-
// FAIL CLOSED when the project registry cannot be read (round-2
|
|
145
|
-
// finding 4): answering the full allowlist would claim enrollment
|
|
146
|
-
// nobody proved.
|
|
147
|
-
if (ctx.enrolled === null) {
|
|
148
|
-
return { ok: false, message: "the project registry could not be read — the enrolled set is unknown, so nothing lists" };
|
|
149
|
-
}
|
|
150
|
-
return {
|
|
151
|
-
ok: true,
|
|
152
|
-
body: {
|
|
153
|
-
repos: ctx.who.repos
|
|
154
|
-
.filter(repo => ctx.enrolled !== null && ctx.enrolled.includes(repo))
|
|
155
|
-
.map(repo => {
|
|
156
|
-
const mode = ctx.store.activeMode(repo, ctx.now);
|
|
157
|
-
return { repo, mode: mode === null ? "no operating mode — every act is a ceremony" : `mode ${mode.name} signed until ${mode.absoluteExpiry}` };
|
|
158
|
-
}),
|
|
159
|
-
},
|
|
160
|
-
};
|
|
161
|
-
},
|
|
162
|
-
},
|
|
163
|
-
recap: {
|
|
164
|
-
description: "How things stand per repository in your allowlist, counts and ids: what waits on the operator (decisions, incidents, scopes awaiting approval), what runs, what is queued, finished, failed. Pass `since` (an ISO timestamp) to count only decisions, incidents, and attempts newer than it, to the hour; queued work and scopes awaiting approval always count.",
|
|
165
|
-
handle: (ctx, args) => {
|
|
166
|
-
const since = args.since;
|
|
167
|
-
if (since !== undefined && (!ISO_STAMP_RULE.test(since) || Number.isNaN(Date.parse(since)))) {
|
|
168
|
-
return { ok: false, message: "since is an ISO timestamp like 2026-09-02T12:00:00Z" };
|
|
169
|
-
}
|
|
170
|
-
return { ok: true, body: labelRepos(recapOver(ctx.store, ctx.who.repos, ctx.now, since ?? null), index => ctx.who.repos[index] ?? "") };
|
|
171
|
-
},
|
|
172
|
-
},
|
|
173
|
-
list_decisions: {
|
|
174
|
-
description: "Open decisions in your allowlist: id, task, question, options (id, label, reversible), age in hours. Never consequences or recommendations; the operator answers them.",
|
|
175
|
-
handle: ctx => ({ ok: true, body: labelRepos(decisionsOver(ctx.store, ctx.who.repos, ctx.now), index => ctx.who.repos[index] ?? "") }),
|
|
176
|
-
},
|
|
177
|
-
queue: {
|
|
178
|
-
description: "One repository's queue by column — the shared column, then each worker's reserved column — each in dispatch order.",
|
|
179
|
-
handle: (ctx, args) => {
|
|
180
|
-
const repo = args.repo;
|
|
181
|
-
if (!ctx.who.repos.includes(repo))
|
|
182
|
-
return { ok: false, message: "not-found: that repository is not in your allowlist" };
|
|
183
|
-
// The installation-wide revision stays home (slice-2 review, finding
|
|
184
|
-
// 12): a coordinator cannot move queues, and the counter would tell it
|
|
185
|
-
// about repos it may not see.
|
|
186
|
-
const { queueRevision: _revision, ...columns } = queueOver(ctx.store, repo, ctx.now);
|
|
187
|
-
return { ok: true, body: { repo, ...columns } };
|
|
188
|
-
},
|
|
189
|
-
},
|
|
190
|
-
get_decision: {
|
|
191
|
-
description: "One open decision in your allowlist in full: question, options with id, label, reversible, and consequence. Never the builder's recommendation.",
|
|
192
|
-
handle: (ctx, args) => {
|
|
193
|
-
const found = decisionOver(ctx.store, ctx.who.repos, args.decision, ctx.now);
|
|
194
|
-
if (found === null)
|
|
195
|
-
return { ok: false, message: "not-found: no such decision in your repositories" };
|
|
196
|
-
ctx.readDecisions.add(args.decision);
|
|
197
|
-
return { ok: true, body: labelRepos(found, index => ctx.who.repos[index] ?? "") };
|
|
198
|
-
},
|
|
199
|
-
},
|
|
200
|
-
propose_next: proposeTool("next", "Propose moving a queued task to the front of its column. An approver confirms; a queue that moved meanwhile refuses."),
|
|
201
|
-
propose_reserve: proposeTool("reserve", "Propose reserving a queued task for one worker, or releasing it to the shared queue with worker null."),
|
|
202
|
-
propose_hold: proposeTool("hold", "Propose holding a task's next attempt, with a reason. A running attempt is never interrupted."),
|
|
203
|
-
propose_unhold: proposeTool("unhold", "Propose lifting the operator's own hold on a task."),
|
|
204
|
-
propose_scope: proposeTool("scope", "Propose rewriting a task's scope. An approver confirms the rewrite, then approves it with a password — a scope you wrote never seals under a mode."),
|
|
205
|
-
propose_cancel: proposeTool("cancel", "Propose cancelling a task, with a reason. The approver arms and confirms it on the task itself."),
|
|
206
|
-
propose_answer: proposeTool("answer", "Propose an answer to an open decision you read with get_decision, with a rationale. The approver confirms where every consequence and the builder's recommendation are shown; an irreversible option needs their explicit confirmation."),
|
|
207
|
-
get_contract: {
|
|
208
|
-
description: "This surface's contract: what a coordinator may do, the proposal lifecycle, and the admission promise.",
|
|
209
|
-
handle: () => ({ ok: true, body: { contract: CONTRACT_GUIDE } }),
|
|
210
|
-
},
|
|
211
|
-
file_proposal: {
|
|
212
|
-
description: "File a task proposal into one of your repositories. It stays quarantined until the operator signs its scope.",
|
|
213
|
-
handle: (ctx, args) => {
|
|
214
|
-
// The token, not the pre-verified identity: filing re-authenticates
|
|
215
|
-
// INSIDE its own transaction (the session's `who` is a courtesy).
|
|
216
|
-
const outcome = fileCoordinatorProposal(ctx.store, ctx.token, { repo: args.repo, title: args.title, ...(args.intent === undefined ? {} : { intent: args.intent }), ...(args.deliverable === undefined ? {} : { deliverable: args.deliverable }), idempotencyKey: args.idempotency_key }, ctx.now);
|
|
217
|
-
if (!outcome.ok)
|
|
218
|
-
return { ok: false, message: outcome.message };
|
|
219
|
-
return {
|
|
220
|
-
ok: true,
|
|
221
|
-
body: {
|
|
222
|
-
ref: outcome.id,
|
|
223
|
-
replayed: outcome.replayed,
|
|
224
|
-
admission: "quarantined until the operator signs its scope — the ceremony shows them your name",
|
|
225
|
-
},
|
|
226
|
-
};
|
|
227
|
-
},
|
|
228
|
-
},
|
|
229
|
-
};
|
|
230
|
-
/** The gateway's tools in tools/list order, each inputSchema derived from its contract. */
|
|
231
|
-
const TOOLS = Object.keys(GATEWAY_TOOL_INPUTS).map((name) => {
|
|
232
|
-
const handler = HANDLERS[name];
|
|
233
|
-
return { name, description: handler.description, inputSchema: toModelSchema(GATEWAY_TOOL_INPUTS[name]), handle: (ctx, args) => handler.handle(ctx, args) };
|
|
234
|
-
});
|
|
31
|
+
import { authenticateCoordinator } from "./coordinator.js";
|
|
32
|
+
import { LEGACY, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, TOOLS, UNSUPPORTED_VERSION, callCoordinatorTool, capabilitiesShape, readMessage, shapeResult, toolsPayload, } from "./mcp-core.js";
|
|
33
|
+
export { LEGACY, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, UNSUPPORTED_VERSION } from "./mcp-core.js";
|
|
235
34
|
function fileIdentity(path) {
|
|
236
35
|
if (path === null)
|
|
237
36
|
return null;
|
|
@@ -286,16 +85,8 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
|
|
|
286
85
|
const respond = (id, era, result, cacheable) => {
|
|
287
86
|
if (id !== null && cancelled.has(JSON.stringify(id)))
|
|
288
87
|
return; // suppressed entirely
|
|
289
|
-
|
|
290
|
-
? { ...result, resultType: "complete", ...(cacheable ? { ttlMs: 0, cacheScope: "private" } : {}) }
|
|
291
|
-
: result;
|
|
292
|
-
io.write(JSON.stringify({ jsonrpc: "2.0", id, result: complete }));
|
|
88
|
+
io.write(JSON.stringify({ jsonrpc: "2.0", id, result: shapeResult(era, result, cacheable) }));
|
|
293
89
|
};
|
|
294
|
-
const toolsPayload = () => ({
|
|
295
|
-
// No outputSchema: MCP output schemas describe structuredContent, and
|
|
296
|
-
// these tools return text content only (round-2 finding 1).
|
|
297
|
-
tools: TOOLS.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })),
|
|
298
|
-
});
|
|
299
90
|
const callTool = (id, era, params) => {
|
|
300
91
|
// A restore or rollback puts a different file at the database's path: this server's connection still reads and
|
|
301
92
|
// writes the one it replaced, so it answers nothing more and exits (the agent starts it again on the new one).
|
|
@@ -304,112 +95,23 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
|
|
|
304
95
|
io.exit(0);
|
|
305
96
|
return;
|
|
306
97
|
}
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
error(id, -32000, "the database schema moved underneath this server — restart it");
|
|
315
|
-
io.exit(0);
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
const name = typeof params["name"] === "string" ? params["name"] : "";
|
|
319
|
-
const tool = TOOLS.find(one => one.name === name);
|
|
320
|
-
if (tool === undefined) {
|
|
321
|
-
error(id, -32602, `no tool \`${name}\``);
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
// tools/list visibility is presentation — authorization happens HERE,
|
|
325
|
-
// on every call, against the live credential row.
|
|
326
|
-
const session = authenticateCoordinator(store, token);
|
|
327
|
-
if (!session.ok) {
|
|
328
|
-
error(id, -32000, "this credential no longer stands — the server exits");
|
|
329
|
-
io.exit(0);
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
// Only an ABSENT `arguments` defaults to empty (round-3 finding 2): an
|
|
333
|
-
// explicit null is not the optional-object shape MCP defines.
|
|
334
|
-
const rawArgs = params["arguments"] === undefined ? {} : params["arguments"];
|
|
335
|
-
if (typeof rawArgs !== "object" || rawArgs === null || Array.isArray(rawArgs)) {
|
|
336
|
-
// The root of `arguments` is an object BEFORE any schema applies
|
|
337
|
-
// (round-2 finding 2): 42 or false never passes an empty schema.
|
|
338
|
-
error(id, -32602, "arguments must be an object");
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
// The call is read by the schema tools/list advertised (review finding
|
|
342
|
-
// 2): unknown fields, bad types, bad enums, and bound violations are
|
|
343
|
-
// protocol errors (InvalidParams) naming each path, never tool refusals.
|
|
344
|
-
const read = parseContract(GATEWAY_TOOL_INPUTS[tool.name], rawArgs);
|
|
345
|
-
if (!read.ok) {
|
|
346
|
-
error(id, -32602, read.issues.map(one => one.line).join("; "));
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
const answered = tool.handle({ store, who: session.who, token, enrolled, now: clock(), readDecisions, ...(evidenceRoot === undefined ? {} : { evidenceRoot }) }, read.value);
|
|
350
|
-
if (!answered.ok) {
|
|
351
|
-
respond(id, era, { content: [{ type: "text", text: answered.message }], isError: true }, false);
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
// Checked against its output schema before delivery; a disagreement is reported, never refused.
|
|
355
|
-
reportToolOutput("gateway", tool.name, GATEWAY_TOOL_OUTPUTS[tool.name], answered.body);
|
|
356
|
-
respond(id, era, { content: [{ type: "text", text: JSON.stringify(answered.body) }] }, false);
|
|
357
|
-
});
|
|
98
|
+
// A moved schema or a credential that no longer stands refuses in words and the server exits clean.
|
|
99
|
+
const outcome = callCoordinatorTool({ store, token, enrolled, clock, readDecisions, ...(evidenceRoot === undefined ? {} : { evidenceRoot }) }, params);
|
|
100
|
+
if (outcome.kind === "result")
|
|
101
|
+
return respond(id, era, outcome.result, false);
|
|
102
|
+
error(id, outcome.code, outcome.message);
|
|
103
|
+
if (outcome.fatal)
|
|
104
|
+
io.exit(0);
|
|
358
105
|
};
|
|
359
106
|
io.onLine(line => {
|
|
360
107
|
if (line.trim() === "")
|
|
361
108
|
return;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
366
|
-
let message;
|
|
367
|
-
try {
|
|
368
|
-
message = JSON.parse(line);
|
|
369
|
-
}
|
|
370
|
-
catch {
|
|
371
|
-
error(null, -32700, "parse error");
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
if (Array.isArray(message)) {
|
|
375
|
-
// Batching left MCP in 2025-06-18 — neither pinned era accepts it.
|
|
376
|
-
error(null, -32600, "JSON-RPC batching is not part of MCP — send one request per line");
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
if (message === null || typeof message !== "object" || depthOf(message) > MAX_DEPTH) {
|
|
380
|
-
error(null, -32600, "invalid request");
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
const request = message;
|
|
384
|
-
if (request["jsonrpc"] !== "2.0") {
|
|
385
|
-
error(null, -32600, 'jsonrpc must be "2.0"');
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
// A notification is the message that carries NO id — the method name
|
|
389
|
-
// never decides (round-3 finding 3): notifications/initialized WITH an
|
|
390
|
-
// id is a malformed message, not a quiet notification. MCP RequestId is
|
|
391
|
-
// a string or an INTEGER (round-2 finding 2): floats, null, and object
|
|
392
|
-
// shapes refuse wherever an id appears.
|
|
393
|
-
const hasId = "id" in request;
|
|
394
|
-
const rawId = request["id"];
|
|
395
|
-
if (hasId && (rawId === null || (typeof rawId !== "string" && !(typeof rawId === "number" && Number.isInteger(rawId))))) {
|
|
396
|
-
error(null, -32600, "id must be a string or an integer");
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
const method = typeof request["method"] === "string" ? request["method"] : "";
|
|
400
|
-
if (method.startsWith("notifications/")) {
|
|
401
|
-
if (hasId) {
|
|
402
|
-
error(rawId, -32600, "a notification carries no id");
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
else if (!hasId) {
|
|
407
|
-
error(null, -32600, "a request carries an id — a string or an integer");
|
|
109
|
+
const read = readMessage(line);
|
|
110
|
+
if (!read.ok) {
|
|
111
|
+
error(read.id, read.code, read.message);
|
|
408
112
|
return;
|
|
409
113
|
}
|
|
410
|
-
const id
|
|
411
|
-
const params = (request["params"] ?? {});
|
|
412
|
-
const meta = (params["_meta"] ?? {});
|
|
114
|
+
const { id, method, params, meta } = read;
|
|
413
115
|
// Every method — lifecycle included — refuses on a moved schema
|
|
414
116
|
// (review finding 3): a server that answers discover from one world
|
|
415
117
|
// and tools from another is lying to somebody.
|
|
@@ -453,9 +155,6 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
|
|
|
453
155
|
}));
|
|
454
156
|
return;
|
|
455
157
|
}
|
|
456
|
-
// ClientCapabilities is an OBJECT shape (round-3 finding 5): an array
|
|
457
|
-
// is not a capabilities declaration.
|
|
458
|
-
const capabilitiesShape = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
459
158
|
if (method === "initialize") {
|
|
460
159
|
if (pinnedEra === "modern") {
|
|
461
160
|
error(id, -32600, "this connection speaks the modern era — initialize belongs to the handshake era");
|
|
@@ -489,7 +188,7 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
|
|
|
489
188
|
supportedVersions: [MODERN, LEGACY],
|
|
490
189
|
capabilities: { tools: {} },
|
|
491
190
|
_meta: { [META_SERVER]: { name: "toolroll", version: PACKAGE_VERSION } },
|
|
492
|
-
...toolsPayload(),
|
|
191
|
+
...toolsPayload(TOOLS),
|
|
493
192
|
}, true);
|
|
494
193
|
return;
|
|
495
194
|
}
|
|
@@ -523,7 +222,7 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
|
|
|
523
222
|
return;
|
|
524
223
|
}
|
|
525
224
|
if (method === "tools/list") {
|
|
526
|
-
respond(id, era, toolsPayload(), true);
|
|
225
|
+
respond(id, era, toolsPayload(TOOLS), true);
|
|
527
226
|
return;
|
|
528
227
|
}
|
|
529
228
|
if (method === "tools/call") {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** The audited remote argument vocabulary. Unlisted flags and extra positionals never dispatch.
|
|
2
|
+
* Keep scalar inputs explicit: adding a resource argument requires classifying it here first.
|
|
3
|
+
* Indices below are relative to the command row (after `task review`, `flows trigger add`, etc.). */
|
|
4
|
+
import type { Store } from './store.js';
|
|
5
|
+
type Kind = 'value' | 'task' | 'run' | 'review' | 'flow' | 'trigger' | 'trigger-json' | 'steps' | 'refused' | 'step-up';
|
|
6
|
+
type Argument = {
|
|
7
|
+
name: string;
|
|
8
|
+
kind: Kind;
|
|
9
|
+
optional: boolean;
|
|
10
|
+
rest: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type RemoteArguments = {
|
|
13
|
+
positionals: readonly Argument[];
|
|
14
|
+
flags: ReadonlyMap<string, Kind>;
|
|
15
|
+
};
|
|
16
|
+
/** All remotely allowed rows, including those with no resource references. Credentials and file-output
|
|
17
|
+
* flags are refused by the outer boundary. `source` on memory is provenance text; on revise it is a scope
|
|
18
|
+
* digest. Neither dereferences a conversation, task or proposal. Creation IDs are never looked up. */
|
|
19
|
+
export declare const REMOTE_ARGUMENTS: ReadonlyMap<string, RemoteArguments>;
|
|
20
|
+
export declare const REMOTE_NOT_FOUND: {
|
|
21
|
+
readonly ok: false;
|
|
22
|
+
readonly reason: 'not-found';
|
|
23
|
+
readonly message: 'Not found.';
|
|
24
|
+
};
|
|
25
|
+
type Result = {
|
|
26
|
+
ok: true;
|
|
27
|
+
repo: string | null;
|
|
28
|
+
taskId: string | null;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
reason: string;
|
|
32
|
+
message: string;
|
|
33
|
+
};
|
|
34
|
+
/** Read-only authorization of every supplied reference, before the handler can disclose or mutate it. */
|
|
35
|
+
export declare function remoteArgumentsOf(store: Store, allows: (repo: string | null) => boolean, invocation: string, positional: readonly string[], flags: ReadonlyMap<string, string | true>, named: string | null, files: Readonly<Record<string, string>>): Result;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
const args = (positionals = '', values = '', references = {}) => ({
|
|
2
|
+
positionals: positionals.split(' ').filter(Boolean).map(word => {
|
|
3
|
+
const [name = '', type = 'value'] = word.split(':');
|
|
4
|
+
return { name: name.replace(/[?*]$/, ''), kind: type, optional: name.endsWith('?'), rest: name.endsWith('*') };
|
|
5
|
+
}),
|
|
6
|
+
flags: new Map([...['json', 'help', 'repo', ...values.split(' ').filter(Boolean)].map(name => [name, 'value']), ...Object.entries(references)]),
|
|
7
|
+
});
|
|
8
|
+
/** All remotely allowed rows, including those with no resource references. Credentials and file-output
|
|
9
|
+
* flags are refused by the outer boundary. `source` on memory is provenance text; on revise it is a scope
|
|
10
|
+
* digest. Neither dereferences a conversation, task or proposal. Creation IDs are never looked up. */
|
|
11
|
+
export const REMOTE_ARGUMENTS = new Map([
|
|
12
|
+
...['status', 'ready', 'grants', 'gaps', 'sync', 'runner list', 'coordinator list', 'incident list',
|
|
13
|
+
'routine list', 'intake show', 'intake run', 'template list', 'webhook status', 'webhook test', 'review show',
|
|
14
|
+
'chat-approval show', 'chat-approval off'].map(row => [row, args()]),
|
|
15
|
+
['integrations', args('', 'saved')],
|
|
16
|
+
['outbox list', args('', 'all')],
|
|
17
|
+
['task add', args('title*', 'key report checks', { id: 'refused', replaces: 'task' })],
|
|
18
|
+
['task ask', args('id:task', 'person why')],
|
|
19
|
+
['task checks', args('task:task', 'level')],
|
|
20
|
+
['task add-tests', args('task:task')],
|
|
21
|
+
['task list', args('', 'state view limit cursor')],
|
|
22
|
+
['task show', args('id:task')],
|
|
23
|
+
['task wait', args('id:task', 'timeout')],
|
|
24
|
+
['task complete', args('task:task', 'digest', { run: 'run', 'pull-request': 'refused' })],
|
|
25
|
+
['task revise', args('task:task', 'feedback source key', { run: 'run' })],
|
|
26
|
+
['task state', args('id:task state', 'key reason', { 'replaced-by': 'task' })],
|
|
27
|
+
['task block', args('id:task', 'key', { on: 'task' })],
|
|
28
|
+
['task unblock', args('id:task', 'key', { on: 'task' })],
|
|
29
|
+
['task next', args('id:task', 'key undo')],
|
|
30
|
+
['task steer', args('id:task', 'note')],
|
|
31
|
+
['task assign', args('id:task', 'key runner anyone')],
|
|
32
|
+
['task scope', args('id:task', 'key goal not touches budget-usd risk acceptance candidate compare model provider race race-count race-per-usd race-total-usd repair-model')],
|
|
33
|
+
['task plan', args('id:task', 'key provider model')],
|
|
34
|
+
['task hold', args('id:task', 'key reason until')],
|
|
35
|
+
['task unhold', args('id:task', 'key')],
|
|
36
|
+
['task require', args('id:task', 'key cap require')],
|
|
37
|
+
['task requeue', args('id:task')],
|
|
38
|
+
['task review', args('id:review', 'brief all', { run: 'run' })],
|
|
39
|
+
['task repair', args('run:run', '', { yes: 'step-up' })],
|
|
40
|
+
['task route', args('id:task', 'risk phase provider model clear-phase digest size risky')],
|
|
41
|
+
['task reopen', args('id:task')],
|
|
42
|
+
['task stop', args('id:task', '', { run: 'run' })],
|
|
43
|
+
['task resume', args('id:task', '', { run: 'run' })],
|
|
44
|
+
['check-progress', args('run:run')],
|
|
45
|
+
['cap list', args()], ['cap add', args('name', 'kind probe expires')],
|
|
46
|
+
// These objects are installation-scoped at the outer boundary; only an all-project principal reaches them.
|
|
47
|
+
['incident resolve', args('incident')],
|
|
48
|
+
['routine show', args('id')],
|
|
49
|
+
['routine add', args('name', 'goal schedule acceptance ceiling budget-usd not touches require')],
|
|
50
|
+
...['refresh', 'pause', 'resume', 'run-now'].map(action => [`routine ${action}`, args('name')]),
|
|
51
|
+
['config show', args('phase?')], ['verify show', args()],
|
|
52
|
+
['intake preview', args()], ['intake pr-comments', args('', 'limit')],
|
|
53
|
+
['template show', args('name')],
|
|
54
|
+
['contest show', args('id')], ['contest exclude', args('contest ordinal')],
|
|
55
|
+
...['search', 'impact'].map(action => [`knowledge ${action}`, args('query', 'base')]),
|
|
56
|
+
['knowledge refresh', args('', 'base')],
|
|
57
|
+
// Decision reads and supersession are already project-bound in project-memory.ts. Proposal IDs are
|
|
58
|
+
// accepted only by memory apply (step-up), never by the listing commands below.
|
|
59
|
+
...['search', 'decisions', 'show', 'decide', 'retire', 'review', 'status'].map(action => [`memory ${action}`,
|
|
60
|
+
args(['search', 'decide'].includes(action) ? 'query*' : ['show', 'retire'].includes(action) ? 'query' : '', 'why reason supersedes source all decision sessions no-local')]),
|
|
61
|
+
['flows list', args()],
|
|
62
|
+
['flows show', args('flow:flow')], ['flows export', args('flow:flow')],
|
|
63
|
+
['flows create', args('', 'yes name template', { steps: 'steps' })],
|
|
64
|
+
['flows edit', args('flow:flow', 'yes name', { steps: 'steps' })],
|
|
65
|
+
['flows trigger add', args('flow:flow trigger:trigger-json', 'yes')],
|
|
66
|
+
...['pause', 'resume', 'remove'].map(action => [`flows trigger ${action}`, args('flow:flow trigger:trigger')]),
|
|
67
|
+
['flows card add', args('flow:flow', 'title description zone')],
|
|
68
|
+
['flows archive', args('flow:flow', 'yes')],
|
|
69
|
+
['models status', args('', 'provider')], ['models list', args('', 'provider')],
|
|
70
|
+
]);
|
|
71
|
+
export const REMOTE_NOT_FOUND = { ok: false, reason: 'not-found', message: 'Not found.' };
|
|
72
|
+
const positiveId = (value) => /^[1-9][0-9]*$/.test(value) && Number.isSafeInteger(Number(value));
|
|
73
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
74
|
+
/** Read-only authorization of every supplied reference, before the handler can disclose or mutate it. */
|
|
75
|
+
export function remoteArgumentsOf(store, allows, invocation, positional, flags, named, files) {
|
|
76
|
+
const audit = REMOTE_ARGUMENTS.get(invocation);
|
|
77
|
+
if (audit === undefined)
|
|
78
|
+
return REMOTE_NOT_FOUND;
|
|
79
|
+
const values = positional.slice(invocation.split(' ').length - 1);
|
|
80
|
+
const resolved = [];
|
|
81
|
+
const admit = (repo) => typeof repo === 'string' && allows(repo) && (named === null || named === repo);
|
|
82
|
+
const input = (value) => {
|
|
83
|
+
const raw = value.trim().startsWith('{') || value.trim().startsWith('[') ? value
|
|
84
|
+
: Object.prototype.hasOwnProperty.call(files, value) ? files[value] : undefined;
|
|
85
|
+
if (raw === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(raw);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const check = (kind, value) => {
|
|
95
|
+
if (kind === 'value')
|
|
96
|
+
return true;
|
|
97
|
+
if (kind === 'review')
|
|
98
|
+
return check(flags.has('brief') ? 'task' : 'run', value);
|
|
99
|
+
if (kind === 'task' || kind === 'run') {
|
|
100
|
+
if (kind === 'task' && (!value || value.length > 64 || /[\x00-\x1f\x7f]/.test(value)))
|
|
101
|
+
return false;
|
|
102
|
+
if (kind === 'run' && !positiveId(value))
|
|
103
|
+
return false;
|
|
104
|
+
const run = kind === 'run' ? store.getRun(Number(value)) : null;
|
|
105
|
+
const ref = kind === 'run' ? run === null ? null : store.refById(run.taskRef) : store.lookupRef(value);
|
|
106
|
+
if (ref === null || !admit(ref.repo) || store.lookupRef(ref.externalId)?.id !== ref.id || store.getTask(ref.externalId) === null)
|
|
107
|
+
return false;
|
|
108
|
+
resolved.push({ repo: ref.repo, taskId: ref.externalId, taskRef: ref.id, kind });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (kind === 'flow' || kind === 'trigger') {
|
|
112
|
+
if (!positiveId(value) || value.length > 10)
|
|
113
|
+
return false;
|
|
114
|
+
const trigger = kind === 'trigger' ? store.getFlowTrigger(Number(value)) : null;
|
|
115
|
+
const flow = kind === 'flow' ? store.getFlow(Number(value)) : trigger === null ? null : store.getFlow(trigger.flow);
|
|
116
|
+
if (flow === null || flow.state !== 'active' || !admit(flow.repo) || trigger?.state === 'removed')
|
|
117
|
+
return false;
|
|
118
|
+
resolved.push({ repo: flow.repo, taskId: null, ...(kind === 'flow' ? { flow: flow.id } : { parentFlow: trigger.flow }), kind });
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (kind === 'trigger-json') {
|
|
122
|
+
const config = input(value);
|
|
123
|
+
// Invalid/unavailable input cannot reach a resource lookup; the handler reports its existing file/JSON error.
|
|
124
|
+
if (!object(config))
|
|
125
|
+
return true;
|
|
126
|
+
return config['kind'] !== 'flow' || (typeof config['flow'] === 'number' && check('flow', String(config['flow'])));
|
|
127
|
+
}
|
|
128
|
+
if (kind === 'steps') {
|
|
129
|
+
const config = input(value), steps = object(config) ? config['steps'] : config;
|
|
130
|
+
if (!Array.isArray(steps))
|
|
131
|
+
return true;
|
|
132
|
+
// Build steps can name another project. Check the token ceiling as well as the owner's account grants.
|
|
133
|
+
return steps.every(step => !object(step) || step['repo'] === undefined ||
|
|
134
|
+
typeof step['repo'] === 'string' && store.knownRepos().includes(step['repo']) && allows(step['repo']));
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
};
|
|
138
|
+
for (const [name, value] of flags) {
|
|
139
|
+
const kind = audit.flags.get(name);
|
|
140
|
+
if (kind === 'step-up')
|
|
141
|
+
return { ok: false, reason: 'step-up', message: 'approve in the console or chat' };
|
|
142
|
+
if (kind === 'refused')
|
|
143
|
+
return { ok: false, reason: 'not-remote', message: 'That option is not available remotely.' };
|
|
144
|
+
if (kind === undefined)
|
|
145
|
+
return { ok: false, reason: 'usage', message: 'That option is not available for this remote command.' };
|
|
146
|
+
// Resolve positionals first below so the primary ledger target is stable regardless of flag order.
|
|
147
|
+
if (kind !== 'value' && typeof value !== 'string')
|
|
148
|
+
return REMOTE_NOT_FOUND;
|
|
149
|
+
}
|
|
150
|
+
for (let i = 0; i < audit.positionals.length; i++) {
|
|
151
|
+
const arg = audit.positionals[i];
|
|
152
|
+
const value = values[i];
|
|
153
|
+
if (value === undefined) {
|
|
154
|
+
if (arg.optional)
|
|
155
|
+
continue;
|
|
156
|
+
return REMOTE_NOT_FOUND;
|
|
157
|
+
}
|
|
158
|
+
if (!check(arg.kind, value))
|
|
159
|
+
return REMOTE_NOT_FOUND;
|
|
160
|
+
}
|
|
161
|
+
if (values.length > audit.positionals.length && !audit.positionals.at(-1)?.rest)
|
|
162
|
+
return REMOTE_NOT_FOUND;
|
|
163
|
+
for (const [name, value] of flags)
|
|
164
|
+
if (!check(audit.flags.get(name), String(value)))
|
|
165
|
+
return REMOTE_NOT_FOUND;
|
|
166
|
+
const task = resolved.find(ref => ref.kind === 'task');
|
|
167
|
+
for (const ref of resolved) {
|
|
168
|
+
if (ref.kind === 'run' && task !== undefined && ref.taskRef !== task.taskRef) {
|
|
169
|
+
// Review/revise accept a saved result from the same revision family. Stop/resume name an exact attempt.
|
|
170
|
+
const runFamily = store.taskFamilyOf(ref.taskId, [ref.repo], false);
|
|
171
|
+
const taskFamily = store.taskFamilyOf(task.taskId, [task.repo], false);
|
|
172
|
+
if (!['task review', 'task revise'].includes(invocation) || ref.repo !== task.repo || runFamily === null || taskFamily === null ||
|
|
173
|
+
runFamily.root.id !== taskFamily.root.id)
|
|
174
|
+
return REMOTE_NOT_FOUND;
|
|
175
|
+
}
|
|
176
|
+
if (ref.parentFlow !== undefined && ref.parentFlow !== resolved.find(one => one.kind === 'flow')?.flow)
|
|
177
|
+
return REMOTE_NOT_FOUND;
|
|
178
|
+
}
|
|
179
|
+
if (invocation === 'task repair') {
|
|
180
|
+
const draft = store.repairChainFor(Number(values[0]))?.draftTask;
|
|
181
|
+
if (draft != null && (!check('task', draft) || resolved.at(-1)?.repo !== resolved[0]?.repo))
|
|
182
|
+
return REMOTE_NOT_FOUND;
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, repo: resolved[0]?.repo ?? named, taskId: resolved[0]?.taskId ?? null };
|
|
185
|
+
}
|