@sjawhar/pi-legion-envoy 1.41.0 → 1.42.1

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,7 +85,7 @@ extension files it does not contain.
85
85
 
86
86
  ## Native Dispatch tools
87
87
 
88
- The extension registers seventeen native Dispatch tools: `dispatch_issue`, `dispatch_ask`, `dispatch_edit_ask`,
88
+ The extension registers eighteen native Dispatch tools: `dispatch_issue`, `dispatch_issue_update`, `dispatch_ask`, `dispatch_edit_ask`,
89
89
  `dispatch_resolve_ask`, `dispatch_resolve_comment`, `dispatch_follow`, `dispatch_comment`, `dispatch_suggest`,
90
90
  `dispatch_message`, `dispatch_doc_edit`, `dispatch_doc_read`, `dispatch_request_approval`, `dispatch_artifact`,
91
91
  `dispatch_read`, `dispatch_search`, `dispatch_open_asks`, and `dispatch_whoami`, when Dispatch configuration resolves both a base URL and bearer token.
@@ -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 seventeen tools unavailable and
115
+ an invalid URL, or an empty token leaves the eighteen 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
package/dist/envoy.js CHANGED
@@ -29876,6 +29876,17 @@ var SPEC_SECTIONS = [
29876
29876
  ];
29877
29877
  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.";
29878
29878
  var ASK_URGENCIES = ["low", "med", "high", "blocking"];
29879
+ var ISSUE_STATUSES = [
29880
+ "triage",
29881
+ "icebox",
29882
+ "backlog",
29883
+ "todo",
29884
+ "in_progress",
29885
+ "testing",
29886
+ "needs_review",
29887
+ "retro",
29888
+ "done"
29889
+ ];
29879
29890
  var DOC_EDIT_OPS = ["replace", "delete", "insert", "retype", "move"];
29880
29891
  var dispatchToolSpecs = [
29881
29892
  {
@@ -29893,6 +29904,26 @@ var dispatchToolSpecs = [
29893
29904
  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()
29894
29905
  })
29895
29906
  },
29907
+ {
29908
+ name: "dispatch_issue_update",
29909
+ 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}`,
29910
+ arguments: (z) => ({
29911
+ issue: z.string().describe(ISSUE_REFERENCE),
29912
+ status: z.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
29913
+ title: z.string({ min: 1 }).describe("Replacement title.").optional(),
29914
+ 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(),
29915
+ external_links: z.array(z.string({ min: 1 })).describe("URLs to link; merged into the issue's existing external links by URL.").optional(),
29916
+ route: z.string().describe("Route the issue to role:<name> or session:<id>; an empty string clears it.").optional()
29917
+ }),
29918
+ validation: {
29919
+ check: (value) => {
29920
+ const input = value;
29921
+ return typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || Array.isArray(input.external_links) || typeof input.route === "string";
29922
+ },
29923
+ message: "Issue update requires at least one field besides issue: status, title, labels, external_links, or route."
29924
+ },
29925
+ strict: true
29926
+ },
29896
29927
  {
29897
29928
  name: "dispatch_ask",
29898
29929
  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}`,
@@ -30280,17 +30311,6 @@ var legionRole = _enum2(LEGION_ROLES);
30280
30311
  var requiredUnknown = unknown().refine((value) => value !== undefined, {
30281
30312
  message: "Required"
30282
30313
  });
30283
- var LIFECYCLE_STATUSES = [
30284
- "triage",
30285
- "icebox",
30286
- "backlog",
30287
- "todo",
30288
- "in_progress",
30289
- "testing",
30290
- "needs_review",
30291
- "retro",
30292
- "done"
30293
- ];
30294
30314
  var architectCapability = strictObject({
30295
30315
  tree: nonEmptyString,
30296
30316
  sessionId: nonEmptyString,
@@ -30322,7 +30342,7 @@ var stateTreeLocator = discriminatedUnion("runtime", [
30322
30342
  var stateIssue = strictObject({
30323
30343
  key: nonEmptyString,
30324
30344
  title: string2(),
30325
- status: _enum2(LIFECYCLE_STATUSES).optional(),
30345
+ status: _enum2(ISSUE_STATUSES).optional(),
30326
30346
  children: array(nonEmptyString),
30327
30347
  parent: nonEmptyString.optional(),
30328
30348
  lastAppliedSeq: number2().int().nonnegative().optional()
@@ -30491,7 +30511,7 @@ var LegionDaemonApi = {
30491
30511
  },
30492
30512
  IssueStatus: {
30493
30513
  request: controllerIssue.extend({
30494
- status: _enum2(LIFECYCLE_STATUSES),
30514
+ status: _enum2(ISSUE_STATUSES),
30495
30515
  tree: nonEmptyString.optional(),
30496
30516
  sessionId: nonEmptyString.optional()
30497
30517
  }),
@@ -31862,6 +31882,9 @@ class DispatchClient {
31862
31882
  async getIssue(issue) {
31863
31883
  return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue)]);
31864
31884
  }
31885
+ async updateIssue(issue, input) {
31886
+ return this.#json("PATCH", ["api", "v1", "issues", await this.#resolveIssue(issue)], input);
31887
+ }
31865
31888
  async getIssueEvents(issue, after = 0, limit = 200) {
31866
31889
  return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "events"], undefined, { after, limit });
31867
31890
  }
@@ -33043,6 +33066,51 @@ async function executeDispatchTool(input) {
33043
33066
  };
33044
33067
  }
33045
33068
  }
33069
+ case "dispatch_issue_update": {
33070
+ const issueKey = issue();
33071
+ const status = optionalString(args, "status");
33072
+ const title = optionalString(args, "title");
33073
+ const route = optionalString(args, "route");
33074
+ const labels = Array.isArray(args.labels) ? args.labels : undefined;
33075
+ const requestedLinks = Array.isArray(args.external_links) ? [...new Set(args.external_links)] : undefined;
33076
+ let newLinks = [];
33077
+ try {
33078
+ const before = await client.getIssue(issueKey);
33079
+ const linked = before.external_links.map((link) => link.url);
33080
+ newLinks = requestedLinks?.filter((url) => !linked.includes(url)) ?? [];
33081
+ const after = await client.updateIssue(issueKey, {
33082
+ ...status === undefined ? {} : { status },
33083
+ ...title === undefined ? {} : { title },
33084
+ ...labels === undefined ? {} : { labels },
33085
+ ...route === undefined ? {} : { route },
33086
+ ...requestedLinks === undefined ? {} : { external_links: [...before.external_links, ...newLinks.map((url) => ({ url }))] },
33087
+ actor
33088
+ });
33089
+ const linkCount = `(${after.external_links.length} ${after.external_links.length === 1 ? "link" : "links"})`;
33090
+ const changes = [
33091
+ ...status === undefined ? [] : [`status ${before.status} -> ${after.status}`],
33092
+ ...title === undefined ? [] : [`title "${after.title}"`],
33093
+ ...labels === undefined ? [] : [after.labels.length === 0 ? "labels cleared" : `labels ${after.labels.join(", ")}`],
33094
+ ...requestedLinks === undefined ? [] : [
33095
+ newLinks.length === 0 ? `already linked ${requestedLinks.join(", ")} ${linkCount}` : `linked ${newLinks.join(", ")} ${linkCount}`
33096
+ ],
33097
+ ...route === undefined ? [] : [after.route === null ? "route cleared" : `route ${after.route}`]
33098
+ ];
33099
+ return {
33100
+ text: `${after.key}: ${changes.join("; ")} ${notSubscribed(issueTopic(after.key))}`,
33101
+ details: {
33102
+ issue: after.key,
33103
+ status: after.status,
33104
+ external_links: after.external_links.map((link) => link.url)
33105
+ }
33106
+ };
33107
+ } catch (error) {
33108
+ if (!(error instanceof DispatchServiceError))
33109
+ throw error;
33110
+ 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)` : "";
33111
+ throw new DispatchServiceError(error.code, error.status, `${error.code}: ${error.message}${taken}`, error.candidates);
33112
+ }
33113
+ }
33046
33114
  case "dispatch_search": {
33047
33115
  const query = stringArg(args, "query");
33048
33116
  const project = optionalString(args, "project");
package/dist/legion.js CHANGED
@@ -29150,6 +29150,17 @@ var SPEC_SECTIONS = [
29150
29150
  ];
29151
29151
  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.";
29152
29152
  var ASK_URGENCIES = ["low", "med", "high", "blocking"];
29153
+ var ISSUE_STATUSES = [
29154
+ "triage",
29155
+ "icebox",
29156
+ "backlog",
29157
+ "todo",
29158
+ "in_progress",
29159
+ "testing",
29160
+ "needs_review",
29161
+ "retro",
29162
+ "done"
29163
+ ];
29153
29164
  var DOC_EDIT_OPS = ["replace", "delete", "insert", "retype", "move"];
29154
29165
  var dispatchToolSpecs = [
29155
29166
  {
@@ -29167,6 +29178,26 @@ var dispatchToolSpecs = [
29167
29178
  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()
29168
29179
  })
29169
29180
  },
29181
+ {
29182
+ name: "dispatch_issue_update",
29183
+ 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}`,
29184
+ arguments: (z) => ({
29185
+ issue: z.string().describe(ISSUE_REFERENCE),
29186
+ status: z.enum(ISSUE_STATUSES).describe("New lifecycle status.").optional(),
29187
+ title: z.string({ min: 1 }).describe("Replacement title.").optional(),
29188
+ 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(),
29189
+ external_links: z.array(z.string({ min: 1 })).describe("URLs to link; merged into the issue's existing external links by URL.").optional(),
29190
+ route: z.string().describe("Route the issue to role:<name> or session:<id>; an empty string clears it.").optional()
29191
+ }),
29192
+ validation: {
29193
+ check: (value) => {
29194
+ const input = value;
29195
+ return typeof input.status === "string" || typeof input.title === "string" || Array.isArray(input.labels) || Array.isArray(input.external_links) || typeof input.route === "string";
29196
+ },
29197
+ message: "Issue update requires at least one field besides issue: status, title, labels, external_links, or route."
29198
+ },
29199
+ strict: true
29200
+ },
29170
29201
  {
29171
29202
  name: "dispatch_ask",
29172
29203
  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}`,
@@ -29554,17 +29585,6 @@ var legionRole = _enum2(LEGION_ROLES);
29554
29585
  var requiredUnknown = unknown().refine((value) => value !== undefined, {
29555
29586
  message: "Required"
29556
29587
  });
29557
- var LIFECYCLE_STATUSES = [
29558
- "triage",
29559
- "icebox",
29560
- "backlog",
29561
- "todo",
29562
- "in_progress",
29563
- "testing",
29564
- "needs_review",
29565
- "retro",
29566
- "done"
29567
- ];
29568
29588
  var architectCapability = strictObject({
29569
29589
  tree: nonEmptyString,
29570
29590
  sessionId: nonEmptyString,
@@ -29596,7 +29616,7 @@ var stateTreeLocator = discriminatedUnion("runtime", [
29596
29616
  var stateIssue = strictObject({
29597
29617
  key: nonEmptyString,
29598
29618
  title: string2(),
29599
- status: _enum2(LIFECYCLE_STATUSES).optional(),
29619
+ status: _enum2(ISSUE_STATUSES).optional(),
29600
29620
  children: array(nonEmptyString),
29601
29621
  parent: nonEmptyString.optional(),
29602
29622
  lastAppliedSeq: number2().int().nonnegative().optional()
@@ -29765,7 +29785,7 @@ var LegionDaemonApi = {
29765
29785
  },
29766
29786
  IssueStatus: {
29767
29787
  request: controllerIssue.extend({
29768
- status: _enum2(LIFECYCLE_STATUSES),
29788
+ status: _enum2(ISSUE_STATUSES),
29769
29789
  tree: nonEmptyString.optional(),
29770
29790
  sessionId: nonEmptyString.optional()
29771
29791
  }),
@@ -30294,19 +30314,8 @@ function toolFailure(error) {
30294
30314
 
30295
30315
  // src/legion/tools.ts
30296
30316
  var jsonSuccess = (details) => toolSuccess(JSON.stringify(details), details);
30297
- var LIFECYCLE_STATUSES2 = [
30298
- "triage",
30299
- "icebox",
30300
- "backlog",
30301
- "todo",
30302
- "in_progress",
30303
- "testing",
30304
- "needs_review",
30305
- "retro",
30306
- "done"
30307
- ];
30308
- function isLifecycleStatus(value) {
30309
- return LIFECYCLE_STATUSES2.includes(value);
30317
+ function isIssueStatus(value) {
30318
+ return ISSUE_STATUSES.includes(value);
30310
30319
  }
30311
30320
  function isDispatchArtifactId(value) {
30312
30321
  return LegionDaemonApi.GatesRegister.request.shape.artifactId.safeParse(value).success;
@@ -30323,7 +30332,7 @@ function legionToolSchema(pi) {
30323
30332
  return z.object({
30324
30333
  op: z.enum(["set_status", "register_gate", "release_wave", "escalate", "spawn_worker"]),
30325
30334
  issue: z.string().optional(),
30326
- status: z.enum(LIFECYCLE_STATUSES2).optional(),
30335
+ status: z.enum(ISSUE_STATUSES).optional(),
30327
30336
  artifactId: z.string().optional(),
30328
30337
  version: z.number().optional(),
30329
30338
  kind: z.enum(["re-file", "capacity", "cross-tree"]).optional(),
@@ -30365,7 +30374,7 @@ function createLegionTool(deps) {
30365
30374
  switch (parameters.op) {
30366
30375
  case "set_status": {
30367
30376
  const status = parameters.status;
30368
- if (typeof status !== "string" || !isLifecycleStatus(status)) {
30377
+ if (typeof status !== "string" || !isIssueStatus(status)) {
30369
30378
  throw new Error("set_status requires a valid Legion issue status");
30370
30379
  }
30371
30380
  await daemon.issueStatus({
@@ -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:
@@ -462,7 +485,10 @@ dispatch_message({
462
485
 
463
486
  `in_reply_to` correlates the answer under the asker's message in its Conversation card. A BTW
464
487
  delivery can post its answer automatically; use this call when the frame asks the primary agent to
465
- reply. `dispatch_message` itself never carries `target` or `delivery`: agent-to-agent traffic goes
488
+ reply. A human may reply to your message in turn — the follow-up arrives as a targeted frame whose
489
+ `in_reply_to` names your message and whose `reply_body` quotes it; answer it the same way,
490
+ `dispatch_message({ issue, in_reply_to: "<their reply id>", body })`, so the exchange reads as one
491
+ thread. `dispatch_message` itself never carries `target` or `delivery`: agent-to-agent traffic goes
466
492
  through Envoy or the hub. A bearer that targets over HTTP names its own session in `actor`
467
493
  (`{kind: "session", id}`), and the card shows that session as the author. `GET /api/v1/agents`
468
494
  (any authenticated caller) lists live sessions with their capabilities (`aside`, `btw`, `steer`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "1.41.0",
3
+ "version": "1.42.1",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [