@sjawhar/opencode-legion-envoy 3.10.1 → 3.11.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/dist/src/server.js +27 -6
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +30 -4
- package/skills/legion-retro/SKILL.md +3 -0
- package/skills/legion-worker/SKILL.md +27 -11
package/dist/src/server.js
CHANGED
|
@@ -13949,6 +13949,7 @@ var ISSUE_STATUSES = [
|
|
|
13949
13949
|
"retro",
|
|
13950
13950
|
"done"
|
|
13951
13951
|
];
|
|
13952
|
+
var ISSUE_ROUTE_STATUSES = ["live", "no_holder", "unknown"];
|
|
13952
13953
|
var DOC_EDIT_OPS = [
|
|
13953
13954
|
"replace",
|
|
13954
13955
|
"delete",
|
|
@@ -14255,8 +14256,8 @@ var dispatchToolSpecs = [
|
|
|
14255
14256
|
},
|
|
14256
14257
|
{
|
|
14257
14258
|
name: "dispatch_issues",
|
|
14258
|
-
example: { project: "AGENTC",
|
|
14259
|
-
description: "List a project's issues for a roadmap or backlog pass: every issue in one project, each carrying " + "its status, priority, parent, labels,
|
|
14259
|
+
example: { project: "AGENTC", route_status: "no_holder" },
|
|
14260
|
+
description: "List a project's issues for a roadmap or backlog pass: every issue in one project, each carrying " + "its status, priority, parent, labels, open-ask count, and route with whether it reaches anyone, " + "so you can see backlog shape without opening every issue. Optionally filter by status, parent, " + "label, priority, route status, or how recently it changed; priority takes one or more of 0-3 " + "(P0-P3) and null for an issue with no priority, so an owner's P0/P1 audit is priority [0, 1]. " + 'route_status "no_holder" lists every open issue whose route names a role nobody holds or a ' + "session that is not running at the moment of the read, whatever its priority. A restarting " + "session is absent for minutes, so an issue is unowned only when a read ten minutes later agrees. " + "Do not use it to search by keyword or phrase; dispatch_search remains the keyword surface. " + "Rows are capped at limit (default 50, max 250), applied to the response here, not by the server.",
|
|
14260
14261
|
arguments: (z2) => ({
|
|
14261
14262
|
project: z2.string().describe("Project key to list issues from."),
|
|
14262
14263
|
status: z2.enum(ISSUE_STATUSES).describe("Optional lifecycle status filter.").optional(),
|
|
@@ -14264,6 +14265,7 @@ var dispatchToolSpecs = [
|
|
|
14264
14265
|
label: z2.string().describe("Optional label filter.").optional(),
|
|
14265
14266
|
priority: z2.array(z2.number({ int: true, min: 0, max: 3 }).nullable(), { min: 1, max: 5 }).describe("Optional priority filter: one or more of 0 (P0, highest) through 3 (P3, lowest), and null " + "for an issue with no priority; an issue matching any listed value is returned.").optional(),
|
|
14266
14267
|
updated_since: z2.string().describe("Optional RFC3339 timestamp; only issues updated at or after it.").optional(),
|
|
14268
|
+
route_status: z2.enum(ISSUE_ROUTE_STATUSES).describe("Optional: only open issues whose route is in this state. live: a running session holds " + "the role or is the routed session. no_holder: nobody running holds the role, or the " + "session is not running, right now. unknown: the Envoy listener did not answer.").optional(),
|
|
14267
14269
|
limit: z2.number({ int: true, min: 1, max: 250 }).describe("Maximum rows, 1-250; default 50.").optional()
|
|
14268
14270
|
})
|
|
14269
14271
|
},
|
|
@@ -16185,6 +16187,20 @@ async function liveSessionTitles(client, needed) {
|
|
|
16185
16187
|
function holdsSession(claim) {
|
|
16186
16188
|
return claim?.actor.kind === "session";
|
|
16187
16189
|
}
|
|
16190
|
+
function routeHeldBySession(issue2) {
|
|
16191
|
+
return issue2.route_status === "live" && issue2.route?.startsWith("role:") === true;
|
|
16192
|
+
}
|
|
16193
|
+
function routeText(issue2, titles) {
|
|
16194
|
+
if (issue2.route === null)
|
|
16195
|
+
return "none";
|
|
16196
|
+
const holder = issue2.route_holder ?? null;
|
|
16197
|
+
const reach = {
|
|
16198
|
+
live: routeHeldBySession(issue2) && holder !== null ? ` (held by ${titles?.get(holder) ?? holder})` : "",
|
|
16199
|
+
no_holder: issue2.route.startsWith("role:") ? " (nobody holds it right now)" : " (that session is not running right now)",
|
|
16200
|
+
unknown: " (the Envoy listener did not answer, so whether it reaches anyone is unknown)"
|
|
16201
|
+
};
|
|
16202
|
+
return issue2.route + (issue2.route_status == null ? "" : reach[issue2.route_status]);
|
|
16203
|
+
}
|
|
16188
16204
|
function issueSummary(issue2, events, references, graph, titles) {
|
|
16189
16205
|
const asks = issue2.open_asks;
|
|
16190
16206
|
const spec = issue2.artifacts?.find((artifact) => artifact.primary);
|
|
@@ -16206,7 +16222,7 @@ function issueSummary(issue2, events, references, graph, titles) {
|
|
|
16206
16222
|
...issue2.priority === null ? [] : [`Priority: P${issue2.priority}`],
|
|
16207
16223
|
`Labels: ${issue2.labels.length === 0 ? "none" : issue2.labels.join(", ")}`,
|
|
16208
16224
|
componentsLine(issue2.components),
|
|
16209
|
-
`Route: ${issue2
|
|
16225
|
+
`Route: ${routeText(issue2, titles)}`,
|
|
16210
16226
|
...specApproval === undefined ? [] : [`Spec ${specApproval.replace(/^Approval/, "approval")}`],
|
|
16211
16227
|
"Open asks:",
|
|
16212
16228
|
...asks.length === 0 ? ["- none"] : asks.map((ask) => `- ${ask.id}: ${ask.question}`),
|
|
@@ -16718,6 +16734,7 @@ async function executeDispatchTool(input) {
|
|
|
16718
16734
|
const label = optionalString(args, "label");
|
|
16719
16735
|
const priority = optionalPriorityFilter(args, "priority");
|
|
16720
16736
|
const updatedSince = optionalString(args, "updated_since");
|
|
16737
|
+
const routeStatus = optionalString(args, "route_status");
|
|
16721
16738
|
const limit = Math.min(Math.max(optionalNumber(args, "limit") ?? 50, 1), 250);
|
|
16722
16739
|
const issues = await client.listIssues({
|
|
16723
16740
|
project,
|
|
@@ -16725,7 +16742,8 @@ async function executeDispatchTool(input) {
|
|
|
16725
16742
|
...parent === undefined ? {} : { parent },
|
|
16726
16743
|
...label === undefined ? {} : { label },
|
|
16727
16744
|
...priority === undefined ? {} : { priority },
|
|
16728
|
-
...updatedSince === undefined ? {} : { updated_since: updatedSince }
|
|
16745
|
+
...updatedSince === undefined ? {} : { updated_since: updatedSince },
|
|
16746
|
+
...routeStatus === undefined ? {} : { route_status: routeStatus }
|
|
16729
16747
|
});
|
|
16730
16748
|
const rows = issues.slice(0, limit).map((row) => ({
|
|
16731
16749
|
key: row.key,
|
|
@@ -16736,13 +16754,16 @@ async function executeDispatchTool(input) {
|
|
|
16736
16754
|
labels: row.labels ?? [],
|
|
16737
16755
|
open_asks: row.open_asks,
|
|
16738
16756
|
claim: row.claim ?? null,
|
|
16757
|
+
route: row.route ?? null,
|
|
16758
|
+
route_status: row.route_status ?? null,
|
|
16759
|
+
route_holder: row.route_holder ?? null,
|
|
16739
16760
|
updated_at: row.updated_at
|
|
16740
16761
|
}));
|
|
16741
16762
|
const titles = await liveSessionTitles(client, rows.some((row) => holdsSession(row.claim)));
|
|
16742
16763
|
return {
|
|
16743
16764
|
text: rows.length === 0 ? `No issues in ${project}.` : [
|
|
16744
16765
|
`${rows.length} ${rows.length === 1 ? "issue" : "issues"} in ${project}` + (issues.length > rows.length ? ` (showing ${rows.length} of ${issues.length})` : ""),
|
|
16745
|
-
...rows.map((row) => `${row.key} [${row.status}]${row.priority === null ? "" : ` P${row.priority}`} ${row.title}` + (row.open_asks === 0 ? "" : ` \xB7 ${row.open_asks} open ${row.open_asks === 1 ? "ask" : "asks"}`) + (row.claim === null ? "" : ` \xB7 claimed by ${claimText(row.claim, titles)}`))
|
|
16766
|
+
...rows.map((row) => `${row.key} [${row.status}]${row.priority === null ? "" : ` P${row.priority}`} ${row.title}` + (row.open_asks === 0 ? "" : ` \xB7 ${row.open_asks} open ${row.open_asks === 1 ? "ask" : "asks"}`) + (row.claim === null ? "" : ` \xB7 claimed by ${claimText(row.claim, titles)}`) + (row.route === null || row.route_status === "live" || row.route_status === null ? "" : ` \xB7 route ${routeText(row)}`))
|
|
16746
16767
|
].join(`
|
|
16747
16768
|
`),
|
|
16748
16769
|
details: { issues: rows }
|
|
@@ -17178,7 +17199,7 @@ ${trailer.join(`
|
|
|
17178
17199
|
const [references, graph, titles] = await Promise.all([
|
|
17179
17200
|
referencesPromise,
|
|
17180
17201
|
graphSections(client, dispatchIssueRef(read.issue.key)),
|
|
17181
|
-
liveSessionTitles(client, holdsSession(read.issue.claim))
|
|
17202
|
+
liveSessionTitles(client, holdsSession(read.issue.claim) || routeHeldBySession(read.issue))
|
|
17182
17203
|
]);
|
|
17183
17204
|
return {
|
|
17184
17205
|
text: issueSummary(read.issue, read.events, references, graph, titles),
|
package/package.json
CHANGED
package/skills/dispatch/SKILL.md
CHANGED
|
@@ -286,13 +286,14 @@ distinction from the named issue, citing it (`dispatch://KEY`), for whoever read
|
|
|
286
286
|
|
|
287
287
|
To see the shape of a project rather than find a phrase, list its issues:
|
|
288
288
|
```ts
|
|
289
|
-
dispatch_issues({ project, status?, parent?, label?, priority?, updated_since?, limit? })
|
|
289
|
+
dispatch_issues({ project, status?, parent?, label?, priority?, route_status?, updated_since?, limit? })
|
|
290
290
|
```
|
|
291
291
|
Each row carries the issue key, title, status, priority, parent, labels, its open-ask count, and
|
|
292
292
|
when it last changed — a roadmap or backlog pass without opening every issue. Filter with `status`
|
|
293
293
|
(a lifecycle status), `parent` (one issue's children), `label`, `priority` (a list of `0`–`3`, with
|
|
294
|
-
`null` for an issue with no priority: `[0, 1]` is every P0 and P1),
|
|
295
|
-
timestamp, for "what moved this week"). `limit` caps the rows at 50 by
|
|
294
|
+
`null` for an issue with no priority: `[0, 1]` is every P0 and P1), `route_status` (below), or
|
|
295
|
+
`updated_since` (an RFC3339 timestamp, for "what moved this week"). `limit` caps the rows at 50 by
|
|
296
|
+
default and 250 at most.
|
|
296
297
|
|
|
297
298
|
This is not search: it matches no text. Use `dispatch_search` for a keyword or phrase, and
|
|
298
299
|
`dispatch_issues` when you want every issue in a project and its current state.
|
|
@@ -311,7 +312,13 @@ and is not re-staffed. A todo with a finished spec reads as queued work that nob
|
|
|
311
312
|
(LEGION-173 sat in todo for two weeks with a complete spec; AGENTC-1010's v4 plan sat in backlog
|
|
312
313
|
with nobody building it).
|
|
313
314
|
|
|
314
|
-
|
|
315
|
+
A close that says the defect cannot happen cites the code that makes it impossible. An issue
|
|
316
|
+
closed because a rewrite forecloses it names the file and line in the rewrite that does so; a
|
|
317
|
+
close that cannot name one is not foreclosed, it is unread. The cheapest way for a rewrite to reach
|
|
318
|
+
parity is to port the code, defect included: LEGION-211's bare `git worktree prune`, filed against
|
|
319
|
+
the TypeScript daemon, had been ported into the Go coordinator and was live in production.
|
|
320
|
+
|
|
321
|
+
The audit finds four shapes:
|
|
315
322
|
|
|
316
323
|
- **Unstaffed work.** A plan or measurement exists, and no one is building it.
|
|
317
324
|
- **Unrecorded delivery.** An issue not yet in `testing` or `done`, claimed or not, has a merged PR
|
|
@@ -321,6 +328,25 @@ The audit finds three shapes:
|
|
|
321
328
|
sits in backlog (OPS-132, done by hand on every migration merge). It leaves no plan and no PR to
|
|
322
329
|
find; the tell is your own messages. Doing something by hand more than once means an issue is
|
|
323
330
|
wearing the wrong status.
|
|
331
|
+
- **Unreachable route.** An open issue whose route names a role nobody holds, or a session that is
|
|
332
|
+
not running, reaches nobody, whatever its priority, and the priority filter above never finds
|
|
333
|
+
it. List it on its own:
|
|
334
|
+
```ts
|
|
335
|
+
dispatch_issues({ project, route_status: "no_holder", limit: 250 })
|
|
336
|
+
```
|
|
337
|
+
Each row reads `route role:sre (nobody holds it right now)` or `route session:<id> (that session
|
|
338
|
+
is not running right now)`. That is one read of the listener, and one read is a restart gap as
|
|
339
|
+
often as a vacancy: an agent box that restarts or resumes keeps the session id, but the session
|
|
340
|
+
is absent from the listener for minutes, and its role with it. On 2026-09-27, 58 of 63 session
|
|
341
|
+
routes one read showed as unreachable pointed at a single session that was moving between boxes.
|
|
342
|
+
So a route is unowned only when it is `no_holder` on two reads at least ten minutes apart: list
|
|
343
|
+
again after ten minutes and act on the issues both lists name. Confirm with the second
|
|
344
|
+
`dispatch_issues` read, not `envoy_role_get`: a role lookup releases the claim of a holder whose
|
|
345
|
+
session is absent from the registry as it answers. Then staff the role, re-route the
|
|
346
|
+
issue to a live holder, or clear the route and assign it (AGENTC-1065, a P2 production listener
|
|
347
|
+
503, sat routed to an unheld `role:sre` with no assignee). `route_status: "unknown"` means the
|
|
348
|
+
listener did not answer, so a route could not be judged; a `no_holder` filter refuses rather
|
|
349
|
+
than answer an empty list then.
|
|
324
350
|
|
|
325
351
|
Run the audit as a step of a coordinator's loop, at each checkpoint, not as a habit: these shapes
|
|
326
352
|
are found by running the check, not by noticing them.
|
|
@@ -9,6 +9,9 @@ Retro is mandatory for every issue that passed review. The architect revives the
|
|
|
9
9
|
implementer so the person with implementation context performs the retrospective, and the
|
|
10
10
|
skill obtains a separate fresh-eyes perspective. Retro runs before merge.
|
|
11
11
|
|
|
12
|
+
Every path this skill cites (`packages/...`, `docs/...`) is in sjawhar/legion, the Legion
|
|
13
|
+
repository, which need not be the repository you are working in.
|
|
14
|
+
|
|
12
15
|
## Merge-gate ordering
|
|
13
16
|
|
|
14
17
|
Follow this ordering exactly. It keeps the reviewed branch clean while preserving the
|
|
@@ -11,6 +11,9 @@ phase gets its own long-lived process against the same jj workspace, run in turn
|
|
|
11
11
|
the phase assigned to you, report its completion to the architect, and leave the durable
|
|
12
12
|
copy the next phase can trust.
|
|
13
13
|
|
|
14
|
+
Every path this skill cites (`packages/...`, `docs/...`, `AGENTS.md`) is in sjawhar/legion, the
|
|
15
|
+
Legion repository, which need not be the repository you are working in.
|
|
16
|
+
|
|
14
17
|
## Identity, scope, and role
|
|
15
18
|
|
|
16
19
|
The daemon spawns you as a separate `omp --mode rpc` process (behind `legion worker-shim`,
|
|
@@ -102,8 +105,9 @@ committed predecessor handoffs in lifecycle order from `$LEGION_WORKSPACE/.legio
|
|
|
102
105
|
5. `review.json`
|
|
103
106
|
|
|
104
107
|
Read only files that precede the assigned phase. Every handoff is validated when it is read:
|
|
105
|
-
`validatePhaseHandoff` (`packages/contracts/src/handoff-schema.ts`) checks the
|
|
106
|
-
ledger (`packages/daemon/src/handoff/ledger.ts`) treats a file that
|
|
108
|
+
`validatePhaseHandoff` (`packages/contracts/src/handoff-schema.ts`) checks the
|
|
109
|
+
file, and the ledger (`packages/daemon/src/handoff/ledger.ts`) treats a file that
|
|
110
|
+
fails validation as missing.
|
|
107
111
|
Undeclared fields pass validation untouched and reach the next worker; a declared field of the
|
|
108
112
|
wrong type fails the whole file, so the `legion` tool's `handoff_read` returns null for that phase.
|
|
109
113
|
Write the phase-specific fields the next phase and the architect need, consistent with what
|
|
@@ -161,14 +165,19 @@ assignment, since `jj split`/`jj describe` keep its author). Never set or overri
|
|
|
161
165
|
`user.name`/`user.email` in any jj or Git scope — not `jj config set`, not `--config`, not
|
|
162
166
|
`git config`: `--config` outranks the pane environment and would put the wrong App back on your
|
|
163
167
|
commits, and the repository-scoped jj config is one file shared by every issue workspace of the
|
|
164
|
-
clone.
|
|
168
|
+
clone. Legion has two GitHub Apps, not one per role: your role's App is the **implement** App
|
|
169
|
+
if you are the implementer or the merger, and the **review** App if you are the planner, tester,
|
|
170
|
+
reviewer, or an architect (in Legion's own deployment, `legion-implementer[bot]` and
|
|
171
|
+
`legion-reviewer[bot]`). A planner's commits authored by the review App are right. Before a push,
|
|
172
|
+
check
|
|
165
173
|
`jj -R "$LEGION_WORKSPACE" log -r 'main@origin..@' -T 'author.email() ++ " | " ++ committer.email() ++ " " ++ description.first_line() ++ "\n"'`
|
|
166
174
|
shows your role's App in both columns **on every commit you made** — not on the whole list:
|
|
167
175
|
earlier phases' commits are legitimately authored by their own role's App, and a conflict-forced
|
|
168
176
|
rebase legitimately sets the committer of every rebased commit, other roles' included, to the
|
|
169
|
-
rebaser. A wrong identity on your own commit is a pane-environment
|
|
170
|
-
architect, not something to pin
|
|
171
|
-
Hazard 1).
|
|
177
|
+
rebaser. A wrong identity on your own commit, the other App or none, is a pane-environment
|
|
178
|
+
problem to report to the architect, not something to pin
|
|
179
|
+
(`docs/solutions/legion/shared-main-repo-hazards-for-concurrent-issue-workspaces.md`, Hazard 1).
|
|
180
|
+
Your session receives the credential capability it needs; invoke GitHub through the
|
|
172
181
|
credential helper:
|
|
173
182
|
|
|
174
183
|
```bash
|
|
@@ -225,8 +234,8 @@ legion gh -- pr comment <pr-number> \
|
|
|
225
234
|
|
|
226
235
|
The plan lives in `.legion/plan.json` and the Dispatch issue document; never commit a plan or spec file to the repository.
|
|
227
236
|
No `docs/plans/*`, `docs/superpowers/plans/*`, or spec markdown goes into the pull request: plan
|
|
228
|
-
and spec content goes into the issue, never into a PR (the root `AGENTS.md`
|
|
229
|
-
|
|
237
|
+
and spec content goes into the issue, never into a PR (the root `AGENTS.md`
|
|
238
|
+
calls its own `docs/plans/` human-authored design history, not a Legion artifact). A skill step that says "save the plan
|
|
230
239
|
to a file" is satisfied by the handoff write in the completion gate below; the planner's only
|
|
231
240
|
commit is `plan: record handoff`.
|
|
232
241
|
|
|
@@ -652,9 +661,16 @@ This publishes your phase's completion to the architect's role and clears the da
|
|
|
652
661
|
record of this issue's active phase. Do not add pipeline labels, run a controller loop, or
|
|
653
662
|
invent a different completion protocol — this is the whole contract.
|
|
654
663
|
|
|
655
|
-
A reviewer's phase ends with its completion, not with its review
|
|
656
|
-
|
|
657
|
-
|
|
664
|
+
A reviewer's phase ends with its completion, not with its review. A round that writes a handoff
|
|
665
|
+
takes this order: write, commit and push the handoff; submit the review of the head that push
|
|
666
|
+
made, by its SHA; then complete. An approval waits for the CI verdict to settle green at that head
|
|
667
|
+
before you submit it, since an approval stands only on green checks and GitHub can dismiss one
|
|
668
|
+
once the head moves, and a verdict that settles red there makes the round's decision a request for
|
|
669
|
+
changes naming the failing checks; a request for changes does not wait, since it stands whatever CI says and the
|
|
670
|
+
issue leaves reviewing with it. A review of a head the handoff push then replaces names a head
|
|
671
|
+
the pull request no longer has. A round that writes none (the final approval of the `.legion/`
|
|
672
|
+
deletion head) reviews the head as it is. The daemon moves the issue once both are in —
|
|
673
|
+
the decision GitHub reports and your completion, in either order — so a review posted without a
|
|
658
674
|
completion leaves the issue in reviewing until you finish.
|
|
659
675
|
|
|
660
676
|
**A refused completion is information, not a retry loop.** The daemon attributes your report to
|