@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
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.
@@ -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.trajectory-labs-pbc.agent-c.pr.9880` (PR #9880 state changes)
123
- - `notifications.github.trajectory-labs-pbc.agent-c.pr.9880.comment` (comments on PR #9880)
124
- - `notifications.github.trajectory-labs-pbc.agent-c.issue.9909.>` (all events on issue #9909)
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.T09FRELLTS8.C0A0DHVU8HE.message`
147
- - `notifications.slack.T09FRELLTS8.C0A0DHVU8HE.mention`
148
- - `notifications.slack.T09FRELLTS8.C0A0DHVU8HE.thread.1234567890_123456.message`
149
- - `notifications.slack.T09FRELLTS8.C0A0DHVU8HE.thread.1234567890_123456.mention`
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.T09FRELLTS8.C0A0DHVU8HE.mention"
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.T09FRELLTS8.C0A0DHVU8HE.thread.1234567890_123456.>"
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.T09FRELLTS8.C0A0DHVU8HE.thread.1234567890_123456.message"
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.T09FRELLTS8.C0A0DHVU8HE.thread.>"
261
+ "notifications.slack.T01234567.C0A0DHVU8HE.thread.>"
254
262
  ])
255
263
  ```
256
264
 
257
- ### Subscribe to all PR events for agent-c
265
+ ### Subscribe to all PR events for example-repo
258
266
 
259
267
  ```text
260
268
  envoy_subscribe([
261
- "notifications.github.trajectory-labs-pbc.agent-c.pr.>"
269
+ "notifications.github.example-org.example-repo.pr.>"
262
270
  ])
263
271
  ```
264
272
 
265
- ### Subscribe controller to GitHub @mentions for agent-c
273
+ ### Subscribe controller to GitHub @mentions for example-repo
266
274
 
267
275
  ```text
268
276
  envoy_subscribe([
269
- "notifications.github.trajectory-labs-pbc.agent-c.mention"
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 `T09FRELLTS8`), not a workspace slug like `trajectorylabs`
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
- body: "<summary, specification, and requested decision>"
65
+ context: "<what the tree is, what triggered the gate>",
66
+ question: "<specification summary and the decision requested>"
66
67
  })
67
68
  ```
68
69
 
@@ -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
- const content = readFileSync(filePath, "utf-8");
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
- const message = messageFor(error);
28
- logger.warn(`[envoy-plugin] Failed to load config at ${filePath}: ${message}`);
29
- return null;
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 {
@@ -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