@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 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.3";
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(HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.setHeaders(HttpClientRequest.post(registration.url), {
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) return Option.some({
31088
- required: true,
31089
- connected: true
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(`status ${status}`);
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("unexpected shape");
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) return Option.none();
31305
- return yield* refreshTokens(registration.name, record.value, tokens.refreshToken).pipe(Effect.map(Option.some), Effect.catchCause((cause) => Effect.logWarning("mcp oauth refresh failed", {
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. Both roles may read paired history and task-relevant
61027
- * external evidence; only Supervisor writes review reports and the phase plan.
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 three "tools" the Linear MCP server offers -
123407
- * `list_issues`, `get_issue`, `save_issue` - and answers in the same row
123408
- * shapes, so `LinearTasks` and the resolver cannot tell which transport
123409
- * served them. That is the point: the MCP server remains the fallback, and a
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
- return { resolve: Effect.fn("TicketResolver.resolve")(function* (reference) {
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((cause) => new TicketResolveError({
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
- const TaskToolkit = Toolkit.make(TaskListTool, TaskGetTool, TaskCurrentTool, TaskCreateTool, TaskUpdateTool, TaskProposeTool, TicketResolveTool);
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 };