@sjawhar/opencode-legion-envoy 3.2.4 → 3.2.6
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 +11 -47
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +16 -16
- package/skills/legion-controller/SKILL.md +26 -12
- package/skills/legion-worker/SKILL.md +9 -1
package/dist/src/server.js
CHANGED
|
@@ -13865,7 +13865,7 @@ function dispatchToolSchema(spec, z2, opts) {
|
|
|
13865
13865
|
const schemaOptions = strict === undefined ? undefined : { strict };
|
|
13866
13866
|
return spec.validation === undefined ? z2.object(shape, schemaOptions) : z2.refineObject(shape, spec.validation.check, spec.validation.message, schemaOptions);
|
|
13867
13867
|
}
|
|
13868
|
-
var ISSUE_REFERENCE = "An issue is a native KEY or external owner/repo#n reference
|
|
13868
|
+
var ISSUE_REFERENCE = "An issue is a native KEY or external owner/repo#n reference. An external reference addresses an existing Dispatch issue, including one linked to that GitHub pull request; only dispatch_issue with external creates a native issue.";
|
|
13869
13869
|
var OWNER_REFERENCE = "Exactly one of issue and project is required. An issue is a native KEY or external owner/repo#n reference; a project is a project key such as CORE and addresses an unlinked project document named by artifact.";
|
|
13870
13870
|
function documentOwnerValidation(requireArtifact, alwaysRequireArtifact = false) {
|
|
13871
13871
|
return {
|
|
@@ -14005,7 +14005,7 @@ var dispatchToolSpecs = [
|
|
|
14005
14005
|
{
|
|
14006
14006
|
name: "dispatch_ask",
|
|
14007
14007
|
example: { issue: "DSP-1", question: "Ship this?" },
|
|
14008
|
-
description: "Open a durable, answerable decision on an issue or project document. Do not use it for a status update or discussion; " + "use dispatch_message instead. A to-do a human must complete is a question phrased as that to-do, with the options you want (for example Done / Can't). " + "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 ${ASK_QUESTION_MAX} ` + `characters and has at most 8 options. ${OWNER_REFERENCE}`,
|
|
14008
|
+
description: "Open a durable, answerable decision on an issue or project document. Do not use it for a status update or discussion; " + "use dispatch_message instead. A to-do a human must complete is a question phrased as that to-do, with the options you want (for example Done / Can't). " + "Anything you are blocked on a human for, including a credential or grant to renew, an approval, or a decision, is an ask, never a message. " + "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 ${ASK_QUESTION_MAX} ` + `characters and has at most 8 options. ${OWNER_REFERENCE}`,
|
|
14009
14009
|
arguments: (z2) => ({
|
|
14010
14010
|
issue: z2.string().describe(ISSUE_REFERENCE).optional(),
|
|
14011
14011
|
project: z2.string().describe("Project key owning the document.").optional(),
|
|
@@ -14126,7 +14126,7 @@ var dispatchToolSpecs = [
|
|
|
14126
14126
|
{
|
|
14127
14127
|
name: "dispatch_message",
|
|
14128
14128
|
example: { issue: "DSP-1", body: "Implementation started." },
|
|
14129
|
-
description: "Post a note humans must read now: a reply to a human's message
|
|
14129
|
+
description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A blocker only a human can " + "clear is an ask (dispatch_ask), so it lands in their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a decision (dispatch_ask) or document feedback (dispatch_comment). To answer a human's direct message to this session - " + "one sent from the Agents page, which names no issue - pass that message's bare id as in_reply_to and no issue; " + "the reply lands in that conversation, and a second call with the same in_reply_to posts nothing because " + "Dispatch keeps the one reply per message. Every other message names its issue. " + `Body is at most 2,000 characters. ${ISSUE_REFERENCE}`,
|
|
14130
14130
|
arguments: (z2) => ({
|
|
14131
14131
|
issue: z2.string().describe(`${ISSUE_REFERENCE} Omit it only when in_reply_to answers a human's direct message to this session.`).optional(),
|
|
14132
14132
|
body: z2.string({ max: 2000 }).describe("Update text, at most 2,000 characters."),
|
|
@@ -15054,7 +15054,6 @@ class DispatchClient {
|
|
|
15054
15054
|
fetchImpl;
|
|
15055
15055
|
#baseUrl;
|
|
15056
15056
|
#resolvedIssues = new Map;
|
|
15057
|
-
#creatingIssues = new Map;
|
|
15058
15057
|
#signal;
|
|
15059
15058
|
constructor(baseUrl, token, fetchImpl = fetch, signal) {
|
|
15060
15059
|
this.token = token;
|
|
@@ -15065,6 +15064,9 @@ class DispatchClient {
|
|
|
15065
15064
|
async issue(input) {
|
|
15066
15065
|
return this.#json("POST", ["api", "v1", "issues"], input);
|
|
15067
15066
|
}
|
|
15067
|
+
async resolveIssue(issueReference) {
|
|
15068
|
+
return this.#resolveIssue(issueReference);
|
|
15069
|
+
}
|
|
15068
15070
|
async listIssues(options = {}) {
|
|
15069
15071
|
return this.#json("GET", ["api", "v1", "issues"], undefined, options);
|
|
15070
15072
|
}
|
|
@@ -15260,43 +15262,6 @@ class DispatchClient {
|
|
|
15260
15262
|
...query.since === undefined ? {} : { since: query.since }
|
|
15261
15263
|
});
|
|
15262
15264
|
}
|
|
15263
|
-
async ensureIssue(issueReference, actor) {
|
|
15264
|
-
if (!issueReference.includes("#"))
|
|
15265
|
-
return issueReference;
|
|
15266
|
-
try {
|
|
15267
|
-
return await this.#resolveIssue(issueReference);
|
|
15268
|
-
} catch (error48) {
|
|
15269
|
-
if (!(error48 instanceof DispatchServiceError) || error48.status !== 404)
|
|
15270
|
-
throw error48;
|
|
15271
|
-
}
|
|
15272
|
-
let creating = this.#creatingIssues.get(issueReference);
|
|
15273
|
-
if (!creating) {
|
|
15274
|
-
creating = this.#createExternalIssue(issueReference, actor);
|
|
15275
|
-
this.#creatingIssues.set(issueReference, creating);
|
|
15276
|
-
}
|
|
15277
|
-
try {
|
|
15278
|
-
return await creating;
|
|
15279
|
-
} finally {
|
|
15280
|
-
if (this.#creatingIssues.get(issueReference) === creating) {
|
|
15281
|
-
this.#creatingIssues.delete(issueReference);
|
|
15282
|
-
}
|
|
15283
|
-
}
|
|
15284
|
-
}
|
|
15285
|
-
async#createExternalIssue(issueReference, actor) {
|
|
15286
|
-
try {
|
|
15287
|
-
const created = await this.#json("POST", ["api", "v1", "issues"], {
|
|
15288
|
-
external: issueReference,
|
|
15289
|
-
actor
|
|
15290
|
-
});
|
|
15291
|
-
this.#resolvedIssues.set(issueReference, Promise.resolve(created.key));
|
|
15292
|
-
return created.key;
|
|
15293
|
-
} catch (error48) {
|
|
15294
|
-
if (error48 instanceof DispatchServiceError && (error48.status === 409 || error48.status === 500)) {
|
|
15295
|
-
return this.#resolveIssue(issueReference);
|
|
15296
|
-
}
|
|
15297
|
-
throw error48;
|
|
15298
|
-
}
|
|
15299
|
-
}
|
|
15300
15265
|
async#resolveIssue(issueReference) {
|
|
15301
15266
|
if (!issueReference.includes("#"))
|
|
15302
15267
|
return issueReference;
|
|
@@ -16497,7 +16462,7 @@ async function executeDispatchTool(input) {
|
|
|
16497
16462
|
const client = dispatchClient();
|
|
16498
16463
|
const owner = ownerArguments.owner?.kind === "issue" ? {
|
|
16499
16464
|
kind: "issue",
|
|
16500
|
-
issue: await
|
|
16465
|
+
issue: await resolveExistingIssue(client, ownerArguments.owner.issue)
|
|
16501
16466
|
} : ownerArguments.owner;
|
|
16502
16467
|
const issue2 = () => {
|
|
16503
16468
|
if (owner?.kind !== "issue")
|
|
@@ -17149,13 +17114,12 @@ ${trailer.join(`
|
|
|
17149
17114
|
throw new Error(`Unknown Dispatch tool: ${input.tool}`);
|
|
17150
17115
|
}
|
|
17151
17116
|
}
|
|
17152
|
-
async function
|
|
17117
|
+
async function resolveExistingIssue(client, issueReference) {
|
|
17153
17118
|
try {
|
|
17154
|
-
return await client.
|
|
17119
|
+
return await client.resolveIssue(issueReference);
|
|
17155
17120
|
} catch (error48) {
|
|
17156
|
-
if (error48 instanceof DispatchServiceError && error48.
|
|
17157
|
-
|
|
17158
|
-
throw new Error(`repository ${repository} is not mapped in repository settings and no DISPATCH_DEFAULT_PROJECT is configured`);
|
|
17121
|
+
if (error48 instanceof DispatchServiceError && error48.status === 404) {
|
|
17122
|
+
throw new Error(`no Dispatch issue is linked to ${issueReference}; create it first with ` + `dispatch_issue({ external: "${issueReference}", ... })`);
|
|
17159
17123
|
}
|
|
17160
17124
|
throw error48;
|
|
17161
17125
|
}
|
package/package.json
CHANGED
package/skills/dispatch/SKILL.md
CHANGED
|
@@ -147,8 +147,9 @@ See [Typed blocks](#typed-blocks) for the syntax and [Before you ask](#before-yo
|
|
|
147
147
|
Every session works on an issue or project document. Legion pre-fills `issue` from `LEGION_ISSUE`: use a native issue key such as
|
|
148
148
|
`LEGION-3`, an external `owner/repo#n` reference, or a bare positive number (resolved against the cwd repository). Otherwise pass
|
|
149
149
|
exactly one owner to every owner-scoped tool: `issue` for an issue, or `project` and `artifact` for an unlinked project document (see
|
|
150
|
-
[References](#references) for the resulting ref shape).
|
|
151
|
-
|
|
150
|
+
[References](#references) for the resulting ref shape). An external issue reference addresses the existing Dispatch issue linked to
|
|
151
|
+
that GitHub issue or pull request. Only `dispatch_issue` with `external` creates a native issue; if no issue is linked, call
|
|
152
|
+
`dispatch_issue({ external: "owner/repo#n", project: "<project>", title: "<title>" })` before addressing it.
|
|
152
153
|
|
|
153
154
|
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`.
|
|
154
155
|
|
|
@@ -189,8 +190,8 @@ dashboard's **Unclaimed** filter is how you find work nobody is on.
|
|
|
189
190
|
that session (its id is in the message; `envoy_send` reaches it) or pick up something else,
|
|
190
191
|
and tell the human if you believe the work should be yours. When a **human** holds it, the
|
|
191
192
|
refusal names the person and says nothing about a session running, because there is none to
|
|
192
|
-
message: ask them
|
|
193
|
-
lapsed — only a human releases or forces a human's claim.
|
|
193
|
+
message: ask them with `dispatch_ask` instead, so the open ask appears in their Inbox, and
|
|
194
|
+
never assume their claim has lapsed — only a human releases or forces a human's claim.
|
|
194
195
|
- **`409 CLAIM_CONTENDED` means the issue changed hands twice while your call ran**, so nothing
|
|
195
196
|
was applied and nobody's liveness was checked. Read the issue and decide again; it is not a
|
|
196
197
|
refusal by a live holder.
|
|
@@ -421,17 +422,16 @@ Before saying you are waiting for human input, call `dispatch_open_asks`. With n
|
|
|
421
422
|
|
|
422
423
|
**Unsettled product shape needs a decision before implementation.** When a page, navigation entry, table key, customer-scoping rule, or persisted sidecar would set product shape that Sami has not already settled, send a one-line ask before the first implementation commit. A lane's schema decision or a platform-PO contract ruling does not settle product shape. This does not turn a user-specified decision or routine implementation into an approval request; it is inferred from AGENTC-186's 2026-09-16 retro (platform PO, 2026-09-17). A control or behaviour the human asked for in words is settled by those words, together with every choice inside it that his words do not make (where it sits, its defaults, its options): build it without an ask, as gate 4 of [Before you ask](#before-you-ask) says. This rule covers only product shape outside what he asked for, and its ask comes before the commit that sets that shape.
|
|
423
424
|
|
|
424
|
-
**Anything
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
exception is a halt condition from [Before you
|
|
428
|
-
platform PO over Envoy instead.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
working on everything that is not.
|
|
425
|
+
**Anything you are blocked on a human for is an open ask.** An agent waits on a human only through
|
|
426
|
+
an open ask. An approval, a credential or grant to renew, a setting only they can change, a review
|
|
427
|
+
click, a decision, or a conflict between two of their own rules: open a `dispatch_ask` the moment
|
|
428
|
+
you know, the action as the question. The exception is a halt condition from [Before you
|
|
429
|
+
ask](#before-you-ask) gate 1, which goes to the platform PO over Envoy instead. Never write it
|
|
430
|
+
into a spec, a comment reply, a message, or a pull-request body: nothing in those paths reaches
|
|
431
|
+
the human's Inbox, and a human who is not reading your document does not know they are the
|
|
432
|
+
blocker. Before asking, try to remove the step: a value already on the machine, a permission you
|
|
433
|
+
already hold, an API that replaces the click. One ask per item, `urgency: "high"` when work is
|
|
434
|
+
stopped on it; while it is open, keep working on everything that is not.
|
|
435
435
|
|
|
436
436
|
A to-do handed to a human is an ordinary question: phrase the to-do as the question and give it
|
|
437
437
|
the options that name its outcomes, in the human's words - there is no fixed vocabulary and the
|
|
@@ -724,7 +724,7 @@ once.
|
|
|
724
724
|
## Messages
|
|
725
725
|
|
|
726
726
|
Dispatch is a high-signal record for humans, not a log of what you are doing. A message is a reply to a human's message, or a
|
|
727
|
-
change a human must know about now: a deliverable landed
|
|
727
|
+
change a human must know about now: a deliverable landed. Nothing else — no progress updates, no
|
|
728
728
|
"starting X", no "still working", no restating the spec, no status on a timer. Your transcript is where work is narrated; the
|
|
729
729
|
pull request is where it is summarised. One message that a human reads beats ten that train them to skip you.
|
|
730
730
|
|
|
@@ -72,8 +72,8 @@ carries, so nothing changes in how you handle wakes. Under the TypeScript daemon
|
|
|
72
72
|
claims the role and calls `/controller/ready` exactly as under tmux; under the Go daemon
|
|
73
73
|
(`LEGION_DAEMON_API=go` in your environment) it registers on `/legion/v1/claims/register` with the
|
|
74
74
|
secret, claims the role, then subscribes to `notifications.legion.<project>.controller`, where the
|
|
75
|
-
Go daemon publishes the
|
|
76
|
-
`controllerLocator: {runtime, external: true, sessionId, registeredAt}`, `runtime` being the
|
|
75
|
+
Go daemon publishes the rows marked from the Go daemon in the wake routing table. The daemon records
|
|
76
|
+
you as `controllerLocator: {runtime, external: true, sessionId, registeredAt}`, `runtime` being the
|
|
77
77
|
daemon's own (`kubernetes`, or `tmux` under the Go daemon). The TypeScript daemon reads your
|
|
78
78
|
liveness from the Envoy role registry (the holder of
|
|
79
79
|
`legion-<project>-controller` and its `last_seen`), not from a pane: keep the session running.
|
|
@@ -87,15 +87,29 @@ mints a new secret, so your grants stop working and the role moves to the new se
|
|
|
87
87
|
|
|
88
88
|
The Go daemon's controller topic is a wake for a session that is running when it is published.
|
|
89
89
|
Envoy hands an Oh My Pi session no retained copy of a notice published before it subscribed, so a
|
|
90
|
-
hold
|
|
91
|
-
every start, before anything else
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
90
|
+
hold, a tree architect's failed claim, or a new triage root from while no controller ran never
|
|
91
|
+
arrives as a wake. At every start, before anything else:
|
|
92
|
+
|
|
93
|
+
1. Read `legion state --json` and handle each issue whose `issues.<KEY>.phase` is `held` (its
|
|
94
|
+
`issues.<KEY>.holdReason` is `escalated` when its architect sent it to you, and absent while the
|
|
95
|
+
architect is still deciding or while its tree lingers or is closed, where the hold waits for the
|
|
96
|
+
tree's re-admission and needs nothing from you), and each tree root whose
|
|
97
|
+
`issues.<KEY>.architect.state` is `failed` and whose `issues.<KEY>.phase` is not `done`, exactly
|
|
98
|
+
as the matching wake below. A parked tree (root phase `done`: it lingers or is closed) needs
|
|
99
|
+
nothing from you: a failed architect ignores the park and reads `failed` until the tree closes.
|
|
100
|
+
2. List the project's triage issues with `dispatch_issues({project, status: "triage", limit: 250})`.
|
|
101
|
+
When its first line ends `(showing N of M)`, it is one page: say in your summary how many rows
|
|
102
|
+
it left unread. The rows show no parent, so open each row with `dispatch_read`: one whose
|
|
103
|
+
`Links:` name a `child_of` issue is a child, which its parent's architect owns, so leave it,
|
|
104
|
+
whether or not `legion state --json` records it (a `child_of` under `Referenced by:` is a child
|
|
105
|
+
of this issue, not its parent). Of the rest, triage each that `legion state --json` does not
|
|
106
|
+
record under `issues` as a new issue. A root recorded there and now in `triage` is work the
|
|
107
|
+
daemon holds that a human pulled back: never re-admit it yourself; name it in your summary to
|
|
108
|
+
the human ("<KEY> was pulled back to triage; what do you want?"). This listing is also the only
|
|
109
|
+
way you learn of an unrecorded root moved back into triage, or of a child detached to a root
|
|
110
|
+
while it is in triage, since the daemon wakes you only on a root's creation.
|
|
111
|
+
|
|
112
|
+
The issue record and Dispatch are the truth; the topic is the wake.
|
|
99
113
|
|
|
100
114
|
## Deployment instructions
|
|
101
115
|
|
|
@@ -123,7 +137,7 @@ quoted here.
|
|
|
123
137
|
|
|
124
138
|
| Wake | Content | Controller action |
|
|
125
139
|
|---|---|---|
|
|
126
|
-
| New issue created in the Dispatch project (`issue.created`, status `triage`; resync heals misses) | issue key + triage context (incl. pre-existing children) | Triage: `legion status <KEY> todo` to admit, or set `backlog`/`icebox` to park |
|
|
140
|
+
| New issue created in the Dispatch project (`issue.created`, status `triage`; under the TypeScript daemon resync heals misses, under the Go daemon the boot step above does). From the Go daemon: `triage on <KEY>` (payload `{kind: "triage"}`) on the controller topic, for a root only | issue key + triage context (incl. pre-existing children) | Triage: `legion status <KEY> todo` to admit, or set `backlog`/`icebox` to park |
|
|
127
141
|
| Backlog eligibility | slot freed / priority change | Reconsider parked items and move the eligible root to `todo` |
|
|
128
142
|
| Architect escalation (controller-actionable only: re-file a child as a root issue, capacity, cross-tree conflicts) | request + context | Judge and act; issue-scoped human Q&A goes through `dispatch_ask` from the owning architect, not here |
|
|
129
143
|
| Resync report | artifact-driven anomaly list (zero-owner trees, untriaged-open, launch-failed, admission-drift) | Verify against fresh state, then heal |
|
|
@@ -603,6 +603,11 @@ This publishes your phase's completion to the architect's role and clears the da
|
|
|
603
603
|
record of this issue's active phase. Do not add pipeline labels, run a controller loop, or
|
|
604
604
|
invent a different completion protocol — this is the whole contract.
|
|
605
605
|
|
|
606
|
+
A reviewer's phase ends with its completion, not with its review: submit the review on GitHub
|
|
607
|
+
first, then commit the handoff and complete. The daemon moves the issue once both are in — the
|
|
608
|
+
decision GitHub reports and your completion, in either order — so a review posted without a
|
|
609
|
+
completion leaves the issue in reviewing until you finish.
|
|
610
|
+
|
|
606
611
|
**A refused completion is information, not a retry loop.** The daemon attributes your report to
|
|
607
612
|
the run whose task you took, and answers with what it found. What each answer carries, and what to
|
|
608
613
|
do:
|
|
@@ -620,7 +625,10 @@ do:
|
|
|
620
625
|
has left your phase; report to the architect rather than completing again.
|
|
621
626
|
- `HANDOFF_NO_RUN` — names neither: it says this claim has taken no task, so the daemon cannot
|
|
622
627
|
tell which run you are reporting. Your pane is completing outside any assignment. Say so to the
|
|
623
|
-
architect; do not re-run the phase.
|
|
628
|
+
architect; do not re-run the phase. The same answer comes when your turn started before your task
|
|
629
|
+
reached you: a notice or a message started it, and the task, refused while that turn ran, is sent
|
|
630
|
+
when the turn ends. When a task arrives, do what it asks; if the work it asks for is already
|
|
631
|
+
committed, call `handoff_complete` again, and never redo the work or write a second handoff.
|
|
624
632
|
- `HANDOFF_ALREADY_RECORDED` — names your role, the phase, the review round and the commit. This
|
|
625
633
|
exact call was received before, and its first answer stands: accepted, or a refusal the daemon
|
|
626
634
|
records with the call — `HANDOFF_STALE_GENERATION`, `HANDOFF_NOT_CURRENT_PHASE`,
|