@sjawhar/opencode-legion-envoy 0.8.0 → 0.9.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 +2 -2
- package/dist/bin/dispatch-mcp-shim.js +525 -9535
- package/dist/src/server.js +547 -9723
- package/package.json +1 -1
- package/skills/AGENTS.md +1 -0
- package/skills/dispatch/SKILL.md +94 -0
- package/skills/envoy/SKILL.md +24 -16
- package/skills/legion-architect/SKILL.md +2 -1
- package/src/config/index.ts +21 -15
- package/src/config/schema.ts +0 -5
package/package.json
CHANGED
package/skills/AGENTS.md
CHANGED
|
@@ -8,6 +8,7 @@ event intake, process lifecycle, credentials, and role delivery.
|
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
skills/
|
|
11
|
+
├── dispatch/ # Raising a durable human question as a GitHub-issue thread
|
|
11
12
|
├── github/ # GitHub issue and pull-request operations
|
|
12
13
|
├── linear/ # Linear adapter instructions
|
|
13
14
|
├── legion-architect/ # Tree ownership, decomposition, gates, and scheduling
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dispatch
|
|
3
|
+
description: Use when you need a human decision from Sami or the user — asking a question, waiting on approval, saying "let me know", writing an end-of-message question block, picking between options with tradeoffs, or starting unattended/long-running work that will need input later. Raises the question as a durable GitHub-issue thread instead of a transcript question nobody will see.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Dispatch
|
|
7
|
+
|
|
8
|
+
`dispatch` turns a question into a durable, dashboard-visible GitHub-issue thread instead
|
|
9
|
+
of a line buried in a transcript. Use it any time you would otherwise leave a question for
|
|
10
|
+
a human to notice on their own.
|
|
11
|
+
|
|
12
|
+
## When to dispatch
|
|
13
|
+
|
|
14
|
+
- Any question not answerable at the keyboard in seconds.
|
|
15
|
+
- Anything decision-shaped: a fork in approach, a tradeoff only the human can weigh, an
|
|
16
|
+
approval gate.
|
|
17
|
+
- Anything you would otherwise write as an end-of-message question block and hope someone
|
|
18
|
+
reads.
|
|
19
|
+
- Before you park on a blocker. Never leave a session idle on an unasked question.
|
|
20
|
+
- Unattended or long-running work (Legion roles, background agents) that will need input
|
|
21
|
+
later — dispatch the question as soon as you know you'll need it, don't wait to be asked.
|
|
22
|
+
|
|
23
|
+
## When NOT to dispatch
|
|
24
|
+
|
|
25
|
+
- A one-word clarification the human is actively typing with you right now, in the same
|
|
26
|
+
turn — use the built-in `ask` tool instead. `dispatch` is for questions the reader has
|
|
27
|
+
not seen your transcript for; `ask` is for questions inside a live conversation.
|
|
28
|
+
- Anything you can resolve yourself from tools or repo context. Dispatch is for genuine
|
|
29
|
+
human decisions, not a substitute for research.
|
|
30
|
+
|
|
31
|
+
## How
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
dispatch({
|
|
35
|
+
subject: "Postgres migration: run online or take a maintenance window?",
|
|
36
|
+
context: "Implementing the users-table index migration for issue #482. The table has
|
|
37
|
+
40M rows; a plain CREATE INDEX locks writes for ~6 minutes in the staging
|
|
38
|
+
timing test I just ran.",
|
|
39
|
+
question: "Current: no online-migration tooling wired into this repo's deploy pipeline.
|
|
40
|
+
Desired: index added without a customer-visible write outage.
|
|
41
|
+
Option A — CREATE INDEX CONCURRENTLY, no lock, ~25 min, can't run inside a
|
|
42
|
+
transaction (rules out the existing migration runner without a change).
|
|
43
|
+
Option B — take a 6-minute maintenance window during the 03:00 UTC low-traffic
|
|
44
|
+
slot, keep the existing runner unchanged.
|
|
45
|
+
Recommendation: B — smaller blast radius, no migration-runner change, and the
|
|
46
|
+
window is well inside the existing maintenance SLA.",
|
|
47
|
+
urgency: "med"
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `subject`: one line, the decision — this is the issue title and the dashboard row.
|
|
52
|
+
- `context` (required): what you are doing, what you found, why you are stuck. **The
|
|
53
|
+
reader has not seen your transcript** — never write "the list above," "those items," or
|
|
54
|
+
anything that assumes shared context. State it fresh.
|
|
55
|
+
- `question` (required): current state → desired state → proposed change, then options
|
|
56
|
+
with tradeoffs, then your own recommendation. Don't just describe the fork — say which
|
|
57
|
+
branch you'd take and why.
|
|
58
|
+
- `ask` (optional): a list of `{ question, header?, options: [{ label, description? }] }`
|
|
59
|
+
when the decision is a discrete set of choices. These render as buttons on the dashboard
|
|
60
|
+
— use this instead of asking the human to type a free-text answer when the answer really
|
|
61
|
+
is "pick one of N."
|
|
62
|
+
- `urgency` (optional, default `med`):
|
|
63
|
+
- `low` — whenever it's convenient, no deadline pressure.
|
|
64
|
+
- `med` — needed today.
|
|
65
|
+
- `high` — blocking something that will stall soon if unanswered.
|
|
66
|
+
- `blocking` — you cannot proceed at all right now.
|
|
67
|
+
- `repo` (optional): only pass this to target a **different** repo than your current
|
|
68
|
+
working directory's GitHub remote. The shim fills `repo` from cwd automatically —
|
|
69
|
+
omit it in the common case.
|
|
70
|
+
- `parent` (optional): only set this when a real issue already exists to attach to —
|
|
71
|
+
`<n>` for an issue in the resolved repo, or `owner/name#<n>` to point at a specific
|
|
72
|
+
issue in another repo. For Legion roles, `parent` is the root issue you're working
|
|
73
|
+
under. Omitting it creates a standalone top-level thread; don't invent a parent that
|
|
74
|
+
doesn't exist.
|
|
75
|
+
|
|
76
|
+
## After dispatching
|
|
77
|
+
|
|
78
|
+
The reply arrives back in your own session as a steer — you do not fetch it. Keep every
|
|
79
|
+
other non-blocked lane of work moving while you wait; a dispatch is not a reason to go
|
|
80
|
+
idle. Never poll GitHub for the reply and never set up a watcher, cron, or retry loop to
|
|
81
|
+
check on it — the delivery mechanism is already push-based. The issue URL returned by the
|
|
82
|
+
tool (`{"thread": N, "url": "..."}`) is the durable record; you don't need to keep it
|
|
83
|
+
anywhere else.
|
|
84
|
+
|
|
85
|
+
If the tool errors because it can't resolve a repo (cwd has no GitHub remote and no
|
|
86
|
+
qualified `parent`), pass `repo=owner/name` explicitly and retry.
|
|
87
|
+
|
|
88
|
+
**Named risk:** a thread created in a repo where the Envoy GitHub App is not installed
|
|
89
|
+
gets created successfully, but replies to it never route back to your session — you'll
|
|
90
|
+
wait forever for a steer that can't arrive. Coverage is every repo under `sjawhar`, plus
|
|
91
|
+
`acme-org/example-repo` and `acme-org/example-e2e` — the App is
|
|
92
|
+
deliberately not installed org-wide. That list can change; to check a repo before relying
|
|
93
|
+
on a reply, `gh api /repos/<owner>/<name>/installation` returns 200 when the App is
|
|
94
|
+
installed there and 404 when it is not.
|
package/skills/envoy/SKILL.md
CHANGED
|
@@ -119,9 +119,9 @@ GitHub topics are **resource-scoped** — every event includes the resource type
|
|
|
119
119
|
|
|
120
120
|
Examples:
|
|
121
121
|
|
|
122
|
-
- `notifications.github.
|
|
123
|
-
- `notifications.github.
|
|
124
|
-
- `notifications.github.
|
|
122
|
+
- `notifications.github.example-org.example-repo.pr.9880` (PR #9880 state changes)
|
|
123
|
+
- `notifications.github.example-org.example-repo.pr.9880.comment` (comments on PR #9880)
|
|
124
|
+
- `notifications.github.example-org.example-repo.issue.9909.>` (all events on issue #9909)
|
|
125
125
|
- `notifications.github.sjawhar.legion.pr.>` (all PR events across all PRs)
|
|
126
126
|
- `notifications.github.sjawhar.legion.mention` (all @mentions repo-wide)
|
|
127
127
|
- `notifications.github.sjawhar.legion.push.branch.main` (pushes to main)
|
|
@@ -143,10 +143,10 @@ Thread timestamps are normalized: `1234567890.123456` → `1234567890_123456`
|
|
|
143
143
|
|
|
144
144
|
Examples:
|
|
145
145
|
|
|
146
|
-
- `notifications.slack.
|
|
147
|
-
- `notifications.slack.
|
|
148
|
-
- `notifications.slack.
|
|
149
|
-
- `notifications.slack.
|
|
146
|
+
- `notifications.slack.T01234567.C0A0DHVU8HE.message`
|
|
147
|
+
- `notifications.slack.T01234567.C0A0DHVU8HE.mention`
|
|
148
|
+
- `notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.message`
|
|
149
|
+
- `notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.mention`
|
|
150
150
|
|
|
151
151
|
### Ghost Wispr
|
|
152
152
|
|
|
@@ -200,6 +200,14 @@ Examples:
|
|
|
200
200
|
|
|
201
201
|
You do NOT need to subscribe in order to send or publish.
|
|
202
202
|
|
|
203
|
+
**Session ids are not stable for the life of a conversation.** On OMP, `/fork` and `/handoff`
|
|
204
|
+
re-mint the session id while the conversation continues (esc-esc rewinds also did, on omp
|
|
205
|
+
18.1.0–18.1.2 only); the extension rebinds automatically and injects an `envoy` notice naming
|
|
206
|
+
the previous and new ids. When that notice arrives, any id you shared earlier (an
|
|
207
|
+
`envoy_whoami` result quoted in a message, an id a peer saved) is stale — re-run
|
|
208
|
+
`envoy_whoami` and re-announce yourself. Never treat a whoami result from earlier in the
|
|
209
|
+
transcript as current when identifying yourself to peers.
|
|
210
|
+
|
|
203
211
|
### To wait for CI, PR checks, or other async work
|
|
204
212
|
|
|
205
213
|
**Don't `sleep`-poll. Don't "check back in N minutes."** Subscribe to the event and continue with productive work — the system will wake the session when the event arrives.
|
|
@@ -226,7 +234,7 @@ If you have nothing else to do, end the response. The user is not your alarm clo
|
|
|
226
234
|
|
|
227
235
|
```text
|
|
228
236
|
envoy_subscribe([
|
|
229
|
-
"notifications.slack.
|
|
237
|
+
"notifications.slack.T01234567.C0A0DHVU8HE.mention"
|
|
230
238
|
])
|
|
231
239
|
```
|
|
232
240
|
|
|
@@ -234,7 +242,7 @@ envoy_subscribe([
|
|
|
234
242
|
|
|
235
243
|
```text
|
|
236
244
|
envoy_subscribe([
|
|
237
|
-
"notifications.slack.
|
|
245
|
+
"notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.>"
|
|
238
246
|
])
|
|
239
247
|
```
|
|
240
248
|
|
|
@@ -242,7 +250,7 @@ envoy_subscribe([
|
|
|
242
250
|
|
|
243
251
|
```text
|
|
244
252
|
envoy_subscribe([
|
|
245
|
-
"notifications.slack.
|
|
253
|
+
"notifications.slack.T01234567.C0A0DHVU8HE.thread.1234567890_123456.message"
|
|
246
254
|
])
|
|
247
255
|
```
|
|
248
256
|
|
|
@@ -250,23 +258,23 @@ envoy_subscribe([
|
|
|
250
258
|
|
|
251
259
|
```text
|
|
252
260
|
envoy_subscribe([
|
|
253
|
-
"notifications.slack.
|
|
261
|
+
"notifications.slack.T01234567.C0A0DHVU8HE.thread.>"
|
|
254
262
|
])
|
|
255
263
|
```
|
|
256
264
|
|
|
257
|
-
### Subscribe to all PR events for
|
|
265
|
+
### Subscribe to all PR events for example-repo
|
|
258
266
|
|
|
259
267
|
```text
|
|
260
268
|
envoy_subscribe([
|
|
261
|
-
"notifications.github.
|
|
269
|
+
"notifications.github.example-org.example-repo.pr.>"
|
|
262
270
|
])
|
|
263
271
|
```
|
|
264
272
|
|
|
265
|
-
### Subscribe controller to GitHub @mentions for
|
|
273
|
+
### Subscribe controller to GitHub @mentions for example-repo
|
|
266
274
|
|
|
267
275
|
```text
|
|
268
276
|
envoy_subscribe([
|
|
269
|
-
"notifications.github.
|
|
277
|
+
"notifications.github.example-org.example-repo.mention"
|
|
270
278
|
])
|
|
271
279
|
```
|
|
272
280
|
|
|
@@ -318,7 +326,7 @@ Catches all conversations and event kinds for the specified phone number.
|
|
|
318
326
|
- Different sessions can subscribe to different channels/repos
|
|
319
327
|
- Agent-to-agent delivery uses exact session IDs
|
|
320
328
|
- `envoy_list()` distinguishes `live`, `registry`, and `both`; a `live` topic is receiving now even when the listener registry has not caught up.
|
|
321
|
-
- For Slack, use the real `team_id` in topics (for example `
|
|
329
|
+
- For Slack, use the real `team_id` in topics (for example `T01234567`), not a workspace slug like `acme`
|
|
322
330
|
- GitHub mention routing is body-based because GitHub has no dedicated app mention webhook event
|
|
323
331
|
|
|
324
332
|
## Synthetic Smoke Test (WhatsApp — NATS Routing Only)
|
|
@@ -62,7 +62,8 @@ legion({ op: "label_add", issue: "<root issue>", label: "needs-approval" })
|
|
|
62
62
|
dispatch({
|
|
63
63
|
parent: "<root issue>",
|
|
64
64
|
subject: "Legion design approval requested",
|
|
65
|
-
|
|
65
|
+
context: "<what the tree is, what triggered the gate>",
|
|
66
|
+
question: "<specification summary and the decision requested>"
|
|
66
67
|
})
|
|
67
68
|
```
|
|
68
69
|
|
package/src/config/index.ts
CHANGED
|
@@ -2,32 +2,38 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { messageFor } from "@legion/envoy-client/errors";
|
|
5
|
-
import { logger } from "../log";
|
|
6
5
|
import { type EnvoyConfig, EnvoyConfigSchema } from "./schema";
|
|
7
6
|
|
|
8
7
|
export interface LoadEnvoyConfigOptions {
|
|
9
8
|
homeDir?: string;
|
|
10
9
|
}
|
|
11
10
|
|
|
11
|
+
/** A present but unusable envoy.json. The plugin refuses to load rather than run with dispatch silently off. */
|
|
12
|
+
export class EnvoyConfigError extends Error {
|
|
13
|
+
readonly filePath: string;
|
|
14
|
+
constructor(filePath: string, detail: string) {
|
|
15
|
+
super(`[envoy-plugin] invalid config at ${filePath}: ${detail}`);
|
|
16
|
+
this.name = "EnvoyConfigError";
|
|
17
|
+
this.filePath = filePath;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
function readConfigFile(filePath: string): EnvoyConfig | null {
|
|
13
22
|
if (!existsSync(filePath)) return null;
|
|
23
|
+
let raw: unknown;
|
|
14
24
|
try {
|
|
15
|
-
|
|
16
|
-
const raw = JSON.parse(content) as unknown;
|
|
17
|
-
const parsed = EnvoyConfigSchema.safeParse(raw);
|
|
18
|
-
if (!parsed.success) {
|
|
19
|
-
const issues = parsed.error.issues
|
|
20
|
-
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
21
|
-
.join(", ");
|
|
22
|
-
logger.warn(`[envoy-plugin] Invalid config at ${filePath}: ${issues}`);
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
return parsed.data as EnvoyConfig;
|
|
25
|
+
raw = JSON.parse(readFileSync(filePath, "utf-8")) as unknown;
|
|
26
26
|
} catch (error) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
throw new EnvoyConfigError(filePath, messageFor(error));
|
|
28
|
+
}
|
|
29
|
+
const parsed = EnvoyConfigSchema.safeParse(raw);
|
|
30
|
+
if (!parsed.success) {
|
|
31
|
+
const issues = parsed.error.issues
|
|
32
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
33
|
+
.join(", ");
|
|
34
|
+
throw new EnvoyConfigError(filePath, issues);
|
|
30
35
|
}
|
|
36
|
+
return parsed.data;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
function mergeConfig(base: EnvoyConfig, override: EnvoyConfig): EnvoyConfig {
|
package/src/config/schema.ts
CHANGED
|
@@ -6,11 +6,6 @@ export const DispatchConfigSchema = z
|
|
|
6
6
|
.object({
|
|
7
7
|
enabled: z.boolean().optional(),
|
|
8
8
|
serverUrl: z.string().url().optional(),
|
|
9
|
-
defaultRepo: z
|
|
10
|
-
.string()
|
|
11
|
-
.regex(/^[^/]+\/[^/]+$/)
|
|
12
|
-
.optional(),
|
|
13
|
-
appClientId: z.string().optional(),
|
|
14
9
|
})
|
|
15
10
|
.strict();
|
|
16
11
|
|