@sjawhar/opencode-legion-envoy 1.29.1 → 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`
@@ -28,8 +29,9 @@ This package exposes:
28
29
  - `dispatch_read`
29
30
  - `dispatch_search`
30
31
  - `dispatch_open_asks`
32
+ - `dispatch_whoami`
31
33
 
32
- The sixteen native `dispatch_*` tools create and read Dispatch issues, asks, comments,
34
+ The eighteen native `dispatch_*` tools create and read Dispatch issues, asks, comments,
33
35
  documents, and artifacts, or search all of them. They are present when `dispatch.enabled`
34
36
  resolves a server URL and bearer token from envoy.json (`~/.config/opencode/envoy.json`, merged
35
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
  {
@@ -13792,9 +13803,30 @@ var dispatchToolSpecs = [
13792
13803
  force: z.boolean().describe("Create even though POSSIBLE_DUPLICATE listed similar issues; pass it only after reading them.").optional(),
13793
13804
  spec: z.string().describe(`Optional initial primary-document markdown. ${SPEC_WRITING_GUIDANCE}`).optional(),
13794
13805
  labels: z.array(z.string({ min: 1, max: 40 }), { max: 20 }).describe("Optional initial labels, at most 20 labels of up to 40 characters.").optional(),
13795
- priority: z.number({ int: true, min: 0, max: 3 }).describe("Optional coarse priority: P0 is highest and P3 is lowest.").optional()
13806
+ priority: z.number({ int: true, min: 0, max: 3 }).describe("Optional coarse priority: P0 is highest and P3 is lowest.").optional(),
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()
13796
13808
  })
13797
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
+ },
13798
13830
  {
13799
13831
  name: "dispatch_ask",
13800
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}`,
@@ -13997,6 +14029,12 @@ var dispatchToolSpecs = [
13997
14029
  description: "List this session's active unanswered asks across issues and project documents, including age and whose reply is needed. Call before saying you are waiting for human input.",
13998
14030
  arguments: () => ({}),
13999
14031
  strict: true
14032
+ },
14033
+ {
14034
+ name: "dispatch_whoami",
14035
+ description: "Who Dispatch takes this session for: {session, owner}. owner is the lowercase GitHub login of the human whose personal token you run under (the default assignee of issues you create), or null under the shared token.",
14036
+ arguments: () => ({}),
14037
+ strict: true
14000
14038
  }
14001
14039
  ];
14002
14040
  // ../contracts/src/envelope.ts
@@ -14159,17 +14197,6 @@ var legionRole = _enum2(LEGION_ROLES);
14159
14197
  var requiredUnknown = unknown().refine((value) => value !== undefined, {
14160
14198
  message: "Required"
14161
14199
  });
14162
- var LIFECYCLE_STATUSES = [
14163
- "triage",
14164
- "icebox",
14165
- "backlog",
14166
- "todo",
14167
- "in_progress",
14168
- "testing",
14169
- "needs_review",
14170
- "retro",
14171
- "done"
14172
- ];
14173
14200
  var architectCapability = strictObject({
14174
14201
  tree: nonEmptyString,
14175
14202
  sessionId: nonEmptyString,
@@ -14201,7 +14228,7 @@ var stateTreeLocator = discriminatedUnion("runtime", [
14201
14228
  var stateIssue = strictObject({
14202
14229
  key: nonEmptyString,
14203
14230
  title: string2(),
14204
- status: _enum2(LIFECYCLE_STATUSES).optional(),
14231
+ status: _enum2(ISSUE_STATUSES).optional(),
14205
14232
  children: array(nonEmptyString),
14206
14233
  parent: nonEmptyString.optional(),
14207
14234
  lastAppliedSeq: number2().int().nonnegative().optional()
@@ -14370,7 +14397,7 @@ var LegionDaemonApi = {
14370
14397
  },
14371
14398
  IssueStatus: {
14372
14399
  request: controllerIssue.extend({
14373
- status: _enum2(LIFECYCLE_STATUSES),
14400
+ status: _enum2(ISSUE_STATUSES),
14374
14401
  tree: nonEmptyString.optional(),
14375
14402
  sessionId: nonEmptyString.optional()
14376
14403
  }),
@@ -14792,6 +14819,9 @@ class DispatchClient {
14792
14819
  async getIssue(issue) {
14793
14820
  return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue)]);
14794
14821
  }
14822
+ async updateIssue(issue, input) {
14823
+ return this.#json("PATCH", ["api", "v1", "issues", await this.#resolveIssue(issue)], input);
14824
+ }
14795
14825
  async getIssueEvents(issue, after = 0, limit = 200) {
14796
14826
  return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "events"], undefined, { after, limit });
14797
14827
  }
@@ -14810,6 +14840,9 @@ class DispatchClient {
14810
14840
  ...since === undefined ? {} : { since }
14811
14841
  });
14812
14842
  }
14843
+ async whoami() {
14844
+ return this.#json("GET", ["api", "v1", "whoami"]);
14845
+ }
14813
14846
  async resolveAsk(id, input) {
14814
14847
  return this.#json("POST", ["api", "v1", "asks", id, "resolve"], input);
14815
14848
  }
@@ -15223,7 +15256,8 @@ var issueFreeTools = {
15223
15256
  dispatch_resolve_comment: true,
15224
15257
  dispatch_follow: true,
15225
15258
  dispatch_search: true,
15226
- dispatch_open_asks: true
15259
+ dispatch_open_asks: true,
15260
+ dispatch_whoami: true
15227
15261
  };
15228
15262
  function canonicalExternalIssueRef(value) {
15229
15263
  const match = value.trim().match(externalIssueRefPattern);
@@ -15639,10 +15673,13 @@ function issueSummary(issue, events, references, graph) {
15639
15673
  const specApproval = spec === undefined ? undefined : approvalLine(spec);
15640
15674
  if (issue.priority === undefined)
15641
15675
  throw new Error("Dispatch issue is missing priority");
15676
+ if (issue.assignee === undefined)
15677
+ throw new Error("Dispatch issue is missing assignee");
15642
15678
  return [
15643
15679
  `Title: ${issue.title}`,
15644
15680
  `Key: ${issue.key}`,
15645
15681
  `Status: ${issue.status}`,
15682
+ `Assignee: ${issue.assignee ?? "unassigned"}`,
15646
15683
  ...issue.priority === null ? [] : [`Priority: P${issue.priority}`],
15647
15684
  `Labels: ${issue.labels.length === 0 ? "none" : issue.labels.join(", ")}`,
15648
15685
  `Route: ${issue.route ?? "none"}`,
@@ -15890,6 +15927,18 @@ async function executeDispatchTool(input) {
15890
15927
  const response = await client.openAsks(sessionId);
15891
15928
  return { text: formatOpenAsksSummary(response, configUrl), details: { ...response } };
15892
15929
  }
15930
+ if (input.tool === "dispatch_whoami") {
15931
+ const sessionId = input.sessionId?.trim();
15932
+ if (!sessionId)
15933
+ throw new Error("host session id is required for dispatch_whoami");
15934
+ const client = new DispatchClient(configUrl, configToken, fetchImpl, input.signal);
15935
+ const identity = await client.whoami();
15936
+ const owner = identity.kind === "agent" ? identity.owner : identity.login.toLowerCase();
15937
+ return {
15938
+ text: owner === null ? `Session ${sessionId} runs under the shared token: no owner, so issues you create without an assignee are unassigned (or inherit their parent's).` : `Session ${sessionId} acts for ${owner}: issues you create without an assignee are assigned to ${owner}.`,
15939
+ details: { session: sessionId, owner }
15940
+ };
15941
+ }
15893
15942
  const args = parsed.success ? parsed.data : ownerArguments.args;
15894
15943
  const actor = toolActor(await resolveOrigin(env, exec, input.cwd), input);
15895
15944
  const client = new DispatchClient(configUrl, configToken, fetchImpl, input.signal);
@@ -15916,6 +15965,7 @@ async function executeDispatchTool(input) {
15916
15965
  const force = optionalBoolean(args, "force");
15917
15966
  const spec = optionalString(args, "spec");
15918
15967
  const priority = optionalNumber(args, "priority");
15968
+ const assignee = optionalString(args, "assignee");
15919
15969
  const labels = args.labels;
15920
15970
  try {
15921
15971
  const created = await client.issue({
@@ -15926,6 +15976,7 @@ async function executeDispatchTool(input) {
15926
15976
  ...force === undefined ? {} : { force },
15927
15977
  ...spec === undefined ? {} : { spec },
15928
15978
  ...priority === undefined ? {} : { priority },
15979
+ ...assignee === undefined ? {} : { assignee },
15929
15980
  ...Array.isArray(labels) ? { labels } : {},
15930
15981
  actor
15931
15982
  });
@@ -15952,6 +16003,51 @@ async function executeDispatchTool(input) {
15952
16003
  };
15953
16004
  }
15954
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
+ }
15955
16051
  case "dispatch_search": {
15956
16052
  const query = stringArg(args, "query");
15957
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.29.1",
3
+ "version": "1.31.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -90,16 +90,47 @@ exactly one owner to every owner-scoped tool: `issue` for an issue, or `project`
90
90
  [References](#references) for the resulting ref shape). On first use, an external issue reference creates its native issue in the
91
91
  project configured for that repository in Dispatch Settings, then falls back to `DISPATCH_DEFAULT_PROJECT`.
92
92
 
93
- Issue reads include `rank`, the server-owned ordering key used by project boards; reorder through `PATCH /api/v1/issues/{key}` with neighboring issue keys. They also include nullable coarse priority (`P0` highest through `P3` lowest).
93
+ Issue reads include `rank`, the server-owned ordering key used by project boards; reorder through `PATCH /api/v1/issues/{key}` with neighboring issue keys. They also include nullable coarse priority (`P0` highest through `P3` lowest) and `assignee`: the lowercase GitHub login of the human who answers the issue's asks, or `null` when nobody holds it. `dispatch_read` of an issue prints it as `Assignee: <login>` or `Assignee: unassigned`.
94
+
95
+ ### Who answers an ask
96
+
97
+ An ask goes to the issue's assignee: their Inbox opens on **Mine**, which lists asks on the issues they hold plus an Unassigned band; an ask on an unassigned issue waits in that band for someone to take it. Find out who Dispatch takes you for with:
98
+ ```ts
99
+ dispatch_whoami({})
100
+ ```
101
+ It returns `details` `{ session, owner }`: `owner` is the lowercase login of the human whose personal token you run under, or `null` under the shared token. An issue you create without `assignee` goes to your owner; under the shared token it inherits its parent's assignee, or stays unassigned without a parent. If an issue you are asking on is unassigned and the answer matters, assign it to your owner (`PATCH /api/v1/issues/{key}` with `{"assignee": "<login>"}`; any authenticated caller may reassign, and an unlisted login is refused with `ASSIGNEE_NOT_ALLOWED`) or name in the question who should answer it. Never reassign an issue a human holds to get an answer faster: that is the human's call.
94
102
 
95
103
  Architects create newly tracked child work with:
96
104
  ```ts
97
- dispatch_issue({ project, title, parent?, external?, spec?, force?, labels?: string[], priority?: 0 | 1 | 2 | 3 })
105
+ dispatch_issue({ project, title, parent?, external?, spec?, force?, labels?: string[], priority?: 0 | 1 | 2 | 3, assignee?: string })
98
106
  ```
99
- `labels` are optional initial labels: Dispatch trims them, preserves their case, and removes case-insensitive duplicates. Set `priority` on creation only when the human's intent makes the bucket clear; otherwise priority remains the human's decision. It returns
107
+ `labels` are optional initial labels: Dispatch trims them, preserves their case, and removes case-insensitive duplicates. Set `priority` on creation only when the human's intent makes the bucket clear; otherwise priority remains the human's decision. Set `assignee` (a GitHub login on the sign-in allowlist) only when the human said who owns the work; otherwise the default above applies, so a child inherits its parent's assignee. It returns
100
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,
101
109
  follow [Writing a spec](#writing-a-spec).
102
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
+
103
134
  ## Search first
104
135
 
105
136
  Before you create an issue or start a design document, search:
@@ -75,7 +75,10 @@ exercise a criterion end to end, building that path is a child issue of this tre
75
75
  the role-token `<project>` (the daemon's own project, e.g. `acme`), a different string. A
76
76
  root session has `LEGION_TREE == LEGION_ISSUE`. The daemon establishes the sub-issue
77
77
  relationship from `parent`. Keep the returned issue keys in ordered waves; a child is
78
- inert until released.
78
+ inert until released. Do not pass `assignee`: the default keeps the tree's questions in one
79
+ Inbox — under the shared token a child inherits its parent's assignee (the human who
80
+ answers the tree's asks); under a personal token it goes to that token's owner, whom
81
+ `dispatch_whoami` names. Set it only when a human told you a specific person owns that child.
79
82
 
80
83
  Specifications written into Dispatch follow [`skills/dispatch`'s Writing a spec](../dispatch/SKILL.md#writing-a-spec).
81
84
  Wave releases, child closures, and your own status are visible from the issue tree and the
@@ -107,6 +107,9 @@ override a Sami ruling quoted here.
107
107
  1. Read `legion state --json`, then inspect the reported Dispatch issue with `dispatch_read`.
108
108
  Verify the issue is in this project, is eligible for a root process, and whether it
109
109
  has pre-existing children. Dispatch and daemon state, not the wake text, decide triage.
110
+ Note the `Assignee:` line: that human answers the tree's asks, and their Inbox opens on
111
+ the issues they hold. Never reassign during triage — who holds an issue is the humans'
112
+ decision, made from the issue header.
110
113
  2. If it should run now, admit the root issue:
111
114
 
112
115
  ```text
@@ -123,6 +126,11 @@ override a Sami ruling quoted here.
123
126
  (or `icebox` for longer-term deferral). Dispatch status is the durable record;
124
127
  there is no separate marker to maintain. Do not triage a system-created child as a root
125
128
  issue.
129
+ 4. When you post a triage note (a `dispatch_comment` on the issue saying what you decided and
130
+ why), name who will be asked: `Assigned to <login>, who will get this tree's questions`,
131
+ or, when the `Assignee:` line says `unassigned`, `Unassigned — nobody's Inbox shows this
132
+ tree's questions until someone takes it from the issue header (Assignee, beside Priority)`.
133
+ An unassigned root still runs; the architect's asks wait in every Inbox's Unassigned band.
126
134
 
127
135
  ## Backlog eligibility
128
136