@sjawhar/pi-legion-envoy 1.38.5 → 1.40.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
@@ -85,10 +85,10 @@ extension files it does not contain.
85
85
 
86
86
  ## Native Dispatch tools
87
87
 
88
- The extension registers fifteen native Dispatch tools: `dispatch_issue`, `dispatch_ask`, `dispatch_edit_ask`,
89
- `dispatch_resolve_ask`, `dispatch_follow`, `dispatch_comment`, `dispatch_suggest`, `dispatch_message`,
90
- `dispatch_doc_edit`, `dispatch_doc_read`, `dispatch_request_approval`, `dispatch_artifact`, `dispatch_read`,
91
- `dispatch_search`, and `dispatch_open_asks`, when Dispatch configuration resolves both a base URL and bearer token.
88
+ The extension registers sixteen native Dispatch tools: `dispatch_issue`, `dispatch_ask`, `dispatch_edit_ask`,
89
+ `dispatch_resolve_ask`, `dispatch_resolve_comment`, `dispatch_follow`, `dispatch_comment`, `dispatch_suggest`,
90
+ `dispatch_message`, `dispatch_doc_edit`, `dispatch_doc_read`, `dispatch_request_approval`, `dispatch_artifact`,
91
+ `dispatch_read`, `dispatch_search`, and `dispatch_open_asks`, when Dispatch configuration resolves both a base URL and bearer token.
92
92
 
93
93
  Configure the shared `envoy.json` with:
94
94
 
@@ -112,7 +112,7 @@ whose trimmed contents are the token — how the Legion daemon delivers it to a
112
112
  pane) wins over every other token source and never falls back when unreadable.
113
113
  Omitting `dispatch.serverUrl` while `dispatch.enabled` is true targets
114
114
  `http://localhost:8766`, the Go server's listen address. Invalid configuration,
115
- an invalid URL, or an empty token leaves the fifteen tools unavailable and
115
+ an invalid URL, or an empty token leaves the sixteen tools unavailable and
116
116
  reports the source of the error.
117
117
 
118
118
  Owner-scoped calls use either an issue (a native `KEY` or external `owner/repo#n` reference) or
@@ -121,7 +121,7 @@ an unlinked project document (`project` plus its `artifact` slug). A Legion sess
121
121
  repository. `dispatch_doc_read` and `dispatch_read` also accept `dispatch://` references,
122
122
  including `dispatch://PROJECT/artifact/<slug>`. `dispatch_search` needs only its query.
123
123
  `dispatch_edit_ask`, `dispatch_resolve_ask`, and `dispatch_follow` instead identify an existing
124
- ask with `ask`. No result carries a subscription topic: opening an ask or replying to one with
124
+ ask with `ask`, and `dispatch_resolve_comment` a comment with `comment`. No result carries a subscription topic: opening an ask or replying to one with
125
125
  `dispatch_comment({ reply_to_ask })` makes the session a follower of that ask (its answer and
126
126
  replies reach the session directly, server-side), the result carries `details.follows.ask`, and
127
127
  the extension's `tool_result` hook tells the model so once per ask. Whole-issue or
package/dist/envoy.js CHANGED
@@ -29946,6 +29946,14 @@ var dispatchToolSpecs = [
29946
29946
  reason: z.string({ min: 1 }).describe("Why the open ask no longer needs a human answer.")
29947
29947
  })
29948
29948
  },
29949
+ {
29950
+ name: "dispatch_resolve_comment",
29951
+ 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.",
29952
+ arguments: (z) => ({
29953
+ 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.")
29954
+ }),
29955
+ strict: true
29956
+ },
29949
29957
  {
29950
29958
  name: "dispatch_follow",
29951
29959
  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>.>",
@@ -30063,7 +30071,7 @@ var dispatchToolSpecs = [
30063
30071
  },
30064
30072
  {
30065
30073
  name: "dispatch_read",
30066
- 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,
30074
+ 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,
30067
30075
  arguments: (z) => ({
30068
30076
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
30069
30077
  project: z.string().describe("Project key owning the document.").optional(),
@@ -31965,6 +31973,9 @@ class DispatchClient {
31965
31973
  async getComment(id) {
31966
31974
  return this.#json("GET", ["api", "v1", "comments", id]);
31967
31975
  }
31976
+ async resolveComment(id, actor) {
31977
+ return this.#json("POST", ["api", "v1", "comments", id, "resolve"], { actor });
31978
+ }
31968
31979
  async getComments(issue, artifact) {
31969
31980
  return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "comments"], undefined, artifact ? { artifact } : undefined);
31970
31981
  }
@@ -31977,6 +31988,13 @@ class DispatchClient {
31977
31988
  "references"
31978
31989
  ]);
31979
31990
  }
31991
+ async getReferences(query) {
31992
+ return this.#json("GET", ["api", "v1", "references"], undefined, {
31993
+ ..."to" in query ? { to: query.to } : { from: query.from },
31994
+ ...query.kind === undefined || query.kind.length === 0 ? {} : { kind: query.kind.join(",") },
31995
+ ...query.since === undefined ? {} : { since: query.since }
31996
+ });
31997
+ }
31980
31998
  async ensureIssue(issueReference, actor) {
31981
31999
  if (!issueReference.includes("#"))
31982
32000
  return issueReference;
@@ -32242,6 +32260,19 @@ async function askOwnerDetails(client, ask, resolved) {
32242
32260
  const artifact = resolved?.artifact ?? await client.getArtifact(ask.artifact_id);
32243
32261
  return { ...documentResultDetails(artifact), ask: ask.id };
32244
32262
  }
32263
+ async function commentOwnerResult(client, comment, artifact) {
32264
+ if (comment.issue_key !== null) {
32265
+ return { label: comment.issue_key, details: { issue: comment.issue_key, comment: comment.id } };
32266
+ }
32267
+ if (comment.artifact_id === undefined || comment.artifact_id === null) {
32268
+ throw new Error("document comment is missing its artifact ID");
32269
+ }
32270
+ const owner = artifact ?? await client.getArtifact(comment.artifact_id);
32271
+ return {
32272
+ label: documentTopic(owner).label,
32273
+ details: { ...documentResultDetails(owner), comment: comment.id }
32274
+ };
32275
+ }
32245
32276
  async function followedAskDetails(client, ask, resolved) {
32246
32277
  return { ...await askOwnerDetails(client, ask, resolved), follows: { ask: ask.id } };
32247
32278
  }
@@ -32252,6 +32283,7 @@ var issueFreeTools = {
32252
32283
  dispatch_issue: true,
32253
32284
  dispatch_edit_ask: true,
32254
32285
  dispatch_resolve_ask: true,
32286
+ dispatch_resolve_comment: true,
32255
32287
  dispatch_follow: true,
32256
32288
  dispatch_search: true,
32257
32289
  dispatch_open_asks: true
@@ -32374,6 +32406,7 @@ function parseDispatchRef(ref) {
32374
32406
  }
32375
32407
  var uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
32376
32408
  var askIdProblem = "ask must be a bare ask id or a dispatch://.../ask/<id> reference";
32409
+ var commentIdProblem = "comment must be a bare comment id or a dispatch://.../comment/<id> reference";
32377
32410
  var messageIdProblem = "in_reply_to must be a full message id (uuid) or a dispatch://KEY/message/<id> reference";
32378
32411
  function askId(args) {
32379
32412
  const ask = stringArg(args, "ask");
@@ -32429,6 +32462,13 @@ function argumentProblems(tool, args) {
32429
32462
  }
32430
32463
  break;
32431
32464
  }
32465
+ case "dispatch_resolve_comment": {
32466
+ const comment = optionalString(args, "comment");
32467
+ if (comment?.startsWith("dispatch://") && parseDispatchRef(comment)?.kind !== "comment") {
32468
+ problems.push(commentIdProblem);
32469
+ }
32470
+ break;
32471
+ }
32432
32472
  case "dispatch_comment": {
32433
32473
  if (optionalString(args, "quote") !== undefined && optionalString(args, "artifact") === undefined) {
32434
32474
  problems.push("artifact is required when quote is supplied");
@@ -32656,7 +32696,7 @@ function approvalLine(artifact) {
32656
32696
  return `Approval: changes requested on v${approval.version} by ${approval.by?.id ?? "unknown"}: ${approval.reason ?? ""}`;
32657
32697
  }
32658
32698
  }
32659
- function issueSummary(issue, events, references) {
32699
+ function issueSummary(issue, events, references, graph) {
32660
32700
  const asks = issue.open_asks;
32661
32701
  const spec = issue.artifacts?.find((artifact) => artifact.primary);
32662
32702
  const specApproval = spec === undefined ? undefined : approvalLine(spec);
@@ -32676,10 +32716,35 @@ function issueSummary(issue, events, references) {
32676
32716
  ...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}`),
32677
32717
  ...typeof references === "string" || !references.truncated ? [] : ["- more references beyond 8 hops"],
32678
32718
  "Events:",
32679
- ...events.length === 0 ? ["- none"] : events.map(eventLine)
32719
+ ...events.length === 0 ? ["- none"] : events.map(eventLine),
32720
+ ...graph
32680
32721
  ].join(`
32681
32722
  `);
32682
32723
  }
32724
+ function referenceLines(edges) {
32725
+ if (typeof edges === "string")
32726
+ return [`- ${edges}`];
32727
+ if (edges.length === 0)
32728
+ return ["- none"];
32729
+ return edges.map((edge) => {
32730
+ const excerpt = edge.excerpt === undefined ? "" : `${textHead(edge.excerpt.text)} \xB7 `;
32731
+ return `- ${edge.kind} ${edge.node.kind} ${edge.node.ref ?? edge.node.id} (${excerpt}${edge.created_at})`;
32732
+ });
32733
+ }
32734
+ async function graphEdges(client, query) {
32735
+ try {
32736
+ return (await client.getReferences(query)).edges;
32737
+ } catch (error) {
32738
+ return error instanceof DispatchServiceError && error.status === 404 ? "unavailable" : `unavailable: ${error instanceof Error ? error.message : String(error)}`;
32739
+ }
32740
+ }
32741
+ async function graphSections(client, ref) {
32742
+ const [incoming, outgoing] = await Promise.all([
32743
+ graphEdges(client, { to: ref }),
32744
+ graphEdges(client, { from: ref })
32745
+ ]);
32746
+ return ["Referenced by:", ...referenceLines(incoming), "Links:", ...referenceLines(outgoing)];
32747
+ }
32683
32748
  function logSummary(issue, events) {
32684
32749
  return [
32685
32750
  `Key: ${issue.key}`,
@@ -32727,7 +32792,7 @@ function childrenSummary(issue) {
32727
32792
  ].join(`
32728
32793
  `);
32729
32794
  }
32730
- function askSummary({ ask, replies }) {
32795
+ function askSummary({ ask, replies }, graph) {
32731
32796
  const answer = ask.answer;
32732
32797
  const chain = replies.flatMap((reply) => [
32733
32798
  `${reply.id} \xB7 ${reply.author.kind} ${reply.author.id}`,
@@ -32751,7 +32816,8 @@ function askSummary({ ask, replies }) {
32751
32816
  `- Reason: ${ask.resolution.reason}`
32752
32817
  ],
32753
32818
  "Replies:",
32754
- ...chain.length === 0 ? ["- none"] : chain
32819
+ ...chain.length === 0 ? ["- none"] : chain,
32820
+ ...graph
32755
32821
  ].join(`
32756
32822
  `);
32757
32823
  }
@@ -32794,7 +32860,7 @@ function formatOpenAsksSummary(response, baseUrl) {
32794
32860
  ].join(`
32795
32861
  `);
32796
32862
  }
32797
- function commentSummary({ comment, replies }) {
32863
+ function commentSummary({ comment, replies }, graph) {
32798
32864
  const root = [
32799
32865
  `${comment.id} \xB7 ${comment.author.kind} ${comment.author.id}`,
32800
32866
  ...comment.anchor?.quote === undefined ? [] : [`> ${comment.anchor.quote}`],
@@ -32805,10 +32871,16 @@ function commentSummary({ comment, replies }) {
32805
32871
  ...reply.anchor?.quote === undefined ? [] : [`> ${reply.anchor.quote}`],
32806
32872
  `Body: ${reply.body}`
32807
32873
  ]);
32808
- return ["Comment:", ...root, "Reply chain:", ...chain.length === 0 ? ["- none"] : chain].join(`
32874
+ return [
32875
+ "Comment:",
32876
+ ...root,
32877
+ "Reply chain:",
32878
+ ...chain.length === 0 ? ["- none"] : chain,
32879
+ ...graph
32880
+ ].join(`
32809
32881
  `);
32810
32882
  }
32811
- function messageSummary({ message, replies }) {
32883
+ function messageSummary({ message, replies }, graph) {
32812
32884
  const root = [
32813
32885
  `${message.id} \xB7 ${message.author.kind} ${message.author.id}`,
32814
32886
  `Body: ${message.body}`
@@ -32817,7 +32889,13 @@ function messageSummary({ message, replies }) {
32817
32889
  `${reply.id} \xB7 ${reply.author.kind} ${reply.author.id}`,
32818
32890
  `Body: ${reply.body}`
32819
32891
  ]);
32820
- return ["Message:", ...root, "Reply chain:", ...chain.length === 0 ? ["- none"] : chain].join(`
32892
+ return [
32893
+ "Message:",
32894
+ ...root,
32895
+ "Reply chain:",
32896
+ ...chain.length === 0 ? ["- none"] : chain,
32897
+ ...graph
32898
+ ].join(`
32821
32899
  `);
32822
32900
  }
32823
32901
  async function openArtifactMarks(client, resolved) {
@@ -32970,6 +33048,23 @@ async function executeDispatchTool(input) {
32970
33048
  details: await askOwnerDetails(client, ask)
32971
33049
  };
32972
33050
  }
33051
+ case "dispatch_resolve_comment": {
33052
+ const reference = stringArg(args, "comment");
33053
+ const ref = reference.startsWith("dispatch://") ? parseDispatchRef(reference) : null;
33054
+ let id = reference;
33055
+ let document;
33056
+ if (ref?.owner.kind === "issue") {
33057
+ const issueKey = ref.owner.issue;
33058
+ id = await resolveIdPrefix(input.tool, "comment", ref, () => client.getComments(issueKey));
33059
+ } else if (ref !== null) {
33060
+ const artifact = (await resolveArtifact(client, ref.owner, ref.artifact)).artifact;
33061
+ document = artifact;
33062
+ id = await resolveIdPrefix(input.tool, "comment", ref, () => client.getArtifactComments(artifact.id));
33063
+ }
33064
+ const comment = await client.resolveComment(id, actor);
33065
+ const { label, details } = await commentOwnerResult(client, comment, document);
33066
+ return { text: `Resolved comment ${comment.id} on ${label}.`, details };
33067
+ }
32973
33068
  case "dispatch_ask": {
32974
33069
  const anchorArgs = asObject(args.anchor);
32975
33070
  const owner = documentOwner();
@@ -33197,8 +33292,9 @@ ${trailer.join(`
33197
33292
  const ref = ownerArguments.ref;
33198
33293
  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"));
33199
33294
  const askRead = await client.getAsk(id);
33295
+ const askRef = ref.owner.kind === "issue" ? `dispatch://${ref.owner.issue}/ask/${id}` : `dispatch://${ref.owner.project}/artifact/${ref.artifact}/ask/${id}`;
33200
33296
  return {
33201
- text: askSummary(askRead),
33297
+ text: askSummary(askRead, await graphSections(client, askRef)),
33202
33298
  details: ref.owner.kind === "project" ? { project: ref.owner.project } : { issue: ref.owner.issue }
33203
33299
  };
33204
33300
  }
@@ -33206,8 +33302,9 @@ ${trailer.join(`
33206
33302
  const ref = ownerArguments.ref;
33207
33303
  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));
33208
33304
  const comment = await client.getComment(id);
33305
+ const commentRef = ref.owner.kind === "issue" ? `dispatch://${ref.owner.issue}/comment/${id}` : `dispatch://${ref.owner.project}/artifact/${ref.artifact}/comment/${id}`;
33209
33306
  return {
33210
- text: commentSummary(comment),
33307
+ text: commentSummary(comment, await graphSections(client, commentRef)),
33211
33308
  details: ref.owner.kind === "project" ? { project: ref.owner.project } : { issue: comment.comment.issue_key }
33212
33309
  };
33213
33310
  }
@@ -33216,19 +33313,22 @@ ${trailer.join(`
33216
33313
  throw new Error("message references are issue-scoped");
33217
33314
  }
33218
33315
  const messageRead = await client.getMessage(ownerArguments.ref.owner.issue, ownerArguments.ref.id);
33316
+ const messageRef = `dispatch://${ownerArguments.ref.owner.issue}/message/${ownerArguments.ref.id}`;
33219
33317
  return {
33220
- text: messageSummary(messageRead),
33318
+ text: messageSummary(messageRead, await graphSections(client, messageRef)),
33221
33319
  details: { issue: messageRead.message.issue_key }
33222
33320
  };
33223
33321
  }
33224
33322
  if (documentOwner().kind === "project") {
33225
33323
  const resolved = await resolveArtifact(client, documentOwner(), stringArg(args, "artifact"));
33324
+ const documentRef = `dispatch://${resolved.artifact.project}/artifact/${resolved.artifact.slug}`;
33226
33325
  return {
33227
33326
  text: [
33228
33327
  `Document: ${resolved.artifact.project} / ${resolved.artifact.name}`,
33229
- `Reference: dispatch://${resolved.artifact.project}/artifact/${resolved.artifact.slug}`,
33328
+ `Reference: ${documentRef}`,
33230
33329
  `Versions: ${resolved.artifact.versions.length}`,
33231
- ...approvalLine(resolved.artifact) === undefined ? [] : [approvalLine(resolved.artifact)]
33330
+ ...approvalLine(resolved.artifact) === undefined ? [] : [approvalLine(resolved.artifact)],
33331
+ ...await graphSections(client, documentRef)
33232
33332
  ].join(`
33233
33333
  `),
33234
33334
  details: {
@@ -33257,7 +33357,7 @@ ${trailer.join(`
33257
33357
  references = error instanceof DispatchServiceError && error.status === 404 ? "unavailable" : `unavailable: ${error instanceof Error ? error.message : String(error)}`;
33258
33358
  }
33259
33359
  return {
33260
- text: issueSummary(read.issue, read.events, references),
33360
+ text: issueSummary(read.issue, read.events, references, await graphSections(client, `dispatch://${read.issue.key}`)),
33261
33361
  details: { issue: read.issue.key }
33262
33362
  };
33263
33363
  }
package/dist/legion.js CHANGED
@@ -29220,6 +29220,14 @@ var dispatchToolSpecs = [
29220
29220
  reason: z.string({ min: 1 }).describe("Why the open ask no longer needs a human answer.")
29221
29221
  })
29222
29222
  },
29223
+ {
29224
+ name: "dispatch_resolve_comment",
29225
+ 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.",
29226
+ arguments: (z) => ({
29227
+ 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.")
29228
+ }),
29229
+ strict: true
29230
+ },
29223
29231
  {
29224
29232
  name: "dispatch_follow",
29225
29233
  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>.>",
@@ -29337,7 +29345,7 @@ var dispatchToolSpecs = [
29337
29345
  },
29338
29346
  {
29339
29347
  name: "dispatch_read",
29340
- 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,
29348
+ 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,
29341
29349
  arguments: (z) => ({
29342
29350
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
29343
29351
  project: z.string().describe("Project key owning the document.").optional(),
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "1.38.5",
3
+ "version": "1.40.0",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [