@sjawhar/opencode-legion-envoy 1.30.0 → 1.31.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
@@ -13,6 +13,7 @@ This package exposes:
13
13
  - `envoy_whoami`
14
14
  - `envoy_sessions`
15
15
  - `dispatch_issue`
16
+ - `dispatch_issue_update`
16
17
  - `dispatch_ask`
17
18
  - `dispatch_edit_ask`
18
19
  - `dispatch_resolve_ask`
@@ -30,7 +31,7 @@ This package exposes:
30
31
  - `dispatch_open_asks`
31
32
  - `dispatch_whoami`
32
33
 
33
- The seventeen native `dispatch_*` tools create and read Dispatch issues, asks, comments,
34
+ The eighteen native `dispatch_*` tools create and read Dispatch issues, asks, comments,
34
35
  documents, and artifacts, or search all of them. They are present when `dispatch.enabled`
35
36
  resolves a server URL and bearer token from envoy.json (`~/.config/opencode/envoy.json`, merged
36
37
  with `<repo>/.opencode/envoy.json`) or the `DISPATCH_URL` and `DISPATCH_TOKEN` environment
@@ -13779,6 +13779,17 @@ var SPEC_SECTIONS = [
13779
13779
  ];
13780
13780
  var SPEC_WRITING_GUIDANCE = `When writing a spec, use these sections in order: ${SPEC_SECTIONS.join(", ")}. ` + "Write for a reader who has not seen the code: plain sentences, every identifier expanded on " + "first use, no coined shorthand; see skills/dispatch Writing for the human and Writing a spec.";
13781
13781
  var ASK_URGENCIES = ["low", "med", "high", "blocking"];
13782
+ var ISSUE_STATUSES = [
13783
+ "triage",
13784
+ "icebox",
13785
+ "backlog",
13786
+ "todo",
13787
+ "in_progress",
13788
+ "testing",
13789
+ "needs_review",
13790
+ "retro",
13791
+ "done"
13792
+ ];
13782
13793
  var DOC_EDIT_OPS = ["replace", "delete", "insert", "retype", "move"];
13783
13794
  var dispatchToolSpecs = [
13784
13795
  {
@@ -13796,6 +13807,26 @@ var dispatchToolSpecs = [
13796
13807
  assignee: z.string().describe("GitHub login of the human who answers this issue's asks; defaults to your owner when you act for a person, else the parent's assignee, else unassigned.").optional()
13797
13808
  })
13798
13809
  },
13810
+ {
13811
+ name: "dispatch_issue_update",
13812
+ description: "Update an existing issue: move its lifecycle status, retitle it, replace its labels, link a URL " + "(the pull request that delivers it, a run, a document), or set its route. 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. Priority is the human's and is not settable here. At least one " + `field besides issue is required. ${ISSUE_REFERENCE}`,
13813
+ arguments: (z) => ({
13814
+ issue: z.string().describe(ISSUE_REFERENCE),
13815
+ status: z.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
13816
+ title: z.string({ min: 1 }).describe("Replacement title.").optional(),
13817
+ labels: z.array(z.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(),
13818
+ external_links: z.array(z.string({ min: 1 })).describe("URLs to link; merged into the issue's existing external links by URL.").optional(),
13819
+ route: z.string().describe("Route the issue to role:<name> or session:<id>; an empty string clears it.").optional()
13820
+ }),
13821
+ validation: {
13822
+ check: (value) => {
13823
+ const input = value;
13824
+ return typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || Array.isArray(input.external_links) || typeof input.route === "string";
13825
+ },
13826
+ message: "Issue update requires at least one field besides issue: status, title, labels, external_links, or route."
13827
+ },
13828
+ strict: true
13829
+ },
13799
13830
  {
13800
13831
  name: "dispatch_ask",
13801
13832
  description: "Open a durable, answerable decision or human to-do on an issue or project document. Do not use it for a status update or discussion; " + "use dispatch_message instead. Use kind: action for a to-do a human must complete; it has fixed Done / Can't answers. " + "Anchor a document question, thread reply_to/reply_to_ask, or cite a dispatch:// " + 'reference \u2014 it must be answerable from its own text and anchor alone, never "see above". A quote anchor is pinned to its block. Question is at most 800 ' + `characters and has at most 8 options. ${OWNER_REFERENCE}`,
@@ -14166,17 +14197,6 @@ var legionRole = _enum2(LEGION_ROLES);
14166
14197
  var requiredUnknown = unknown().refine((value) => value !== undefined, {
14167
14198
  message: "Required"
14168
14199
  });
14169
- var LIFECYCLE_STATUSES = [
14170
- "triage",
14171
- "icebox",
14172
- "backlog",
14173
- "todo",
14174
- "in_progress",
14175
- "testing",
14176
- "needs_review",
14177
- "retro",
14178
- "done"
14179
- ];
14180
14200
  var architectCapability = strictObject({
14181
14201
  tree: nonEmptyString,
14182
14202
  sessionId: nonEmptyString,
@@ -14208,7 +14228,7 @@ var stateTreeLocator = discriminatedUnion("runtime", [
14208
14228
  var stateIssue = strictObject({
14209
14229
  key: nonEmptyString,
14210
14230
  title: string2(),
14211
- status: _enum2(LIFECYCLE_STATUSES).optional(),
14231
+ status: _enum2(ISSUE_STATUSES).optional(),
14212
14232
  children: array(nonEmptyString),
14213
14233
  parent: nonEmptyString.optional(),
14214
14234
  lastAppliedSeq: number2().int().nonnegative().optional()
@@ -14377,7 +14397,7 @@ var LegionDaemonApi = {
14377
14397
  },
14378
14398
  IssueStatus: {
14379
14399
  request: controllerIssue.extend({
14380
- status: _enum2(LIFECYCLE_STATUSES),
14400
+ status: _enum2(ISSUE_STATUSES),
14381
14401
  tree: nonEmptyString.optional(),
14382
14402
  sessionId: nonEmptyString.optional()
14383
14403
  }),
@@ -14799,6 +14819,9 @@ class DispatchClient {
14799
14819
  async getIssue(issue) {
14800
14820
  return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue)]);
14801
14821
  }
14822
+ async updateIssue(issue, input) {
14823
+ return this.#json("PATCH", ["api", "v1", "issues", await this.#resolveIssue(issue)], input);
14824
+ }
14802
14825
  async getIssueEvents(issue, after = 0, limit = 200) {
14803
14826
  return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "events"], undefined, { after, limit });
14804
14827
  }
@@ -15980,6 +16003,51 @@ async function executeDispatchTool(input) {
15980
16003
  };
15981
16004
  }
15982
16005
  }
16006
+ case "dispatch_issue_update": {
16007
+ const issueKey = issue();
16008
+ const status = optionalString(args, "status");
16009
+ const title = optionalString(args, "title");
16010
+ const route = optionalString(args, "route");
16011
+ const labels = Array.isArray(args.labels) ? args.labels : undefined;
16012
+ const requestedLinks = Array.isArray(args.external_links) ? [...new Set(args.external_links)] : undefined;
16013
+ let newLinks = [];
16014
+ try {
16015
+ const before = await client.getIssue(issueKey);
16016
+ const linked = before.external_links.map((link) => link.url);
16017
+ newLinks = requestedLinks?.filter((url) => !linked.includes(url)) ?? [];
16018
+ const after = await client.updateIssue(issueKey, {
16019
+ ...status === undefined ? {} : { status },
16020
+ ...title === undefined ? {} : { title },
16021
+ ...labels === undefined ? {} : { labels },
16022
+ ...route === undefined ? {} : { route },
16023
+ ...requestedLinks === undefined ? {} : { external_links: [...before.external_links, ...newLinks.map((url) => ({ url }))] },
16024
+ actor
16025
+ });
16026
+ const linkCount = `(${after.external_links.length} ${after.external_links.length === 1 ? "link" : "links"})`;
16027
+ const changes = [
16028
+ ...status === undefined ? [] : [`status ${before.status} -> ${after.status}`],
16029
+ ...title === undefined ? [] : [`title "${after.title}"`],
16030
+ ...labels === undefined ? [] : [after.labels.length === 0 ? "labels cleared" : `labels ${after.labels.join(", ")}`],
16031
+ ...requestedLinks === undefined ? [] : [
16032
+ newLinks.length === 0 ? `already linked ${requestedLinks.join(", ")} ${linkCount}` : `linked ${newLinks.join(", ")} ${linkCount}`
16033
+ ],
16034
+ ...route === undefined ? [] : [after.route === null ? "route cleared" : `route ${after.route}`]
16035
+ ];
16036
+ return {
16037
+ text: `${after.key}: ${changes.join("; ")} ${notSubscribed(issueTopic(after.key))}`,
16038
+ details: {
16039
+ issue: after.key,
16040
+ status: after.status,
16041
+ external_links: after.external_links.map((link) => link.url)
16042
+ }
16043
+ };
16044
+ } catch (error) {
16045
+ if (!(error instanceof DispatchServiceError))
16046
+ throw error;
16047
+ const taken = error.status === 500 && newLinks.length > 0 ? `; one of ${newLinks.join(", ")} may already be linked from another issue (a URL links exactly one issue)` : "";
16048
+ throw new DispatchServiceError(error.code, error.status, `${error.code}: ${error.message}${taken}`, error.candidates);
16049
+ }
16050
+ }
15983
16051
  case "dispatch_search": {
15984
16052
  const query = stringArg(args, "query");
15985
16053
  const project = optionalString(args, "project");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -108,6 +108,29 @@ dispatch_issue({ project, title, parent?, external?, spec?, force?, labels?: str
108
108
  `details` `{ issue }`; creating an issue does not subscribe you to it (see [Following](#following)). Use `dispatch_issue` only to create an issue; never use it to park a question. When `spec` is supplied,
109
109
  follow [Writing a spec](#writing-a-spec).
110
110
 
111
+ ## Issue status is yours to move
112
+
113
+ The issue's status is how a human sees delivery without asking a session. Outside Legion (where
114
+ the daemon writes it), the session doing the work moves it, the way a person moves a card:
115
+ `in_progress` when implementation starts, `testing` when the change is being proven on a
116
+ production-like surface, `needs_review` when its pull request is open and waiting on the merge
117
+ queue, `done` when the change has been driven in production (a merge is not `done`). Move child
118
+ issues you own as well as the root. An issue left at `triage` while work is underway is a defect:
119
+ Sami, 2026-09-15, on the roadmap he could not read — "I'm not even sure what their development
120
+ status is." Waiting for the deploy lane is not a status and is never announced. Priority stays the
121
+ human's: set it on creation only when their intent is clear, and change it only on their word.
122
+
123
+ ```ts
124
+ // PATCH /api/v1/issues/{key} — status, title, labels, external_links (merged by URL), route
125
+ dispatch_issue_update({ issue: "AGENTC-175", status: "testing" })
126
+ dispatch_issue_update({ issue: "AGENTC-175", external_links: ["https://github.com/owner/repo/pull/7"] })
127
+ ```
128
+
129
+ Link the pull request that delivers the issue in `external_links` when you open it; the issue page
130
+ renders its state and checks from that link. The call is authenticated with the same bearer as every
131
+ other `dispatch_*` tool: a Legion pane reads it from the `DISPATCH_TOKEN_FILE` path the daemon sets on
132
+ the pane; an OMP session outside Legion reads `dispatch.token` from `~/.config/opencode/envoy.json`.
133
+
111
134
  ## Search first
112
135
 
113
136
  Before you create an issue or start a design document, search: