@p4code/cli 0.6.3 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.mjs +390 -40
- package/dist/client/assets/{DiffPanel-Dz2KC-p6.js → DiffPanel-DfuqXVpG.js} +2 -2
- package/dist/client/assets/{FilePreviewPanel-D3Hb6YHz.js → FilePreviewPanel-BT2cVEr7.js} +2 -2
- package/dist/client/assets/{PreviewPanel-CHM6nHtt.js → PreviewPanel-Cvzdihek.js} +2 -2
- package/dist/client/assets/{PullRequestCodeTab-BPiTYW6L.js → PullRequestCodeTab-BWT2wgTE.js} +2 -2
- package/dist/client/assets/{fileCommentAnnotations-bier3V4K.js → fileCommentAnnotations-j7Nl3tNy.js} +2 -2
- package/dist/client/assets/{index-BMR_XqN8.js → index-DE12bQJs.js} +11 -6
- package/dist/client/assets/{previewAssetResource-oaiSyQyE.js → previewAssetResource-x8WpJWsS.js} +25 -25
- package/dist/client/assets/{renderFileChildren-CVP7M3Sh.js → renderFileChildren-BvcSiOwh.js} +2 -2
- package/dist/client/assets/{toggle-group-CUemgizn.js → toggle-group-zU_XUcK4.js} +2 -2
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
package/dist/bin.mjs
CHANGED
|
@@ -266,7 +266,7 @@ const make$122 = () => {
|
|
|
266
266
|
const layer$123 = Layer.sync(NetService, make$122);
|
|
267
267
|
//#endregion
|
|
268
268
|
//#region package.json
|
|
269
|
-
var version$1 = "0.6.
|
|
269
|
+
var version$1 = "0.6.4";
|
|
270
270
|
//#endregion
|
|
271
271
|
//#region src/config.ts
|
|
272
272
|
/**
|
|
@@ -12648,6 +12648,44 @@ const TicketResolveResult = Schema$1.Struct({
|
|
|
12648
12648
|
*/
|
|
12649
12649
|
description: Schema$1.optional(TrimmedNonEmptyString)
|
|
12650
12650
|
});
|
|
12651
|
+
/** One link filed alongside a comment: the proof, and what to call it. */
|
|
12652
|
+
const TicketCommentAttachment = Schema$1.Struct({
|
|
12653
|
+
url: TrimmedNonEmptyString.pipe(Schema$1.annotateEncoded({ description: "Publicly reachable URL of the proof to file against the ticket. Local file paths cannot be attached." })),
|
|
12654
|
+
title: Schema$1.optional(TrimmedNonEmptyString.pipe(Schema$1.annotateEncoded({ description: "Label for the link. Defaults to the URL itself." })))
|
|
12655
|
+
});
|
|
12656
|
+
/**
|
|
12657
|
+
* One comment on a ticket, with the links that belong to it.
|
|
12658
|
+
*
|
|
12659
|
+
* Commenting rather than editing: closeout writes what happened, and the
|
|
12660
|
+
* description belongs to whoever filed the ticket. The attachments ride along
|
|
12661
|
+
* because proof without the sentence explaining it is an orphan link.
|
|
12662
|
+
*/
|
|
12663
|
+
const TicketCommentInput = Schema$1.Struct({
|
|
12664
|
+
reference: TrimmedNonEmptyString.pipe(Schema$1.annotateEncoded({ description: "The ticket as it was written: an identifier like MOBILE-12241, or a tracker URL." })),
|
|
12665
|
+
body: TrimmedNonEmptyString.pipe(Schema$1.annotateEncoded({ description: "The comment, in Markdown, exactly as it should appear on the ticket." })),
|
|
12666
|
+
attachments: Schema$1.optional(Schema$1.Array(TicketCommentAttachment))
|
|
12667
|
+
});
|
|
12668
|
+
/** A link the tracker would not file, and the sentence explaining why. */
|
|
12669
|
+
const TicketCommentAttachmentFailure = Schema$1.Struct({
|
|
12670
|
+
url: TrimmedNonEmptyString,
|
|
12671
|
+
detail: Schema$1.String
|
|
12672
|
+
});
|
|
12673
|
+
const TicketCommentResult = Schema$1.Struct({
|
|
12674
|
+
externalRef: TaskExternalRef,
|
|
12675
|
+
/** Absent when the tracker filed the comment and reported no link to it. */
|
|
12676
|
+
commentUrl: Schema$1.optional(TrimmedNonEmptyString),
|
|
12677
|
+
/** The attachments that landed, in the order they were asked for. */
|
|
12678
|
+
attachedUrls: Schema$1.Array(TrimmedNonEmptyString),
|
|
12679
|
+
/**
|
|
12680
|
+
* The attachments that did not land.
|
|
12681
|
+
*
|
|
12682
|
+
* Reported rather than thrown, because the comment is already on the ticket
|
|
12683
|
+
* by the time a link can fail: a caller told only "it failed" retries the
|
|
12684
|
+
* whole call and files the comment twice. With this it can retry the links
|
|
12685
|
+
* alone, or say in the thread which proof is missing.
|
|
12686
|
+
*/
|
|
12687
|
+
failedAttachments: Schema$1.Array(TicketCommentAttachmentFailure)
|
|
12688
|
+
});
|
|
12651
12689
|
/**
|
|
12652
12690
|
* Why a reference could not be turned into a ticket.
|
|
12653
12691
|
*
|
|
@@ -30894,6 +30932,34 @@ Layer.succeed(ClaudeMcpFiles, {
|
|
|
30894
30932
|
const PROBE_CACHE_TTL_MS = 300 * 1e3;
|
|
30895
30933
|
/** An authorize URL nobody returned from within this window is abandoned. */
|
|
30896
30934
|
const PENDING_FLOW_TTL_MS = 600 * 1e3;
|
|
30935
|
+
/**
|
|
30936
|
+
* The two answers that mean the credential, not the request, was refused.
|
|
30937
|
+
*
|
|
30938
|
+
* The same pair `McpToolClient` reports as "the server rejected p4code's
|
|
30939
|
+
* credentials": 401 for a token the server will not accept, 403 for one it
|
|
30940
|
+
* accepts and will not act on. Both leave the session with a server it cannot
|
|
30941
|
+
* use, so both take the one refresh and, failing that, the sign-in.
|
|
30942
|
+
*/
|
|
30943
|
+
const credentialsRefused = (status) => status === 401 || status === 403;
|
|
30944
|
+
/**
|
|
30945
|
+
* Whether a token endpoint's answer means the grant itself is gone.
|
|
30946
|
+
*
|
|
30947
|
+
* RFC 6749 gives a retired or rejected refresh token one spelling: 400 with
|
|
30948
|
+
* `error: "invalid_grant"`. The other 400s - `invalid_request`,
|
|
30949
|
+
* `unsupported_grant_type`, `invalid_scope` - are complaints about the request
|
|
30950
|
+
* this code sent, and erasing the user's credential over a malformed request
|
|
30951
|
+
* of ours would charge them a sign-in for a bug. 401 and 403 stay terminal:
|
|
30952
|
+
* the client credential itself was refused, and no retry of this shape fixes
|
|
30953
|
+
* that. Everything else - 429, a 5xx, whatever a proxy invents - is the server
|
|
30954
|
+
* having a bad minute.
|
|
30955
|
+
*/
|
|
30956
|
+
const terminalRefreshAnswer = (status, json) => {
|
|
30957
|
+
if (status === 401 || status === 403) return true;
|
|
30958
|
+
if (status !== 400) return false;
|
|
30959
|
+
return (typeof json === "object" && json !== null ? json["error"] : void 0) === "invalid_grant";
|
|
30960
|
+
};
|
|
30961
|
+
/** Why one refresh attempt failed, and whether the stored grant survives it. */
|
|
30962
|
+
var RefreshFailure = class extends Data.TaggedError("RefreshFailure") {};
|
|
30897
30963
|
/** A token this close to expiry is refreshed rather than handed to a session. */
|
|
30898
30964
|
const TOKEN_EXPIRY_MARGIN_MS = 60 * 1e3;
|
|
30899
30965
|
/** Outbound discovery, registration and token requests all use this cap. */
|
|
@@ -31005,6 +31071,7 @@ const make$109 = Effect.gen(function* () {
|
|
|
31005
31071
|
const crypto = yield* Crypto.Crypto;
|
|
31006
31072
|
const pendingFlows = yield* Ref.make(/* @__PURE__ */ new Map());
|
|
31007
31073
|
const probeCache = yield* Ref.make(/* @__PURE__ */ new Map());
|
|
31074
|
+
const tokenVerdicts = yield* Ref.make(/* @__PURE__ */ new Map());
|
|
31008
31075
|
const redirectUri = `http://127.0.0.1:${config.port}${OAUTH_CALLBACK_PATH}`;
|
|
31009
31076
|
const nowMillis = Effect.map(DateTime.now, DateTime.toEpochMillis);
|
|
31010
31077
|
const readRecord = Effect.fn("McpOAuth.readRecord")(function* (server) {
|
|
@@ -31036,26 +31103,28 @@ const make$109 = Effect.gen(function* () {
|
|
|
31036
31103
|
* does — while answering the initialize POST with the 401 challenge this
|
|
31037
31104
|
* probe exists to see.
|
|
31038
31105
|
*/
|
|
31106
|
+
const initializeRequest = (registration, accessToken) => HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.setHeaders(HttpClientRequest.post(registration.url), {
|
|
31107
|
+
...registration.headers,
|
|
31108
|
+
...accessToken === void 0 ? {} : { authorization: `Bearer ${accessToken}` },
|
|
31109
|
+
accept: "application/json, text/event-stream"
|
|
31110
|
+
}), {
|
|
31111
|
+
jsonrpc: "2.0",
|
|
31112
|
+
id: 0,
|
|
31113
|
+
method: "initialize",
|
|
31114
|
+
params: {
|
|
31115
|
+
protocolVersion: MCP_PROBE_PROTOCOL_VERSION,
|
|
31116
|
+
capabilities: {},
|
|
31117
|
+
clientInfo: {
|
|
31118
|
+
name: "p4code",
|
|
31119
|
+
version: "0.0.0"
|
|
31120
|
+
}
|
|
31121
|
+
}
|
|
31122
|
+
});
|
|
31039
31123
|
const probe = Effect.fn("McpOAuth.probe")(function* (registration) {
|
|
31040
31124
|
const now = yield* nowMillis;
|
|
31041
31125
|
const cached = (yield* Ref.get(probeCache)).get(registration.url);
|
|
31042
31126
|
if (cached !== void 0 && now - cached.at < PROBE_CACHE_TTL_MS) return cached;
|
|
31043
|
-
const verdict = yield* http.execute(
|
|
31044
|
-
...registration.headers,
|
|
31045
|
-
accept: "application/json, text/event-stream"
|
|
31046
|
-
}), {
|
|
31047
|
-
jsonrpc: "2.0",
|
|
31048
|
-
id: 0,
|
|
31049
|
-
method: "initialize",
|
|
31050
|
-
params: {
|
|
31051
|
-
protocolVersion: MCP_PROBE_PROTOCOL_VERSION,
|
|
31052
|
-
capabilities: {},
|
|
31053
|
-
clientInfo: {
|
|
31054
|
-
name: "p4code",
|
|
31055
|
-
version: "0.0.0"
|
|
31056
|
-
}
|
|
31057
|
-
}
|
|
31058
|
-
})).pipe(Effect.timeout(OAUTH_REQUEST_TIMEOUT_MS), Effect.map((response) => {
|
|
31127
|
+
const verdict = yield* http.execute(initializeRequest(registration)).pipe(Effect.timeout(OAUTH_REQUEST_TIMEOUT_MS), Effect.map((response) => {
|
|
31059
31128
|
const challengeHeader = response.headers["www-authenticate"];
|
|
31060
31129
|
const challenge = response.status === 401 && challengeHeader !== void 0 ? parseWwwAuthenticate(challengeHeader) : void 0;
|
|
31061
31130
|
return {
|
|
@@ -31082,12 +31151,53 @@ const make$109 = Effect.gen(function* () {
|
|
|
31082
31151
|
next.delete(url);
|
|
31083
31152
|
return next;
|
|
31084
31153
|
});
|
|
31154
|
+
/**
|
|
31155
|
+
* Ask the server whether the token this machine holds still works.
|
|
31156
|
+
*
|
|
31157
|
+
* `probe` cannot answer this. It asks without credentials, so its 401 is the
|
|
31158
|
+
* one every protected server sends and says nothing about the token sitting
|
|
31159
|
+
* in the record. A token revoked at the other end, or issued for a workspace
|
|
31160
|
+
* the user has since left, stays well inside its stored expiry and is
|
|
31161
|
+
* refused on every call - the state this catches.
|
|
31162
|
+
*
|
|
31163
|
+
* Unreachable counts as usable, exactly as `probe` treats it: a network
|
|
31164
|
+
* fault is not a revocation, and signing someone out of a server that is
|
|
31165
|
+
* merely down helps nobody. Only a real answer is remembered.
|
|
31166
|
+
*/
|
|
31167
|
+
const validateToken = Effect.fn("McpOAuth.validateToken")(function* (registration, accessToken) {
|
|
31168
|
+
const now = yield* nowMillis;
|
|
31169
|
+
const cached = (yield* Ref.get(tokenVerdicts)).get(registration.name);
|
|
31170
|
+
if (cached !== void 0 && cached.accessToken === accessToken) {
|
|
31171
|
+
if (!cached.accepted) return false;
|
|
31172
|
+
if (now - cached.at < PROBE_CACHE_TTL_MS) return true;
|
|
31173
|
+
}
|
|
31174
|
+
const answered = yield* http.execute(initializeRequest(registration, accessToken)).pipe(Effect.timeout(OAUTH_REQUEST_TIMEOUT_MS), Effect.map((response) => Option.some(!credentialsRefused(response.status))), Effect.orElseSucceed(() => Option.none()));
|
|
31175
|
+
if (Option.isNone(answered)) return true;
|
|
31176
|
+
yield* Ref.update(tokenVerdicts, (cache) => {
|
|
31177
|
+
const next = new Map(cache);
|
|
31178
|
+
next.set(registration.name, {
|
|
31179
|
+
accessToken,
|
|
31180
|
+
at: now,
|
|
31181
|
+
accepted: answered.value
|
|
31182
|
+
});
|
|
31183
|
+
return next;
|
|
31184
|
+
});
|
|
31185
|
+
return answered.value;
|
|
31186
|
+
});
|
|
31187
|
+
/** Whether the server has refused the exact token the record still holds. */
|
|
31188
|
+
const rejectedAlready = (server, accessToken) => Effect.map(Ref.get(tokenVerdicts), (cache) => {
|
|
31189
|
+
const verdict = cache.get(server);
|
|
31190
|
+
return verdict !== void 0 && verdict.accessToken === accessToken && !verdict.accepted;
|
|
31191
|
+
});
|
|
31085
31192
|
const statusFor = (registration) => Effect.gen(function* () {
|
|
31086
31193
|
const record = yield* readRecord(registration.name);
|
|
31087
|
-
if (Option.isSome(record) && record.value.tokens !== void 0)
|
|
31088
|
-
|
|
31089
|
-
|
|
31090
|
-
|
|
31194
|
+
if (Option.isSome(record) && record.value.tokens !== void 0) {
|
|
31195
|
+
const rejected = yield* rejectedAlready(registration.name, record.value.tokens.accessToken);
|
|
31196
|
+
return Option.some({
|
|
31197
|
+
required: true,
|
|
31198
|
+
connected: !rejected
|
|
31199
|
+
});
|
|
31200
|
+
}
|
|
31091
31201
|
if ((yield* probe(registration)).requiresAuth || Option.isSome(record)) return Option.some({
|
|
31092
31202
|
required: true,
|
|
31093
31203
|
connected: false
|
|
@@ -31262,6 +31372,24 @@ const make$109 = Effect.gen(function* () {
|
|
|
31262
31372
|
ok: false,
|
|
31263
31373
|
detail: "The sign-in failed. See the server log."
|
|
31264
31374
|
}))));
|
|
31375
|
+
/**
|
|
31376
|
+
* Drop the tokens the server will not accept, keeping the client
|
|
31377
|
+
* registration so the sign-in that follows skips re-registration.
|
|
31378
|
+
*
|
|
31379
|
+
* Compare-and-delete rather than a blind write, because two sessions can
|
|
31380
|
+
* resolve the same server at once. The loser of that race spends a refresh
|
|
31381
|
+
* token the winner has already rotated, is told `invalid_grant`, and would
|
|
31382
|
+
* otherwise erase the credential the winner just stored - turning a refresh
|
|
31383
|
+
* that worked into a sign-in prompt. A record whose access token has moved on
|
|
31384
|
+
* belongs to somebody else's successful refresh and is left alone.
|
|
31385
|
+
*/
|
|
31386
|
+
const forgetTokens = Effect.fn("McpOAuth.forgetTokens")(function* (server, refusedAccessToken) {
|
|
31387
|
+
const current = yield* readRecord(server);
|
|
31388
|
+
if (Option.isNone(current)) return;
|
|
31389
|
+
if (current.value.tokens?.accessToken !== refusedAccessToken) return;
|
|
31390
|
+
const { tokens: _refused, ...rest } = current.value;
|
|
31391
|
+
yield* writeRecord(server, rest);
|
|
31392
|
+
});
|
|
31265
31393
|
const disconnect = (name) => Effect.gen(function* () {
|
|
31266
31394
|
const record = yield* readRecord(name);
|
|
31267
31395
|
if (Option.isNone(record)) return;
|
|
@@ -31279,9 +31407,15 @@ const make$109 = Effect.gen(function* () {
|
|
|
31279
31407
|
...record.clientSecret === void 0 ? {} : { client_secret: record.clientSecret },
|
|
31280
31408
|
...record.resource === void 0 ? {} : { resource: record.resource }
|
|
31281
31409
|
}));
|
|
31282
|
-
if (status !== 200) return yield* Effect.fail(
|
|
31410
|
+
if (status !== 200) return yield* Effect.fail(new RefreshFailure({
|
|
31411
|
+
terminal: terminalRefreshAnswer(status, json),
|
|
31412
|
+
detail: `status ${status}`
|
|
31413
|
+
}));
|
|
31283
31414
|
const decoded = decodeTokenResponse(json);
|
|
31284
|
-
if (decoded._tag !== "Success") return yield* Effect.fail(
|
|
31415
|
+
if (decoded._tag !== "Success") return yield* Effect.fail(new RefreshFailure({
|
|
31416
|
+
terminal: false,
|
|
31417
|
+
detail: "unexpected shape"
|
|
31418
|
+
}));
|
|
31285
31419
|
const response = decoded.value;
|
|
31286
31420
|
const now = yield* nowMillis;
|
|
31287
31421
|
const tokens = {
|
|
@@ -31300,9 +31434,16 @@ const make$109 = Effect.gen(function* () {
|
|
|
31300
31434
|
if (Option.isNone(record) || record.value.tokens === void 0) return Option.none();
|
|
31301
31435
|
const tokens = record.value.tokens;
|
|
31302
31436
|
const now = yield* nowMillis;
|
|
31303
|
-
if (!(tokens.expiresAt !== void 0 && now >= tokens.expiresAt - TOKEN_EXPIRY_MARGIN_MS)) return Option.some(tokens.accessToken);
|
|
31304
|
-
if (tokens.refreshToken === void 0)
|
|
31305
|
-
|
|
31437
|
+
if (!(tokens.expiresAt !== void 0 && now >= tokens.expiresAt - TOKEN_EXPIRY_MARGIN_MS) && (yield* validateToken(registration, tokens.accessToken))) return Option.some(tokens.accessToken);
|
|
31438
|
+
if (tokens.refreshToken === void 0) {
|
|
31439
|
+
yield* forgetTokens(registration.name, tokens.accessToken);
|
|
31440
|
+
return Option.none();
|
|
31441
|
+
}
|
|
31442
|
+
return yield* refreshTokens(registration.name, record.value, tokens.refreshToken).pipe(Effect.map(Option.some), Effect.catchTag("RefreshFailure", (failure) => Effect.logWarning("mcp oauth refresh failed", {
|
|
31443
|
+
server: registration.name,
|
|
31444
|
+
detail: failure.detail,
|
|
31445
|
+
terminal: failure.terminal
|
|
31446
|
+
}).pipe(Effect.andThen(failure.terminal ? forgetTokens(registration.name, tokens.accessToken) : Effect.void), Effect.as(Option.none()))), Effect.catchCause((cause) => Effect.logWarning("mcp oauth refresh could not be attempted", {
|
|
31306
31447
|
cause,
|
|
31307
31448
|
server: registration.name
|
|
31308
31449
|
}).pipe(Effect.as(Option.none()))));
|
|
@@ -60994,8 +61135,15 @@ const FUSION_SAFETY_KERNEL = [
|
|
|
60994
61135
|
* `task_propose` is deliberately absent: it tells its caller to stop and wait
|
|
60995
61136
|
* for the user, and a supervisor that waits is one the builder sits idle
|
|
60996
61137
|
* behind. Asking the user is what `ask_user_question` is for.
|
|
61138
|
+
*
|
|
61139
|
+
* `thread_spawn` is here because starting a thread is a coordination act: when
|
|
61140
|
+
* the authorized work needs a separate checkout, a different provider or its
|
|
61141
|
+
* own pair, the half that owns the plan is the half that decides. It stays in
|
|
61142
|
+
* the builder's denial list, so the pair still has exactly one thread that can
|
|
61143
|
+
* open new work.
|
|
60997
61144
|
*/
|
|
60998
61145
|
const FUSION_SUPERVISOR_P4_TOOLS = /* @__PURE__ */ new Set([
|
|
61146
|
+
"thread_spawn",
|
|
60999
61147
|
"thread_watch_review",
|
|
61000
61148
|
"thread_watch_events",
|
|
61001
61149
|
"thread_evidence_report",
|
|
@@ -61013,7 +61161,8 @@ const FUSION_SUPERVISOR_P4_TOOLS = /* @__PURE__ */ new Set([
|
|
|
61013
61161
|
"task_list",
|
|
61014
61162
|
"task_create",
|
|
61015
61163
|
"task_update",
|
|
61016
|
-
"ticket_resolve"
|
|
61164
|
+
"ticket_resolve",
|
|
61165
|
+
"ticket_comment"
|
|
61017
61166
|
]);
|
|
61018
61167
|
/**
|
|
61019
61168
|
* What the builder half must not reach.
|
|
@@ -61023,8 +61172,9 @@ const FUSION_SUPERVISOR_P4_TOOLS = /* @__PURE__ */ new Set([
|
|
|
61023
61172
|
* those should reach it without an edit here. What it must never have is the
|
|
61024
61173
|
* pair's own machinery - spawning threads, creating pairs, advising, answering
|
|
61025
61174
|
* its own gate - the phase plan, which the supervisor owns outright, plus
|
|
61026
|
-
* task-board mutation
|
|
61027
|
-
*
|
|
61175
|
+
* task-board mutation and the tracker comment that closes a task. Both roles
|
|
61176
|
+
* may read paired history and task-relevant external evidence; only Supervisor
|
|
61177
|
+
* writes review reports and the phase plan.
|
|
61028
61178
|
*
|
|
61029
61179
|
* Both roles retain `thread_pair_detach` as their way out. Its handler limits
|
|
61030
61180
|
* detaching to a pair containing the caller whose other thread this session
|
|
@@ -61046,6 +61196,7 @@ const FUSION_BUILDER_DENIED_P4_TOOLS = /* @__PURE__ */ new Set([
|
|
|
61046
61196
|
"task_create",
|
|
61047
61197
|
"task_update",
|
|
61048
61198
|
"task_propose",
|
|
61199
|
+
"ticket_comment",
|
|
61049
61200
|
"memory_append"
|
|
61050
61201
|
]);
|
|
61051
61202
|
/**
|
|
@@ -63641,6 +63792,7 @@ const P4CODE_MCP_TOOL_NAMES = [
|
|
|
63641
63792
|
"thread_spawn",
|
|
63642
63793
|
"thread_watch_events",
|
|
63643
63794
|
"thread_watch_review",
|
|
63795
|
+
"ticket_comment",
|
|
63644
63796
|
"ticket_resolve"
|
|
63645
63797
|
];
|
|
63646
63798
|
/** The catalogue as the adapters read it. */
|
|
@@ -123186,6 +123338,9 @@ const callMcpTool = Effect.fn("McpToolClient.callMcpTool")(function* (input) {
|
|
|
123186
123338
|
* name that drifts between them fails at runtime with a shrug from the server.
|
|
123187
123339
|
*/
|
|
123188
123340
|
const LINEAR_GET_ISSUE_TOOL = "get_issue";
|
|
123341
|
+
/** The write half: one comment, and the links filed beside it. */
|
|
123342
|
+
const LINEAR_SAVE_COMMENT_TOOL = "save_comment";
|
|
123343
|
+
const LINEAR_CREATE_ATTACHMENT_TOOL = "create_attachment";
|
|
123189
123344
|
/** Why a Linear call could not be made, in terms a person can act on. */
|
|
123190
123345
|
var LinearUnavailable = class extends Schema$1.TaggedErrorClass()("LinearUnavailable", {
|
|
123191
123346
|
reason: Schema$1.Literals([
|
|
@@ -123403,10 +123558,11 @@ const graphqlRequest = Effect.fn("tracker/graphqlRequest")(function* (input) {
|
|
|
123403
123558
|
/**
|
|
123404
123559
|
* Linear over its own GraphQL API, wearing the MCP tool vocabulary.
|
|
123405
123560
|
*
|
|
123406
|
-
* This speaks the same
|
|
123407
|
-
* `
|
|
123408
|
-
*
|
|
123409
|
-
*
|
|
123561
|
+
* This speaks the same "tools" the Linear MCP server offers - `list_issues`,
|
|
123562
|
+
* `get_issue`, `save_issue`, `list_comments`, `save_comment` and
|
|
123563
|
+
* `create_attachment` - and answers in the same row shapes, so `LinearTasks`,
|
|
123564
|
+
* the resolver and the closeout path cannot tell which transport served
|
|
123565
|
+
* them. That is the point: the MCP server remains the fallback, and a
|
|
123410
123566
|
* consumer that could tell the two apart would need two code paths.
|
|
123411
123567
|
*
|
|
123412
123568
|
* The one real difference is hidden here: the MCP tools accept names where
|
|
@@ -123476,6 +123632,29 @@ const toIssueRow = (node) => {
|
|
|
123476
123632
|
parentId: parent?.["id"]
|
|
123477
123633
|
};
|
|
123478
123634
|
};
|
|
123635
|
+
/** Everything a closeout comment's caller renders back: who, when, and where. */
|
|
123636
|
+
const COMMENT_SELECTION = `
|
|
123637
|
+
id
|
|
123638
|
+
body
|
|
123639
|
+
url
|
|
123640
|
+
createdAt
|
|
123641
|
+
user { id name displayName }
|
|
123642
|
+
`;
|
|
123643
|
+
/** An attachment as Linear files it: a link with a label, against one issue. */
|
|
123644
|
+
const ATTACHMENT_SELECTION = `
|
|
123645
|
+
id
|
|
123646
|
+
url
|
|
123647
|
+
title
|
|
123648
|
+
subtitle
|
|
123649
|
+
`;
|
|
123650
|
+
/** One comment node, flattened the way `toIssueRow` flattens an issue. */
|
|
123651
|
+
const toCommentRow = (node) => ({
|
|
123652
|
+
id: node["id"],
|
|
123653
|
+
body: node["body"],
|
|
123654
|
+
url: node["url"],
|
|
123655
|
+
createdAt: node["createdAt"],
|
|
123656
|
+
user: node["user"]
|
|
123657
|
+
});
|
|
123479
123658
|
/** A case-insensitive equality filter over the ways a person is named. */
|
|
123480
123659
|
const personFilter = (person) => ({ or: [
|
|
123481
123660
|
{ displayName: { eqIgnoreCase: person } },
|
|
@@ -123648,6 +123827,107 @@ const makeLinearApiTransport = Effect.gen(function* () {
|
|
|
123648
123827
|
});
|
|
123649
123828
|
return toIssueRow(issue);
|
|
123650
123829
|
});
|
|
123830
|
+
/**
|
|
123831
|
+
* The issue's own id, from whatever a caller had - an identifier, a URL's
|
|
123832
|
+
* tail, or the id itself.
|
|
123833
|
+
*
|
|
123834
|
+
* The comment and attachment mutations take the UUID only, while every
|
|
123835
|
+
* caller here starts from what a person typed. One lookup closes that gap
|
|
123836
|
+
* and gives the "no such issue" sentence a name to report.
|
|
123837
|
+
*/
|
|
123838
|
+
const resolveIssueNodeId = (apiKey, reference) => Effect.gen(function* () {
|
|
123839
|
+
const response = yield* request(apiKey, `query($id: String!) { issue(id: $id) { id } }`, { id: reference });
|
|
123840
|
+
const issue = yield* readEntity(response, "issue");
|
|
123841
|
+
const id = asString(issue?.["id"]);
|
|
123842
|
+
if (id === void 0) return yield* new LinearUnavailable({
|
|
123843
|
+
reason: "failed",
|
|
123844
|
+
detail: `Linear has no issue "${reference}".`
|
|
123845
|
+
});
|
|
123846
|
+
return id;
|
|
123847
|
+
});
|
|
123848
|
+
const listComments = (apiKey, args) => Effect.gen(function* () {
|
|
123849
|
+
const reference = asString(args["issueId"]) ?? asString(args["id"]);
|
|
123850
|
+
if (reference === void 0) return yield* new LinearUnavailable({
|
|
123851
|
+
reason: "failed",
|
|
123852
|
+
detail: "list_comments needs an issueId."
|
|
123853
|
+
});
|
|
123854
|
+
const response = yield* request(apiKey, `query($id: String!, $first: Int!) {
|
|
123855
|
+
issue(id: $id) {
|
|
123856
|
+
comments(first: $first) {
|
|
123857
|
+
nodes { ${COMMENT_SELECTION} }
|
|
123858
|
+
}
|
|
123859
|
+
}
|
|
123860
|
+
}`, {
|
|
123861
|
+
id: reference,
|
|
123862
|
+
first: typeof args["limit"] === "number" ? args["limit"] : 50
|
|
123863
|
+
});
|
|
123864
|
+
const issue = yield* readEntity(response, "issue");
|
|
123865
|
+
if (issue === void 0) return yield* new LinearUnavailable({
|
|
123866
|
+
reason: "failed",
|
|
123867
|
+
detail: `Linear has no issue "${reference}".`
|
|
123868
|
+
});
|
|
123869
|
+
return { comments: asArray(asRecord$5(issue["comments"])?.["nodes"]).map(asRecord$5).filter((node) => node !== void 0).map(toCommentRow) };
|
|
123870
|
+
});
|
|
123871
|
+
const saveComment = (apiKey, args) => Effect.gen(function* () {
|
|
123872
|
+
const reference = asString(args["issueId"]) ?? asString(args["id"]);
|
|
123873
|
+
const body = typeof args["body"] === "string" ? args["body"] : void 0;
|
|
123874
|
+
if (reference === void 0 || body === void 0) return yield* new LinearUnavailable({
|
|
123875
|
+
reason: "failed",
|
|
123876
|
+
detail: "save_comment needs an issueId and a body."
|
|
123877
|
+
});
|
|
123878
|
+
const issueNodeId = yield* resolveIssueNodeId(apiKey, reference);
|
|
123879
|
+
const parentId = asString(args["parentId"]);
|
|
123880
|
+
const response = yield* request(apiKey, `mutation($input: CommentCreateInput!) {
|
|
123881
|
+
commentCreate(input: $input) { comment { ${COMMENT_SELECTION} } }
|
|
123882
|
+
}`, { input: {
|
|
123883
|
+
issueId: issueNodeId,
|
|
123884
|
+
body,
|
|
123885
|
+
...parentId === void 0 ? {} : { parentId }
|
|
123886
|
+
} });
|
|
123887
|
+
const payload = yield* readEntity(response, "commentCreate");
|
|
123888
|
+
const comment = asRecord$5(payload?.["comment"]);
|
|
123889
|
+
if (comment === void 0) return yield* new LinearUnavailable({
|
|
123890
|
+
reason: "failed",
|
|
123891
|
+
detail: response.errors.length > 0 ? response.errors.join("; ") : "Linear accepted the comment and returned nothing."
|
|
123892
|
+
});
|
|
123893
|
+
return toCommentRow(comment);
|
|
123894
|
+
});
|
|
123895
|
+
/**
|
|
123896
|
+
* A link filed against an issue, which is every attachment this transport
|
|
123897
|
+
* can make.
|
|
123898
|
+
*
|
|
123899
|
+
* Linear's own file upload is a signed-URL handshake followed by a PUT of
|
|
123900
|
+
* the bytes, and nothing here has bytes to send: the proof a closeout links
|
|
123901
|
+
* is already hosted somewhere. A caller holding a local file gets the same
|
|
123902
|
+
* answer it would get from the MCP server's `create_attachment`, which also
|
|
123903
|
+
* takes a URL.
|
|
123904
|
+
*/
|
|
123905
|
+
const createAttachment = (apiKey, args) => Effect.gen(function* () {
|
|
123906
|
+
const reference = asString(args["issueId"]) ?? asString(args["id"]);
|
|
123907
|
+
const url = asString(args["url"]);
|
|
123908
|
+
if (reference === void 0 || url === void 0) return yield* new LinearUnavailable({
|
|
123909
|
+
reason: "failed",
|
|
123910
|
+
detail: "create_attachment needs an issueId and a url."
|
|
123911
|
+
});
|
|
123912
|
+
const issueNodeId = yield* resolveIssueNodeId(apiKey, reference);
|
|
123913
|
+
const title = asString(args["title"]);
|
|
123914
|
+
const subtitle = asString(args["subtitle"]);
|
|
123915
|
+
const response = yield* request(apiKey, `mutation($input: AttachmentCreateInput!) {
|
|
123916
|
+
attachmentCreate(input: $input) { attachment { ${ATTACHMENT_SELECTION} } }
|
|
123917
|
+
}`, { input: {
|
|
123918
|
+
issueId: issueNodeId,
|
|
123919
|
+
url,
|
|
123920
|
+
title: title ?? url,
|
|
123921
|
+
...subtitle === void 0 ? {} : { subtitle }
|
|
123922
|
+
} });
|
|
123923
|
+
const payload = yield* readEntity(response, "attachmentCreate");
|
|
123924
|
+
const attachment = asRecord$5(payload?.["attachment"]);
|
|
123925
|
+
if (attachment === void 0) return yield* new LinearUnavailable({
|
|
123926
|
+
reason: "failed",
|
|
123927
|
+
detail: response.errors.length > 0 ? response.errors.join("; ") : "Linear accepted the attachment and returned nothing."
|
|
123928
|
+
});
|
|
123929
|
+
return attachment;
|
|
123930
|
+
});
|
|
123651
123931
|
const isConfigured = secrets.read("linear").pipe(Effect.map((credential) => Option.isSome(credential)));
|
|
123652
123932
|
const call = (toolName, args) => Effect.gen(function* () {
|
|
123653
123933
|
const credential = yield* secrets.read("linear");
|
|
@@ -123660,6 +123940,9 @@ const makeLinearApiTransport = Effect.gen(function* () {
|
|
|
123660
123940
|
case "list_issues": return yield* listIssues(apiKey, args);
|
|
123661
123941
|
case "get_issue": return yield* getIssue(apiKey, args);
|
|
123662
123942
|
case "save_issue": return yield* saveIssue(apiKey, args);
|
|
123943
|
+
case "list_comments": return yield* listComments(apiKey, args);
|
|
123944
|
+
case "save_comment": return yield* saveComment(apiKey, args);
|
|
123945
|
+
case "create_attachment": return yield* createAttachment(apiKey, args);
|
|
123663
123946
|
default: return yield* new LinearUnavailable({
|
|
123664
123947
|
reason: "failed",
|
|
123665
123948
|
detail: `The Linear API transport has no translation for "${toolName}".`
|
|
@@ -134245,22 +134528,27 @@ PreviewToolkit.toLayer(handlers$5);
|
|
|
134245
134528
|
//#endregion
|
|
134246
134529
|
//#region src/mcp/TicketResolver.ts
|
|
134247
134530
|
var TicketResolver = class extends Context.Service()("@p4code/cli/mcp/TicketResolver") {};
|
|
134531
|
+
/**
|
|
134532
|
+
* The client's reasons map one-to-one except `failed`, which for a caller
|
|
134533
|
+
* here means "try again", not "fix your configuration".
|
|
134534
|
+
*/
|
|
134535
|
+
const toResolveError = (cause) => new TicketResolveError({
|
|
134536
|
+
reason: cause.reason === "failed" ? "unavailable" : cause.reason,
|
|
134537
|
+
detail: cause.detail
|
|
134538
|
+
});
|
|
134248
134539
|
const stringField = (record, key) => {
|
|
134249
134540
|
const value = record[key];
|
|
134250
134541
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
|
|
134251
134542
|
};
|
|
134252
134543
|
const make$12 = Effect.gen(function* () {
|
|
134253
134544
|
const linear = yield* LinearClient;
|
|
134254
|
-
|
|
134545
|
+
const resolve = Effect.fn("TicketResolver.resolve")(function* (reference) {
|
|
134255
134546
|
const identifier = parseTicketReference(reference);
|
|
134256
134547
|
if (identifier === null) return yield* new TicketResolveError({
|
|
134257
134548
|
reason: "not_found",
|
|
134258
134549
|
detail: `"${reference}" does not look like a ticket reference.`
|
|
134259
134550
|
});
|
|
134260
|
-
const issue = readOne(yield* linear.call(LINEAR_GET_ISSUE_TOOL, { id: identifier }).pipe(Effect.mapError(
|
|
134261
|
-
reason: cause.reason === "failed" ? "unavailable" : cause.reason,
|
|
134262
|
-
detail: cause.detail
|
|
134263
|
-
}))));
|
|
134551
|
+
const issue = readOne(yield* linear.call(LINEAR_GET_ISSUE_TOOL, { id: identifier }).pipe(Effect.mapError(toResolveError)));
|
|
134264
134552
|
const url = issue === void 0 ? void 0 : stringField(issue, "url");
|
|
134265
134553
|
const title = issue === void 0 ? void 0 : stringField(issue, "title");
|
|
134266
134554
|
if (issue === void 0 || url === void 0 || title === void 0) return yield* new TicketResolveError({
|
|
@@ -134278,7 +134566,43 @@ const make$12 = Effect.gen(function* () {
|
|
|
134278
134566
|
title,
|
|
134279
134567
|
...description === void 0 ? {} : { description }
|
|
134280
134568
|
};
|
|
134281
|
-
})
|
|
134569
|
+
});
|
|
134570
|
+
return {
|
|
134571
|
+
resolve,
|
|
134572
|
+
comment: Effect.fn("TicketResolver.comment")(function* (input) {
|
|
134573
|
+
const ticket = yield* resolve(input.reference);
|
|
134574
|
+
const identifier = ticket.externalRef.identifier;
|
|
134575
|
+
const comment = readOne(yield* linear.call(LINEAR_SAVE_COMMENT_TOOL, {
|
|
134576
|
+
issueId: identifier,
|
|
134577
|
+
body: input.body
|
|
134578
|
+
}).pipe(Effect.mapError(toResolveError)));
|
|
134579
|
+
const commentUrl = comment === void 0 ? void 0 : stringField(comment, "url");
|
|
134580
|
+
const attachedUrls = [];
|
|
134581
|
+
const failedAttachments = [];
|
|
134582
|
+
for (const attachment of input.attachments ?? []) {
|
|
134583
|
+
const failure = yield* linear.call(LINEAR_CREATE_ATTACHMENT_TOOL, {
|
|
134584
|
+
issueId: identifier,
|
|
134585
|
+
url: attachment.url,
|
|
134586
|
+
...attachment.title === void 0 ? {} : { title: attachment.title }
|
|
134587
|
+
}).pipe(Effect.as(void 0), Effect.catch((cause) => Effect.logWarning("ticket attachment failed", {
|
|
134588
|
+
ticket: identifier,
|
|
134589
|
+
url: attachment.url,
|
|
134590
|
+
detail: cause.detail
|
|
134591
|
+
}).pipe(Effect.as(cause.detail))));
|
|
134592
|
+
if (failure === void 0) attachedUrls.push(attachment.url);
|
|
134593
|
+
else failedAttachments.push({
|
|
134594
|
+
url: attachment.url,
|
|
134595
|
+
detail: failure
|
|
134596
|
+
});
|
|
134597
|
+
}
|
|
134598
|
+
return {
|
|
134599
|
+
externalRef: ticket.externalRef,
|
|
134600
|
+
...commentUrl === void 0 ? {} : { commentUrl },
|
|
134601
|
+
attachedUrls,
|
|
134602
|
+
failedAttachments
|
|
134603
|
+
};
|
|
134604
|
+
})
|
|
134605
|
+
};
|
|
134282
134606
|
});
|
|
134283
134607
|
const layer$5 = Layer.effect(TicketResolver, make$12);
|
|
134284
134608
|
//#endregion
|
|
@@ -134399,7 +134723,29 @@ const TicketResolveTool = Tool.make("ticket_resolve", {
|
|
|
134399
134723
|
failure: Schema$1.Union([TaskToolUnavailableError, TicketResolveError]),
|
|
134400
134724
|
dependencies: [McpInvocationContext, TicketResolver]
|
|
134401
134725
|
}).annotate(Tool.Title, "Resolve a ticket").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, true);
|
|
134402
|
-
|
|
134726
|
+
/**
|
|
134727
|
+
* Writing one comment back to the ticket, through p4code's own transport.
|
|
134728
|
+
*
|
|
134729
|
+
* Separate from `ticket_resolve` because it writes, and on the task toolkit
|
|
134730
|
+
* for the same reason that one is: it is the tracker side of the work a board
|
|
134731
|
+
* row describes, under the one capability a session is granted.
|
|
134732
|
+
*
|
|
134733
|
+
* It exists because a provider's own tracker registration is not a transport
|
|
134734
|
+
* p4code controls - it can be absent, or hold a credential the tracker has
|
|
134735
|
+
* stopped accepting - while closeout is the moment that must not fail
|
|
134736
|
+
* silently. `LinearClient` picks the stored API key when there is one and the
|
|
134737
|
+
* registered MCP server otherwise, so this reaches the ticket whenever any
|
|
134738
|
+
* route to it exists. Both halves of a Fusion pair may call it; which half
|
|
134739
|
+
* should is the prompt's business, not the toolkit's.
|
|
134740
|
+
*/
|
|
134741
|
+
const TicketCommentTool = Tool.make("ticket_comment", {
|
|
134742
|
+
description: "Comment on a tracker ticket by id or URL (e.g. MOBILE-12241, or a linear.app link), optionally filing links as attachments alongside it. Use it for an authorized closeout note or a requested ticket comment - never for progress chatter. Reaches the tracker through p4code's own credential, so it works when the session has no tracker server of its own. The comment is written as given: Markdown, and the caller's words. Attachments take publicly reachable URLs; a local file path cannot be filed. A link the tracker refuses comes back in failedAttachments with the reason - retry that link alone rather than the whole call, which would file the comment twice.",
|
|
134743
|
+
parameters: TicketCommentInput,
|
|
134744
|
+
success: TicketCommentResult,
|
|
134745
|
+
failure: Schema$1.Union([TaskToolUnavailableError, TicketResolveError]),
|
|
134746
|
+
dependencies: [McpInvocationContext, TicketResolver]
|
|
134747
|
+
}).annotate(Tool.Title, "Comment on a ticket").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.Idempotent, false);
|
|
134748
|
+
const TaskToolkit = Toolkit.make(TaskListTool, TaskGetTool, TaskCurrentTool, TaskCreateTool, TaskUpdateTool, TaskProposeTool, TicketResolveTool, TicketCommentTool);
|
|
134403
134749
|
//#endregion
|
|
134404
134750
|
//#region src/mcp/toolkits/tasks/handlers.ts
|
|
134405
134751
|
const nowIso$1 = Effect.map(DateTime.now, DateTime.formatIso);
|
|
@@ -134502,6 +134848,10 @@ const TaskToolkitHandlersLive = TaskToolkit.toLayer({
|
|
|
134502
134848
|
yield* requireTaskCapability();
|
|
134503
134849
|
return yield* (yield* TicketResolver).resolve(input.reference);
|
|
134504
134850
|
}),
|
|
134851
|
+
ticket_comment: (input) => Effect.gen(function* () {
|
|
134852
|
+
yield* requireTaskCapability();
|
|
134853
|
+
return yield* (yield* TicketResolver).comment(input);
|
|
134854
|
+
}),
|
|
134505
134855
|
task_propose: () => Effect.gen(function* () {
|
|
134506
134856
|
yield* requireTaskCapability();
|
|
134507
134857
|
return { awaitingConfirmation: true };
|