@sjawhar/opencode-legion-envoy 1.27.5 → 1.29.0

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/README.md CHANGED
@@ -16,6 +16,7 @@ This package exposes:
16
16
  - `dispatch_ask`
17
17
  - `dispatch_edit_ask`
18
18
  - `dispatch_resolve_ask`
19
+ - `dispatch_resolve_comment`
19
20
  - `dispatch_follow`
20
21
  - `dispatch_comment`
21
22
  - `dispatch_suggest`
@@ -28,7 +29,7 @@ This package exposes:
28
29
  - `dispatch_search`
29
30
  - `dispatch_open_asks`
30
31
 
31
- The fifteen native `dispatch_*` tools create and read Dispatch issues, asks, comments,
32
+ The sixteen native `dispatch_*` tools create and read Dispatch issues, asks, comments,
32
33
  documents, and artifacts, or search all of them. They are present when `dispatch.enabled`
33
34
  resolves a server URL and bearer token from envoy.json (`~/.config/opencode/envoy.json`, merged
34
35
  with `<repo>/.opencode/envoy.json`) or the `DISPATCH_URL` and `DISPATCH_TOKEN` environment
@@ -13849,6 +13849,14 @@ var dispatchToolSpecs = [
13849
13849
  reason: z.string({ min: 1 }).describe("Why the open ask no longer needs a human answer.")
13850
13850
  })
13851
13851
  },
13852
+ {
13853
+ name: "dispatch_resolve_comment",
13854
+ description: "Resolve a review comment thread once it has been addressed - typically your own comment " + "after the document was fixed. Any session or human may resolve any open comment on an " + "open issue or project document; reopening a resolved comment is human-only (the dashboard). " + "Not for asks: use dispatch_resolve_ask.",
13855
+ arguments: (z) => ({
13856
+ comment: z.string().describe("Comment id (uuid), or a dispatch://KEY/comment/<id> or " + "dispatch://PROJECT/artifact/<slug>/comment/<id> reference; a reference accepts an " + "8+ character id prefix that is unique on its owner.")
13857
+ }),
13858
+ strict: true
13859
+ },
13852
13860
  {
13853
13861
  name: "dispatch_follow",
13854
13862
  description: "Follow or unfollow an ask. Every session that opens or replies to an ask follows it: its answer, " + "edits, resolution, and replies reach that session directly. Unfollow to stop; follow to rejoin or " + "to hear an ask you never wrote to. Whole-issue subscription is separate: envoy_subscribe " + "notifications.dispatch.issue.<KEY>.>",
@@ -13966,7 +13974,7 @@ var dispatchToolSpecs = [
13966
13974
  },
13967
13975
  {
13968
13976
  name: "dispatch_read",
13969
- description: "Read an issue or project-document summary, targeted ask, or targeted comment reply chain. Do not use it for document " + "contents; use dispatch_doc_read instead. Supply ref, issue, or project plus artifact. " + OWNER_REFERENCE,
13977
+ description: "Read an issue or project-document summary, targeted ask, or targeted comment reply chain. Do not use it for document " + "contents; use dispatch_doc_read instead. Supply ref, issue, or project plus artifact. " + "Every read ends with `Referenced by:` (what cites or hangs off this node, each with its dispatch:// address, " + "an excerpt, and when) and `Links:` (what it cites), so tracing provenance is one call. " + OWNER_REFERENCE,
13970
13978
  arguments: (z) => ({
13971
13979
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
13972
13980
  project: z.string().describe("Project key owning the document.").optional(),
@@ -14902,6 +14910,9 @@ class DispatchClient {
14902
14910
  async getComment(id) {
14903
14911
  return this.#json("GET", ["api", "v1", "comments", id]);
14904
14912
  }
14913
+ async resolveComment(id, actor) {
14914
+ return this.#json("POST", ["api", "v1", "comments", id, "resolve"], { actor });
14915
+ }
14905
14916
  async getComments(issue, artifact) {
14906
14917
  return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "comments"], undefined, artifact ? { artifact } : undefined);
14907
14918
  }
@@ -14914,6 +14925,13 @@ class DispatchClient {
14914
14925
  "references"
14915
14926
  ]);
14916
14927
  }
14928
+ async getReferences(query) {
14929
+ return this.#json("GET", ["api", "v1", "references"], undefined, {
14930
+ ..."to" in query ? { to: query.to } : { from: query.from },
14931
+ ...query.kind === undefined || query.kind.length === 0 ? {} : { kind: query.kind.join(",") },
14932
+ ...query.since === undefined ? {} : { since: query.since }
14933
+ });
14934
+ }
14917
14935
  async ensureIssue(issueReference, actor) {
14918
14936
  if (!issueReference.includes("#"))
14919
14937
  return issueReference;
@@ -15179,6 +15197,19 @@ async function askOwnerDetails(client, ask, resolved) {
15179
15197
  const artifact = resolved?.artifact ?? await client.getArtifact(ask.artifact_id);
15180
15198
  return { ...documentResultDetails(artifact), ask: ask.id };
15181
15199
  }
15200
+ async function commentOwnerResult(client, comment, artifact) {
15201
+ if (comment.issue_key !== null) {
15202
+ return { label: comment.issue_key, details: { issue: comment.issue_key, comment: comment.id } };
15203
+ }
15204
+ if (comment.artifact_id === undefined || comment.artifact_id === null) {
15205
+ throw new Error("document comment is missing its artifact ID");
15206
+ }
15207
+ const owner = artifact ?? await client.getArtifact(comment.artifact_id);
15208
+ return {
15209
+ label: documentTopic(owner).label,
15210
+ details: { ...documentResultDetails(owner), comment: comment.id }
15211
+ };
15212
+ }
15182
15213
  async function followedAskDetails(client, ask, resolved) {
15183
15214
  return { ...await askOwnerDetails(client, ask, resolved), follows: { ask: ask.id } };
15184
15215
  }
@@ -15189,6 +15220,7 @@ var issueFreeTools = {
15189
15220
  dispatch_issue: true,
15190
15221
  dispatch_edit_ask: true,
15191
15222
  dispatch_resolve_ask: true,
15223
+ dispatch_resolve_comment: true,
15192
15224
  dispatch_follow: true,
15193
15225
  dispatch_search: true,
15194
15226
  dispatch_open_asks: true
@@ -15311,6 +15343,7 @@ function parseDispatchRef(ref) {
15311
15343
  }
15312
15344
  var uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15313
15345
  var askIdProblem = "ask must be a bare ask id or a dispatch://.../ask/<id> reference";
15346
+ var commentIdProblem = "comment must be a bare comment id or a dispatch://.../comment/<id> reference";
15314
15347
  var messageIdProblem = "in_reply_to must be a full message id (uuid) or a dispatch://KEY/message/<id> reference";
15315
15348
  function askId(args) {
15316
15349
  const ask = stringArg(args, "ask");
@@ -15366,6 +15399,13 @@ function argumentProblems(tool, args) {
15366
15399
  }
15367
15400
  break;
15368
15401
  }
15402
+ case "dispatch_resolve_comment": {
15403
+ const comment = optionalString(args, "comment");
15404
+ if (comment?.startsWith("dispatch://") && parseDispatchRef(comment)?.kind !== "comment") {
15405
+ problems.push(commentIdProblem);
15406
+ }
15407
+ break;
15408
+ }
15369
15409
  case "dispatch_comment": {
15370
15410
  if (optionalString(args, "quote") !== undefined && optionalString(args, "artifact") === undefined) {
15371
15411
  problems.push("artifact is required when quote is supplied");
@@ -15593,7 +15633,7 @@ function approvalLine(artifact) {
15593
15633
  return `Approval: changes requested on v${approval.version} by ${approval.by?.id ?? "unknown"}: ${approval.reason ?? ""}`;
15594
15634
  }
15595
15635
  }
15596
- function issueSummary(issue, events, references) {
15636
+ function issueSummary(issue, events, references, graph) {
15597
15637
  const asks = issue.open_asks;
15598
15638
  const spec = issue.artifacts?.find((artifact) => artifact.primary);
15599
15639
  const specApproval = spec === undefined ? undefined : approvalLine(spec);
@@ -15613,10 +15653,35 @@ function issueSummary(issue, events, references) {
15613
15653
  ...typeof references === "string" ? [`- ${references}`] : references.members.length === 0 ? ["- none"] : references.members.map(({ artifact, depth, via }) => `- ${artifact.project}/${artifact.slug} \xB7 depth ${depth} via ${via.kind} ${via.id}`),
15614
15654
  ...typeof references === "string" || !references.truncated ? [] : ["- more references beyond 8 hops"],
15615
15655
  "Events:",
15616
- ...events.length === 0 ? ["- none"] : events.map(eventLine)
15656
+ ...events.length === 0 ? ["- none"] : events.map(eventLine),
15657
+ ...graph
15617
15658
  ].join(`
15618
15659
  `);
15619
15660
  }
15661
+ function referenceLines(edges) {
15662
+ if (typeof edges === "string")
15663
+ return [`- ${edges}`];
15664
+ if (edges.length === 0)
15665
+ return ["- none"];
15666
+ return edges.map((edge) => {
15667
+ const excerpt = edge.excerpt === undefined ? "" : `${textHead(edge.excerpt.text)} \xB7 `;
15668
+ return `- ${edge.kind} ${edge.node.kind} ${edge.node.ref ?? edge.node.id} (${excerpt}${edge.created_at})`;
15669
+ });
15670
+ }
15671
+ async function graphEdges(client, query) {
15672
+ try {
15673
+ return (await client.getReferences(query)).edges;
15674
+ } catch (error) {
15675
+ return error instanceof DispatchServiceError && error.status === 404 ? "unavailable" : `unavailable: ${error instanceof Error ? error.message : String(error)}`;
15676
+ }
15677
+ }
15678
+ async function graphSections(client, ref) {
15679
+ const [incoming, outgoing] = await Promise.all([
15680
+ graphEdges(client, { to: ref }),
15681
+ graphEdges(client, { from: ref })
15682
+ ]);
15683
+ return ["Referenced by:", ...referenceLines(incoming), "Links:", ...referenceLines(outgoing)];
15684
+ }
15620
15685
  function logSummary(issue, events) {
15621
15686
  return [
15622
15687
  `Key: ${issue.key}`,
@@ -15664,7 +15729,7 @@ function childrenSummary(issue) {
15664
15729
  ].join(`
15665
15730
  `);
15666
15731
  }
15667
- function askSummary({ ask, replies }) {
15732
+ function askSummary({ ask, replies }, graph) {
15668
15733
  const answer = ask.answer;
15669
15734
  const chain = replies.flatMap((reply) => [
15670
15735
  `${reply.id} \xB7 ${reply.author.kind} ${reply.author.id}`,
@@ -15688,7 +15753,8 @@ function askSummary({ ask, replies }) {
15688
15753
  `- Reason: ${ask.resolution.reason}`
15689
15754
  ],
15690
15755
  "Replies:",
15691
- ...chain.length === 0 ? ["- none"] : chain
15756
+ ...chain.length === 0 ? ["- none"] : chain,
15757
+ ...graph
15692
15758
  ].join(`
15693
15759
  `);
15694
15760
  }
@@ -15731,7 +15797,7 @@ function formatOpenAsksSummary(response, baseUrl) {
15731
15797
  ].join(`
15732
15798
  `);
15733
15799
  }
15734
- function commentSummary({ comment, replies }) {
15800
+ function commentSummary({ comment, replies }, graph) {
15735
15801
  const root = [
15736
15802
  `${comment.id} \xB7 ${comment.author.kind} ${comment.author.id}`,
15737
15803
  ...comment.anchor?.quote === undefined ? [] : [`> ${comment.anchor.quote}`],
@@ -15742,10 +15808,16 @@ function commentSummary({ comment, replies }) {
15742
15808
  ...reply.anchor?.quote === undefined ? [] : [`> ${reply.anchor.quote}`],
15743
15809
  `Body: ${reply.body}`
15744
15810
  ]);
15745
- return ["Comment:", ...root, "Reply chain:", ...chain.length === 0 ? ["- none"] : chain].join(`
15811
+ return [
15812
+ "Comment:",
15813
+ ...root,
15814
+ "Reply chain:",
15815
+ ...chain.length === 0 ? ["- none"] : chain,
15816
+ ...graph
15817
+ ].join(`
15746
15818
  `);
15747
15819
  }
15748
- function messageSummary({ message, replies }) {
15820
+ function messageSummary({ message, replies }, graph) {
15749
15821
  const root = [
15750
15822
  `${message.id} \xB7 ${message.author.kind} ${message.author.id}`,
15751
15823
  `Body: ${message.body}`
@@ -15754,7 +15826,13 @@ function messageSummary({ message, replies }) {
15754
15826
  `${reply.id} \xB7 ${reply.author.kind} ${reply.author.id}`,
15755
15827
  `Body: ${reply.body}`
15756
15828
  ]);
15757
- return ["Message:", ...root, "Reply chain:", ...chain.length === 0 ? ["- none"] : chain].join(`
15829
+ return [
15830
+ "Message:",
15831
+ ...root,
15832
+ "Reply chain:",
15833
+ ...chain.length === 0 ? ["- none"] : chain,
15834
+ ...graph
15835
+ ].join(`
15758
15836
  `);
15759
15837
  }
15760
15838
  async function openArtifactMarks(client, resolved) {
@@ -15907,6 +15985,23 @@ async function executeDispatchTool(input) {
15907
15985
  details: await askOwnerDetails(client, ask)
15908
15986
  };
15909
15987
  }
15988
+ case "dispatch_resolve_comment": {
15989
+ const reference = stringArg(args, "comment");
15990
+ const ref = reference.startsWith("dispatch://") ? parseDispatchRef(reference) : null;
15991
+ let id = reference;
15992
+ let document;
15993
+ if (ref?.owner.kind === "issue") {
15994
+ const issueKey = ref.owner.issue;
15995
+ id = await resolveIdPrefix(input.tool, "comment", ref, () => client.getComments(issueKey));
15996
+ } else if (ref !== null) {
15997
+ const artifact = (await resolveArtifact(client, ref.owner, ref.artifact)).artifact;
15998
+ document = artifact;
15999
+ id = await resolveIdPrefix(input.tool, "comment", ref, () => client.getArtifactComments(artifact.id));
16000
+ }
16001
+ const comment = await client.resolveComment(id, actor);
16002
+ const { label, details } = await commentOwnerResult(client, comment, document);
16003
+ return { text: `Resolved comment ${comment.id} on ${label}.`, details };
16004
+ }
15910
16005
  case "dispatch_ask": {
15911
16006
  const anchorArgs = asObject(args.anchor);
15912
16007
  const owner = documentOwner();
@@ -16134,8 +16229,9 @@ ${trailer.join(`
16134
16229
  const ref = ownerArguments.ref;
16135
16230
  const id = await resolveIdPrefix(input.tool, "ask", ref, async () => ref.owner.kind === "issue" ? client.listIssueAsks(ref.owner.issue) : client.getArtifactAsks((await resolveArtifact(client, ref.owner, ref.artifact)).artifact.id, "all"));
16136
16231
  const askRead = await client.getAsk(id);
16232
+ const askRef = ref.owner.kind === "issue" ? `dispatch://${ref.owner.issue}/ask/${id}` : `dispatch://${ref.owner.project}/artifact/${ref.artifact}/ask/${id}`;
16137
16233
  return {
16138
- text: askSummary(askRead),
16234
+ text: askSummary(askRead, await graphSections(client, askRef)),
16139
16235
  details: ref.owner.kind === "project" ? { project: ref.owner.project } : { issue: ref.owner.issue }
16140
16236
  };
16141
16237
  }
@@ -16143,8 +16239,9 @@ ${trailer.join(`
16143
16239
  const ref = ownerArguments.ref;
16144
16240
  const id = await resolveIdPrefix(input.tool, "comment", ref, async () => ref.owner.kind === "issue" ? client.getComments(ref.owner.issue) : client.getArtifactComments((await resolveArtifact(client, ref.owner, ref.artifact)).artifact.id));
16145
16241
  const comment = await client.getComment(id);
16242
+ const commentRef = ref.owner.kind === "issue" ? `dispatch://${ref.owner.issue}/comment/${id}` : `dispatch://${ref.owner.project}/artifact/${ref.artifact}/comment/${id}`;
16146
16243
  return {
16147
- text: commentSummary(comment),
16244
+ text: commentSummary(comment, await graphSections(client, commentRef)),
16148
16245
  details: ref.owner.kind === "project" ? { project: ref.owner.project } : { issue: comment.comment.issue_key }
16149
16246
  };
16150
16247
  }
@@ -16153,19 +16250,22 @@ ${trailer.join(`
16153
16250
  throw new Error("message references are issue-scoped");
16154
16251
  }
16155
16252
  const messageRead = await client.getMessage(ownerArguments.ref.owner.issue, ownerArguments.ref.id);
16253
+ const messageRef = `dispatch://${ownerArguments.ref.owner.issue}/message/${ownerArguments.ref.id}`;
16156
16254
  return {
16157
- text: messageSummary(messageRead),
16255
+ text: messageSummary(messageRead, await graphSections(client, messageRef)),
16158
16256
  details: { issue: messageRead.message.issue_key }
16159
16257
  };
16160
16258
  }
16161
16259
  if (documentOwner().kind === "project") {
16162
16260
  const resolved = await resolveArtifact(client, documentOwner(), stringArg(args, "artifact"));
16261
+ const documentRef = `dispatch://${resolved.artifact.project}/artifact/${resolved.artifact.slug}`;
16163
16262
  return {
16164
16263
  text: [
16165
16264
  `Document: ${resolved.artifact.project} / ${resolved.artifact.name}`,
16166
- `Reference: dispatch://${resolved.artifact.project}/artifact/${resolved.artifact.slug}`,
16265
+ `Reference: ${documentRef}`,
16167
16266
  `Versions: ${resolved.artifact.versions.length}`,
16168
- ...approvalLine(resolved.artifact) === undefined ? [] : [approvalLine(resolved.artifact)]
16267
+ ...approvalLine(resolved.artifact) === undefined ? [] : [approvalLine(resolved.artifact)],
16268
+ ...await graphSections(client, documentRef)
16169
16269
  ].join(`
16170
16270
  `),
16171
16271
  details: {
@@ -16194,7 +16294,7 @@ ${trailer.join(`
16194
16294
  references = error instanceof DispatchServiceError && error.status === 404 ? "unavailable" : `unavailable: ${error instanceof Error ? error.message : String(error)}`;
16195
16295
  }
16196
16296
  return {
16197
- text: issueSummary(read.issue, read.events, references),
16297
+ text: issueSummary(read.issue, read.events, references, await graphSections(client, `dispatch://${read.issue.key}`)),
16198
16298
  details: { issue: read.issue.key }
16199
16299
  };
16200
16300
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "1.27.5",
3
+ "version": "1.29.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -199,6 +199,8 @@ Use `retracted` when the question is obsolete and `resolved` when you found the
199
199
  in its Conversation card and reply thread. Resolution is not an answer: it never records a human decision, and an answered ask cannot be
200
200
  resolved. A human may reply to an open or answered ask; so may you, e.g. after finding the answer — use `reply_to_ask` on
201
201
  `dispatch_comment` (mutually exclusive with `reply_to`).
202
+ A review comment you opened has its own closer, `dispatch_resolve_comment` — see
203
+ [Comments and suggestions](#comments-and-suggestions).
202
204
 
203
205
  A human answers or asks back from the same field; a question-shaped free-text answer is offered as a clarification first. A human
204
206
  reply while your ask is still open (the delivered `comment.created` carries `ask_state: open`) is a request for clarification, not
@@ -353,6 +355,18 @@ the human needs to act; see [Asking](#asking). Comments are edited only by their
353
355
  dashboard. A delivered `comment.created` event carries the comment `id`; reply to it with
354
356
  `dispatch_comment({ reply_to: <id> })`.
355
357
 
358
+ Resolve your own review comment once you have addressed it:
359
+
360
+ ```ts
361
+ dispatch_resolve_comment({ comment })
362
+ ```
363
+
364
+ `comment` is the comment id, or a `dispatch://KEY/comment/<id>` or `dispatch://PROJECT/artifact/<slug>/comment/<id>` reference (an
365
+ 8+ character id prefix is resolved against the owner's comments). It returns the owner details plus `comment`, and takes no reason —
366
+ say what you did in a `reply_to` first if the thread needs it. The server lets any session or human resolve any open comment, so
367
+ resolve only threads you opened or were asked to close; reopening a resolved thread is human-only (from the dashboard), though your
368
+ reply to it reopens it. Asks are closed with `dispatch_resolve_ask` instead.
369
+
356
370
  Propose an exact replacement instead of describing it:
357
371
 
358
372
  ```ts
@@ -384,6 +398,11 @@ with your text. Never do that: the spec is edited in place with `dispatch_doc_ed
384
398
  artifact by the slug shown in the upload result or by its filename, and a project document by its artifact id, slug, or filename; the
385
399
  slug also arrives on `artifact.created` events.
386
400
 
401
+ Documents are CommonMark. A bare `<https://example.com|text>` is a CommonMark autolink and is normalised: the angle brackets are
402
+ dropped and the URL keeps `|text`. A backslash-escaped `\<https://example.com|text>` displays as `<https://example.com|text>` in the
403
+ document but comes back re-escaped (`\<`) from `dispatch_doc_read`. A Slack mrkdwn draft, or any other payload that is not Markdown,
404
+ still belongs inside a fenced code block, where it survives verbatim both ways.
405
+
387
406
  ## Structure over stream
388
407
 
389
408
  Dispatch is a structured workspace, never a message stream (Sami, 2026-09-13, verbatim: "strange
@@ -475,6 +494,14 @@ document owner or ref, it returns a document summary with `details` `{ project,
475
494
  question, options, state, answer, and its reply thread. With a comment ref, it returns that comment and its quoted reply chain. With a
476
495
  message ref, it returns that message and its reply chain. Reads do not subscribe; use `dispatch_doc_read` for document contents.
477
496
 
497
+ Every read ends with two sections from the reference graph. `Referenced by:` lists what points at the node — every document, ask,
498
+ comment, or message that cites it, plus its structure: child issues, attached documents, anchored and owned asks and comments, replies,
499
+ followers — and `Links:` lists what it cites. Each row is `- <edge kind> <node kind> dispatch://… (<excerpt> · <when>)`; for a
500
+ document source the excerpt is the block containing the mention. Cross-project, always: a message on another project's issue that
501
+ cites an ask shows up under that ask. So "what led to this decision" is one `dispatch_read` on the ask, and "who relies on this
502
+ document" one read on the document. Cite with `dispatch://` references (below) whenever you name a node in a body — a bare id or
503
+ title is invisible to the graph.
504
+
478
505
  ## References
479
506
 
480
507
  Use these in document, ask, comment, and message bodies. In the dashboard, a reference renders