@sjawhar/opencode-legion-envoy 3.6.0 → 3.8.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.
@@ -13978,11 +13978,16 @@ var dispatchToolSpecs = [
13978
13978
  },
13979
13979
  {
13980
13980
  name: "dispatch_issue_update",
13981
- example: { issue: "DSP-1", status: "in_progress" },
13982
- description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, or attach it to architecture components. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. external_links are " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link. components replaces the issue's own attachment. A closed issue " + "takes only rank, components, and a reopening status (any status but done); everything " + "else, priority included, waits for the reopen. " + "priority is yours to set and a human overrides it; rank, the board's own order, is not " + "settable here. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
13981
+ example: {
13982
+ issue: "DSP-1",
13983
+ status: "done",
13984
+ reason: "Shipped in owner/repo#7; verified on the production dashboard."
13985
+ },
13986
+ description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, set " + "its priority, link a URL (the pull request that delivers it, a run, a document), set its " + "route, set or clear its parent, or attach it to architecture components. Status is one of " + `${ISSUE_STATUSES.join(", ")}; outside Legion, move it yourself as the work advances; inside ` + "Legion the daemon moves it. external_links are " + "merged into the issue's existing links by URL, so linking the pull request you just opened " + "keeps every earlier link. components replaces the issue's own attachment. Closing an issue " + "(status done) requires reason, the note that says why: it is posted on the issue as a " + "message, then the issue closes, because a closed issue refuses messages, comments, and " + "artifacts; reason goes only with status done. A closed issue takes only rank, components, " + "and a reopening status (any status but done); everything else, priority included, waits " + "for the reopen. " + "priority is yours to set and a human overrides it; rank, the board's own order, is not " + "settable here. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
13983
13987
  arguments: (z2) => ({
13984
13988
  issue: z2.string().describe(ISSUE_REFERENCE),
13985
13989
  status: z2.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
13990
+ reason: z2.string({ max: 2000 }).describe("Required with status done, and only with it: why the issue is closing, at most 2,000 characters. Posted on the issue as a message before it closes.").optional(),
13986
13991
  title: z2.string({ min: 1 }).describe("Replacement title.").optional(),
13987
13992
  labels: z2.array(z2.string({ min: 1, max: 40 }), { max: 20 }).describe("Replacement label set, at most 20 labels of up to 40 characters; replaces every existing label.").optional(),
13988
13993
  priority: z2.number({ int: true, min: 0, max: 3 }).nullable().optional().describe("Coarse priority: 0 is P0 (highest) through 3 is P3 (lowest); null clears it."),
@@ -13994,9 +13999,10 @@ var dispatchToolSpecs = [
13994
13999
  validation: {
13995
14000
  check: (value) => {
13996
14001
  const input = value;
13997
- return typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || typeof input.components === "object" && input.components !== null;
14002
+ const reasonFits = input.status === "done" ? typeof input.reason === "string" && input.reason.trim() !== "" : input.reason === undefined;
14003
+ return reasonFits && (typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || typeof input.priority === "number" || input.priority === null || Array.isArray(input.external_links) || typeof input.route === "string" || typeof input.parent === "string" || typeof input.components === "object" && input.components !== null);
13998
14004
  },
13999
- message: "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, or components."
14005
+ message: "Issue update requires at least one field besides issue: status, title, labels, priority, external_links, route, parent, or components. " + "status done requires reason, a non-empty note saying why the issue is closing, posted on the issue before it closes because a closed issue refuses messages, comments, and artifacts; reason goes only with status done."
14000
14006
  },
14001
14007
  strict: true
14002
14008
  },
@@ -16548,6 +16554,7 @@ async function executeDispatchTool(input) {
16548
16554
  case "dispatch_issue_update": {
16549
16555
  const issueKey = issue2();
16550
16556
  const status = optionalString(args, "status");
16557
+ const reason = optionalString(args, "reason");
16551
16558
  const title = optionalString(args, "title");
16552
16559
  const route = optionalString(args, "route");
16553
16560
  const parent = optionalString(args, "parent");
@@ -16555,12 +16562,29 @@ async function executeDispatchTool(input) {
16555
16562
  const priority = optionalPriority(args, "priority");
16556
16563
  const labels = Array.isArray(args.labels) ? args.labels : undefined;
16557
16564
  const requestedLinks = Array.isArray(args.external_links) ? [...new Set(args.external_links)] : undefined;
16558
- let newLinks = [];
16565
+ let before;
16566
+ try {
16567
+ before = await client.getIssue(issueKey);
16568
+ } catch (error48) {
16569
+ throw refusalWithCode(error48);
16570
+ }
16571
+ let closingNote;
16572
+ if (reason !== undefined) {
16573
+ try {
16574
+ const message = await client.message(issueKey, { body: reason, actor });
16575
+ closingNote = {
16576
+ id: message.id,
16577
+ ref: dispatchChildRef(dispatchIssueRef(issueKey), "message", message.id)
16578
+ };
16579
+ } catch (error48) {
16580
+ throw refusalWithCode(error48, "; the reason was not posted, so the close was not sent");
16581
+ }
16582
+ }
16583
+ const linked = before.external_links.map((link) => link.url);
16584
+ const newLinks = requestedLinks?.filter((url2) => !linked.includes(url2)) ?? [];
16585
+ let after;
16559
16586
  try {
16560
- const before = await client.getIssue(issueKey);
16561
- const linked = before.external_links.map((link) => link.url);
16562
- newLinks = requestedLinks?.filter((url2) => !linked.includes(url2)) ?? [];
16563
- const after = await client.updateIssue(issueKey, {
16587
+ after = await client.updateIssue(issueKey, {
16564
16588
  ...status === undefined ? {} : { status },
16565
16589
  ...title === undefined ? {} : { title },
16566
16590
  ...labels === undefined ? {} : { labels },
@@ -16571,39 +16595,50 @@ async function executeDispatchTool(input) {
16571
16595
  ...requestedLinks === undefined ? {} : { external_links: [...before.external_links, ...newLinks.map((url2) => ({ url: url2 }))] },
16572
16596
  actor
16573
16597
  });
16574
- const linkCount = `(${after.external_links.length} ${after.external_links.length === 1 ? "link" : "links"})`;
16575
- const changes = [
16576
- ...status === undefined ? [] : [`status ${before.status} -> ${after.status}`],
16577
- ...title === undefined ? [] : [`title "${after.title}"`],
16578
- ...labels === undefined ? [] : [after.labels.length === 0 ? "labels cleared" : `labels ${after.labels.join(", ")}`],
16579
- ...priority === undefined ? [] : [after.priority === null ? "priority cleared" : `priority -> P${after.priority}`],
16580
- ...requestedLinks === undefined ? [] : [
16581
- newLinks.length === 0 ? `already linked ${requestedLinks.join(", ")} ${linkCount}` : `linked ${newLinks.join(", ")} ${linkCount}`
16582
- ],
16583
- ...route === undefined ? [] : [after.route === null ? "route cleared" : `route ${after.route}`],
16584
- ...parent === undefined ? [] : [after.parent === null ? "parent cleared" : `parent -> ${after.parent}`],
16585
- ...components === undefined ? [] : [componentsChange(components, after.components)]
16586
- ];
16587
- const adviceLines = renderAdvice(input.tool, after.key, after.advice, {
16588
- setsStatus: status !== undefined
16589
- });
16590
- return {
16591
- text: [
16592
- `${after.key}: ${changes.join("; ")} ${notSubscribed(issueTopic(after.key))}`,
16593
- ...adviceLines
16594
- ].join(`
16595
- `),
16596
- details: {
16597
- issue: after.key,
16598
- status: after.status,
16599
- external_links: after.external_links.map((link) => link.url),
16600
- ...after.advice === undefined ? {} : { advice: after.advice }
16601
- }
16602
- };
16603
16598
  } catch (error48) {
16604
16599
  const taken = error48 instanceof DispatchServiceError && error48.status === 500 && newLinks.length > 0 ? `; one of ${newLinks.join(", ")} may already be linked from another issue (a URL links exactly one issue)` : "";
16605
- throw refusalWithCode(error48, taken);
16600
+ if (closingNote === undefined)
16601
+ throw refusalWithCode(error48, taken);
16602
+ const refused = error48 instanceof DispatchServiceError && error48.status < 500;
16603
+ const posted = `; the reason already landed as message ${closingNote.id} (${closingNote.ref})`;
16604
+ const landed = refused ? `${posted} but the issue did not close. Retrying this call posts its reason again, so fix what refused the close, then retry with a reason that points at message ${closingNote.id}` : `${posted}, and the close may or may not have taken effect. Read the issue's status before retrying: done means it closed; otherwise retry with a reason that points at message ${closingNote.id}, since retrying this call posts its reason again`;
16605
+ if (error48 instanceof DispatchServiceError)
16606
+ throw refusalWithCode(error48, taken + landed);
16607
+ throw new Error(`${error48 instanceof Error ? error48.message : String(error48)}${landed}`, {
16608
+ cause: error48
16609
+ });
16606
16610
  }
16611
+ const linkCount = `(${after.external_links.length} ${after.external_links.length === 1 ? "link" : "links"})`;
16612
+ const changes = [
16613
+ ...closingNote === undefined ? [] : [`reason posted as message ${closingNote.id} (${closingNote.ref})`],
16614
+ ...status === undefined ? [] : [`status ${before.status} -> ${after.status}`],
16615
+ ...title === undefined ? [] : [`title "${after.title}"`],
16616
+ ...labels === undefined ? [] : [after.labels.length === 0 ? "labels cleared" : `labels ${after.labels.join(", ")}`],
16617
+ ...priority === undefined ? [] : [after.priority === null ? "priority cleared" : `priority -> P${after.priority}`],
16618
+ ...requestedLinks === undefined ? [] : [
16619
+ newLinks.length === 0 ? `already linked ${requestedLinks.join(", ")} ${linkCount}` : `linked ${newLinks.join(", ")} ${linkCount}`
16620
+ ],
16621
+ ...route === undefined ? [] : [after.route === null ? "route cleared" : `route ${after.route}`],
16622
+ ...parent === undefined ? [] : [after.parent === null ? "parent cleared" : `parent -> ${after.parent}`],
16623
+ ...components === undefined ? [] : [componentsChange(components, after.components)]
16624
+ ];
16625
+ const adviceLines = renderAdvice(input.tool, after.key, after.advice, {
16626
+ setsStatus: status !== undefined
16627
+ });
16628
+ return {
16629
+ text: [
16630
+ `${after.key}: ${changes.join("; ")} ${notSubscribed(issueTopic(after.key))}`,
16631
+ ...adviceLines
16632
+ ].join(`
16633
+ `),
16634
+ details: {
16635
+ issue: after.key,
16636
+ status: after.status,
16637
+ external_links: after.external_links.map((link) => link.url),
16638
+ ...closingNote === undefined ? {} : { message: closingNote.id },
16639
+ ...after.advice === undefined ? {} : { advice: after.advice }
16640
+ }
16641
+ };
16607
16642
  }
16608
16643
  case "dispatch_claim": {
16609
16644
  const issueKey = issue2();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "3.6.0",
3
+ "version": "3.8.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -224,11 +224,19 @@ status is." Waiting for the deploy lane is not a status and is never announced.
224
224
  ```ts
225
225
  // PATCH /api/v1/issues/{key} — status, title, labels, priority, external_links (merged by URL), route, parent
226
226
  dispatch_issue_update({ issue: "AGENTC-175", status: "testing" })
227
+ dispatch_issue_update({ issue: "AGENTC-175", status: "done", reason: "Shipped in owner/repo#7; verified on the production dashboard." })
227
228
  dispatch_issue_update({ issue: "AGENTC-175", priority: 1 }) // 0–3; see Priority is yours to set
228
229
  dispatch_issue_update({ issue: "AGENTC-175", external_links: ["https://github.com/owner/repo/pull/7"] })
229
230
  dispatch_issue_update({ issue: "AGENTC-175", parent: "AGENTC-170" }) // same-project key; "" clears the parent
230
231
  ```
231
232
 
233
+ Closing takes a `reason`, and the tool refuses `status: "done"` without one: it posts the reason on
234
+ the issue as a message, then closes it, because a closed issue refuses messages, comments, and
235
+ artifacts, so a reason left for later has nowhere to go. When the close fails after the post, the
236
+ error names the posted message; after a timeout or a server error it also says the close may have
237
+ landed, so read the issue's status first. A retry points its reason at the posted message rather
238
+ than repeating it.
239
+
232
240
  The two clears differ: `priority` clears with `null`, while `parent` and `route` clear with `""`.
233
241
  Guessing the other one is a refusal either way.
234
242
 
@@ -833,7 +841,9 @@ message ref, it returns that message and its reply chain. Reads do not subscribe
833
841
  Every read ends with two sections from the reference graph. `Referenced by:` lists what points at the node — every document, ask,
834
842
  comment, or message that cites it, plus its structure: child issues, attached documents, anchored and owned asks and comments, replies,
835
843
  followers — and `Links:` lists what it cites. Each row is `- <edge kind> <node kind> dispatch://… (<excerpt> · <when>)`; for a
836
- document source the excerpt is the block containing the mention. Cross-project, always: a message on another project's issue that
844
+ document source the excerpt is the start of the block holding the mention, and a whole list is one block, so every issue named in
845
+ one list previews the list's first item. When a document references many issues and each backlink should read right, give each
846
+ issue its own paragraph (or block), not an item of one list. Cross-project, always: a message on another project's issue that
837
847
  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
838
848
  document" one read on the document. Cite with `dispatch://` references (below) whenever you name a node in a body — a bare id or
839
849
  title is invisible to the graph.