@sjawhar/opencode-legion-envoy 3.13.0 → 3.14.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.
@@ -0,0 +1,28 @@
1
+ # Authentication and the HTTP API
2
+
3
+ `skill://dispatch` sends you here when you set up a Dispatch token, or need a route the tools do
4
+ not cover.
5
+
6
+ ## Agent authentication
7
+
8
+ Use a personal Dispatch token: a human mints it in Dispatch **Settings → Agent tokens** and supplies
9
+ it to the agent through `dispatch.token` in `~/.config/opencode/envoy.json` or `DISPATCH_TOKEN`.
10
+ The server records the minting human as the owner of that session's writes. `DISPATCH_AGENT_TOKEN`
11
+ is the shared devbox fallback; do not configure it for an individual agent.
12
+
13
+ The deployed Dispatch server's browser origin is configured separately with
14
+ `DISPATCH_SERVER_URL` in the deployment `compose/.env`. Do not change an
15
+ agent's `envoy.json` to set the GitHub OAuth callback origin: the value must
16
+ be the exact URL humans type in their browser, and the GitHub App callback is
17
+ `<DISPATCH_SERVER_URL>/auth/callback`.
18
+
19
+ ### Finding a route
20
+
21
+ The tools cover the everyday surface. For anything else, ask the server: `GET /api/v1` (no
22
+ credential) returns every route as `{method, path, auth, description}` sorted by path — `auth`
23
+ is `public`, `any` (a human or a bearer), `human` (a bearer gets `403 HUMAN_ONLY`), or `bearer`.
24
+ A path Dispatch does not serve under `/api` or `/v1` answers
25
+ `404 {"code":"NOT_FOUND","error":"no route for GET /v1/issues","hint":"GET /api/v1 lists every
26
+ route"}`; when you see that, you typed the path wrong — read the index rather than guessing. Every
27
+ `/api/v1` error body carries a `code`; branch on the code, never on the text.
28
+
@@ -0,0 +1,109 @@
1
+ # Asks after they open: who answers, editing, resolving, replying, and following
2
+
3
+ `skill://dispatch` sends you here once you are asking: to find who answers an ask, hand a human a
4
+ to-do, edit or retract an ask, answer a clarification, say whose turn it is, or follow or leave its
5
+ thread. Whether to ask at all, and how to write the question, is "Before you ask" in
6
+ `skill://dispatch`.
7
+
8
+ ## Who answers an ask
9
+
10
+ An ask goes to the issue's assignee: their Inbox opens on **Mine**, which lists asks on the issues they hold plus an Unassigned band; an ask on an unassigned issue waits in that band for someone to take it. Find out who Dispatch takes you for with:
11
+ ```ts
12
+ dispatch_whoami({})
13
+ ```
14
+ It returns `details` `{ session, owner, service }`: `owner` is the lowercase login of the human whose personal token you run under, or `null` under the shared token or a verified service token, in which case `service` is that token's subject (`system:serviceaccount:<namespace>:<name>`, `null` otherwise) and every write you make is rendered `(as <namespace>/<name>)` — the namespace is kept because every namespace has a `default` service account, and a subject that is not a Kubernetes one is shown whole. An issue you create without `assignee` goes to your owner; with no owner it inherits its parent's assignee, or stays unassigned without a parent. If an issue you are asking on is unassigned and the answer matters, assign it to your owner (`PATCH /api/v1/issues/{key}` with `{"assignee": "<login>"}`; any authenticated caller may reassign, and an unlisted login is refused with `ASSIGNEE_NOT_ALLOWED`) or name in the question who should answer it. Never reassign an issue a human holds to get an answer faster: that is the human's call.
15
+
16
+ ## Handing a human a to-do, editing, resolving, and replying
17
+
18
+ A to-do handed to a human is an ordinary question: phrase the to-do as the question and give it
19
+ the options that name its outcomes, in the human's words - there is no fixed vocabulary and the
20
+ server treats no label specially. If an outcome needs a reason, say so in that option's
21
+ description, and the human's free-text answer carries it:
22
+ ```ts
23
+ dispatch_ask({ issue: "DSP-42",
24
+ question: "Run the production deploy for #19125?",
25
+ options: [{ label: "Deployed" }, { label: "Blocked", description: "Say what is missing." }] })
26
+ ```
27
+
28
+ Correct or refine an open ask in place instead of opening a second question:
29
+ ```ts
30
+ dispatch_edit_ask({
31
+ ask,
32
+ question?,
33
+ options?: { label, description? }[],
34
+ multiple?,
35
+ urgency?,
36
+ })
37
+ ```
38
+ At least one field besides `ask` is required. Use this only while the same decision remains open: it keeps the prior text in the event
39
+ log and invalidates any answer draft against the prior `edited_at` revision, so the human sees the new wording and explicitly reconfirms.
40
+ An answered or resolved ask cannot be edited. If the decision is moot or superseded, retract the old ask and open a new one.
41
+
42
+ An ask that lives as an `ask` block in a document keeps its question and options in the block, and `dispatch_edit_ask` writes the
43
+ block along with the row, so the edit stands and the document reads the same. It changes only the fields you name: pass `urgency`
44
+ alone and the question's own wording, formatting, links and comment anchors are untouched. Pass `question` or `options` and that part
45
+ is rewritten, so anchors inside the text you replaced move as they would for any document edit. Either way it is a document edit: it
46
+ writes a new version, which on a spec awaiting approval closes the design gate until the new version is approved. Editing the block
47
+ with `dispatch_doc_edit` works too and is the way to change anything else about it, including adding formatting to a question.
48
+ Re-sending a field unchanged rewrites nothing, so retrying the whole ask is safe.
49
+ Text the block cannot carry back unchanged is refused outright, naming the field and writing nothing - an option label containing
50
+ `": "`, the separator between a label and its description, is one example of text that cannot survive the round trip. Blank lines separate paragraphs;
51
+ a single newline is kept as a line break.
52
+
53
+ An ask stays open until a human answers, unless its question no longer needs that answer. Retract a moot or superseded question, or
54
+ self-resolve one after finding the answer:
55
+ ```ts
56
+ dispatch_resolve_ask({
57
+ ask,
58
+ kind: "retracted",
59
+ reason: "A newer ask supersedes this question.",
60
+ })
61
+ ```
62
+ Use `retracted` when the question is obsolete and `resolved` when you found the answer. Include the reason because the question remains
63
+ in its Conversation card and reply thread; a reason beginning `removed from the document in version` is refused, because that is how a
64
+ retraction the document's own settlement wrote is recognised. Resolving a block ask records it in the block too, so it stays resolved
65
+ however the document moves afterwards — while deleting the block from the document is the other way to close one, and putting the block
66
+ back reopens it. Resolution is not an answer: it never records a human decision, and an answered ask cannot be
67
+ resolved. A human may reply to an open or answered ask; so may you, e.g. after finding the answer — use `reply_to_ask` on
68
+ `dispatch_comment` (mutually exclusive with `reply_to`).
69
+ A review comment you opened has its own closer, `dispatch_resolve_comment` — see
70
+ [Comments and suggestions](skill://dispatch/references/documents.md).
71
+
72
+ A human answers or asks back from the same field; a question-shaped free-text answer is offered as a clarification first. A human
73
+ reply while your ask is still open (the delivered `comment.created` carries `ask_state: open`) is a request for clarification, not
74
+ an answer: the human did not understand the question or needs more before choosing. The ask now waits on you in their Inbox. Answer
75
+ in the same thread with `dispatch_comment({ reply_to_ask })`, or reword the question itself with `dispatch_edit_ask` when the wording
76
+ was the problem; either puts the ask back in front of them. Do not open a second ask.
77
+
78
+ Every reply to an open ask says whose turn it is next, and the Inbox files the ask by that, not by who spoke last. Your plain reply
79
+ (`turn` omitted, or `turn: "human"`) hands the turn to the human: the ask returns to their `Waiting on you`. When you are not done
80
+ yet — "dispatched two auditors, back with results", "checking the release branch, back shortly", any working-on-it note — reply with
81
+ `turn: "agent"`: the note lands in the thread, the ask stays under `Waiting on agents`, and the human is not told to act. Use
82
+ `turn: "agent"` for every progress note and `turn: "human"` (the default) only when you need them. A human's reply always hands the
83
+ turn to you. The result names the state (`ask now waiting on agent` / `human`), the delivered `comment.created` carries it as
84
+ `ask_waiting_on`, and every ask read carries it as `waiting_on`.
85
+
86
+ ## Following
87
+
88
+ An ask has followers: every session that wrote to it — the session that opened it and every session that replied with
89
+ `dispatch_comment({ reply_to_ask })` — plus any session a human adds from the ask card. The ask's answer, edits, resolution, and
90
+ every reply on it reach each follower's own agent topic directly, whether or not the writer was a human and whatever the issue's
91
+ route. The tool result says so (`You follow this ask: its answer and replies reach you directly.`) and carries `details.follows.ask`;
92
+ the host tells you once per ask. Leave a thread you no longer need, or rejoin one, with:
93
+
94
+ ```ts
95
+ dispatch_follow({ ask, action: "follow" | "unfollow" })
96
+ ```
97
+
98
+ `ask` is the full ask id or a `dispatch://KEY/ask/<id>` reference; `dispatch_follow`, `dispatch_edit_ask`, and
99
+ `dispatch_resolve_ask` also take an 8+ hex prefix that is unique among your own open asks, and refuse anything shorter by naming
100
+ those asks. A human may also remove you from the ask card; either way you are
101
+ told with an `ask.follower_removed` notice, and a human adding you arrives as `ask.follower_added`.
102
+
103
+ No write subscribes you to an issue or document. Following covers your own asks and the threads you joined; everything else on the
104
+ owner — other sessions' asks, comments, messages, status changes — reaches you only if you subscribe to the owner topic yourself.
105
+ Every write result names that line: `envoy_subscribe notifications.dispatch.issue.<KEY>.>` for an issue,
106
+ `envoy_subscribe notifications.dispatch.document.<PROJECT>.<SLUG>.>` for a project document. The owner topic carries every Dispatch
107
+ event; `notify` only controls agent wake and routed delivery. A human may unsubscribe you from the issue or document header; you
108
+ are told with a `subscription.removed` notice when that happens.
109
+
@@ -1,7 +1,7 @@
1
1
  # Editing a document
2
2
 
3
3
  Every document a Dispatch tool writes — an issue's spec, a project document — is edited in place
4
- with `dispatch_doc_edit`, never re-uploaded. [The Spec](../SKILL.md#the-spec) sends you here for
4
+ with `dispatch_doc_edit`, never re-uploaded. "The Spec" in `skill://dispatch` sends you here for
5
5
  the tool's shape, how to target the text you mean, what each operation costs a block, and how to
6
6
  reject a stale edit.
7
7
 
@@ -0,0 +1,148 @@
1
+ # Documents: typed blocks, comments, suggestions, and artifacts
2
+
3
+ `skill://dispatch` sends you here when you write a typed block (an `:::ask` or a callout), comment on
4
+ or suggest a change to a document, upload an artifact, or get `DOC_SCHEMA`, `INVALID_ASK_BLOCK` or
5
+ `DOC_SERVICE_UNAVAILABLE` back. Changing a document's text, tables included, is
6
+ [Editing a document](skill://dispatch/references/document-edits.md).
7
+
8
+ ## Typed blocks
9
+
10
+ The server declares typed document blocks at `GET /api/v1/schema/blocks`. Write one only with the
11
+ container-directive form `:::name{#block-id key="value"}` on its own line, ordinary block children,
12
+ and a closing line of as many colons at the same nesting. A typed block directly inside another needs the outer
13
+ one's fence a colon longer (`::::callout{…}` around a `:::callout{…}`), and so does one whose code holds a `:::` line;
14
+ Dispatch writes its fences that way. An unclosed typed block at document level is rejected. For
15
+ a new typed block, omit `#block-id`; Dispatch mints it. When editing an existing typed block, retain
16
+ its id and every rendered attribute. Never copy an existing block's id into new markdown: an id
17
+ names one block, so an insert, upload or suggestion whose markdown names an id the document holds
18
+ outside the text it replaces is refused naming the id: `INVALID_OP` for an insert,
19
+ `INVALID_MARKDOWN` for any other write. To rewrite such a block whole, `delete` it and then
20
+ `insert` the new one carrying its id, anchored on the block before or after it, in that order and
21
+ in one batch: an insert carrying an id the document still holds is refused.
22
+
23
+ Use only the type names, content rule, attributes, and enum values returned by the schema. Values are
24
+ quoted: `:::callout{kind="warning" title="Risk"}`. Do not write Pandoc-style `::: {.callout}`, leaf
25
+ `::name` directives, or text `:name` directives; those strings are literal when quoted inside a code
26
+ block. Do not set attributes the schema marks `server: true`; the server ignores them and reasserts
27
+ its authoritative value at settlement.
28
+
29
+ Questions about a document must be `ask` blocks, never an `Open questions` prose section. An ask
30
+ body is one or more question paragraphs followed by an optional bullet list of options, where each
31
+ item is `Label: description`. A spec, an uploaded document or an uploaded version holding an ask that
32
+ breaks that shape - a code block, heading or quote in it, a paragraph after its options, a second
33
+ list - is refused with `INVALID_ASK_BLOCK`; so is an edit that writes one. An ask someone left
34
+ unreadable in the browser refuses nothing it is carried through unchanged by. For example:
35
+
36
+ ```md
37
+ :::ask{urgency="high" multiple="false"}
38
+ Should we ship the migration?
39
+
40
+ - Ship: Release the verified change.
41
+ - Hold: Wait for another review.
42
+ :::
43
+ ```
44
+
45
+ When a human answers a decision written as an ask block, the answer lives on that ask. Use
46
+ `dispatch_resolve_ask` when the decision is resolved without a human response, or preserve the
47
+ human's answer; never rewrite the question into its answer or blank its options. An edit that leaves
48
+ an ask block without a question or with a blank option is rejected with `INVALID_ASK_BLOCK`.
49
+
50
+ ## Comments and suggestions
51
+
52
+ Add feedback with:
53
+
54
+ ```ts
55
+ dispatch_comment({ issue?, project?, artifact?, ref?, quote?, occurrence?, body, reply_to?, reply_to_ask?, turn? })
56
+ ```
57
+
58
+ It returns issue or project-document owner details plus `comment`; a `reply_to_ask` reply also returns `ask` and `follows: { ask }`,
59
+ because replying to an ask makes you one of its followers (see [Following](skill://dispatch/references/asks.md)).
60
+ `ref` names the owner (an issue or project-document reference) in place of `issue`/`project`.
61
+ `quote` requires `artifact`; its anchor is pinned to the containing block while retaining the quote
62
+ for display. Omit both for a floating issue comment. A reply (`reply_to`/`reply_to_ask`) takes no
63
+ `quote`; it belongs to its parent's anchor. Reply to any comment in a thread; the server keeps
64
+ threads flat. A reply to a resolved thread reopens it. Use `reply_to_ask` to reply directly under a
65
+ question asked with `dispatch_ask`; `turn` (only with `reply_to_ask`) says who holds the turn after
66
+ the reply — `agent` for a progress note that keeps the ask waiting on you, `human` (the default) when
67
+ the human needs to act; see "Asking" in `skill://dispatch`. Comments are edited only by their author from the
68
+ dashboard. A delivered `comment.created` event carries the comment `id`; reply to it with
69
+ `dispatch_comment({ reply_to: <id> })`.
70
+
71
+ Resolve your own review comment once you have addressed it:
72
+
73
+ ```ts
74
+ dispatch_resolve_comment({ comment })
75
+ ```
76
+
77
+ `comment` is the comment id, or a `dispatch://KEY/comment/<id>` or `dispatch://PROJECT/artifact/<slug>/comment/<id>` reference (an
78
+ 8+ character id prefix is resolved against the owner's comments). It returns the owner details plus `comment`, and takes no reason —
79
+ say what you did in a `reply_to` first if the thread needs it. The server lets any session or human resolve any open comment, so
80
+ resolve only threads you opened or were asked to close; reopening a resolved thread is human-only (from the dashboard), though your
81
+ reply to it reopens it. Asks are closed with `dispatch_resolve_ask` instead.
82
+
83
+ An exact replacement for document text is a suggestion (`dispatch_suggest`), never a comment; a
84
+ comment is for a question or a note the human answers in words. A human accepts a suggestion with
85
+ one click and cannot accept a comment, so propose the replacement instead of describing it:
86
+
87
+ ```ts
88
+ dispatch_suggest({ issue?, project?, artifact, ref?, quote, replace_with, body?, occurrence? })
89
+ ```
90
+
91
+ It returns issue or project-document owner details plus `comment`. A human accepts or rejects a suggestion.
92
+ Errors: `TARGET_AMBIGUOUS` (add `occurrence`), `TARGET_NOT_FOUND` (re-read first), `INVALID_ANCHOR`/`ANCHOR_MISSING`/`ANCHOR_ORPHANED`
93
+ (bad, unwritten, or stale quote), `INVALID_MARKDOWN`/`DOC_SCHEMA` (malformed content), `CAP_EXCEEDED`, `ISSUE_CLOSED`.
94
+ Suggest only what the quoted block can hold. The accept, not the suggestion, checks that: an
95
+ accept whose `replace_with` would break an ask block that was readable before it is refused with
96
+ `INVALID_ASK_BLOCK` - a question given a code block, text after an ask's options, a second option
97
+ list, or an emptied question; one that names an id the document holds outside the text it
98
+ replaces, an ask under a held id included, with `INVALID_MARKDOWN`; and one no part of the
99
+ document can hold where it sits (a code block over a table cell's whole text), or whose quote runs
100
+ into an ask or callout from the text before it, with `INVALID_OP`. Either changes nothing: the human sees the reason with no Retry, and the suggestion
101
+ stays open until someone rejects or replaces it.
102
+
103
+ ## Artifacts
104
+
105
+ Attach an image, diagram, or local file with:
106
+
107
+ ```ts
108
+ dispatch_artifact({ issue?, project?, name, path, summary? })
109
+ ```
110
+
111
+ Or, when the text is already in the call, post a Markdown document directly:
112
+
113
+ ```ts
114
+ dispatch_artifact({ issue?, project?, name: "load-test-results.md", content: "# Load test\n..." })
115
+ ```
116
+
117
+ Exactly one of `issue` and `project` is required. A project upload creates an unlinked project document; it must not include `artifact`.
118
+ Exactly one of `path` and `content` is required. It returns issue or project-document owner details plus `artifact` and `version`.
119
+ Uploading the same `name` creates its next version — so uploading `spec.md` **replaces the issue's own specification**
120
+ with your text. Never do that: the spec is edited in place with `dispatch_doc_edit` (see [Editing a document](skill://dispatch/references/document-edits.md)). Address an existing
121
+ artifact by the slug shown in the upload result or by its filename, and a project document by its artifact id, slug, or filename; the
122
+ slug also arrives on `artifact.created` events. Dispatch suffixes a slug two documents would share, so one document's
123
+ filename can be another's slug (`plan v2` takes `plan-v2`, then a document named `plan-v2` takes `plan-v2-2`): a bare
124
+ `artifact` that names both is refused with each one's id, while a `dispatch://` reference's document part is always the slug.
125
+
126
+ Documents are CommonMark. A bare `<https://example.com|text>` is a CommonMark autolink and is normalised: the angle brackets are
127
+ dropped and the URL keeps `|text`. A backslash-escaped `\<https://example.com|text>` displays as `<https://example.com|text>` in the
128
+ document but comes back re-escaped (`\<`) from `dispatch_doc_read`. A Slack mrkdwn draft, or any other payload that is not Markdown,
129
+ still belongs inside a fenced code block, where it survives verbatim both ways.
130
+
131
+ ## A document that is reloading
132
+
133
+ These calls can answer `DOC_SERVICE_UNAVAILABLE` (HTTP 503), because each writes a document inside its
134
+ transaction: `dispatch_doc_edit`; `dispatch_ask` and `dispatch_comment` on a quote; a `dispatch_comment` reply
135
+ in a thread whose first comment is anchored; `dispatch_suggest`; `dispatch_resolve_comment` on an anchored
136
+ comment; `dispatch_artifact` replacing a document that already exists; and, on an ask that lives in a `:::ask`
137
+ block, `dispatch_edit_ask` and `dispatch_resolve_ask`, which write that block. Creating an issue with a spec,
138
+ uploading a new document, `dispatch_request_approval` and `dispatch_message` never answer it, and neither do
139
+ `dispatch_edit_ask` and `dispatch_resolve_ask` on an ask that has no block. It means that document's live room
140
+ failed and is reloading from its durable copy, so the server refused rather than wait for it; your call wrote
141
+ nothing and the document is intact. Nothing retries it for you: the Dispatch client hands a 503 straight back.
142
+ Wait a few seconds and make the same call again. A second refusal in a row is worth telling your human about,
143
+ with the document's reference.
144
+
145
+ `dispatch_doc_read` can answer it too, though it writes nothing: a read never opens a live room, and waits out
146
+ a room that is reloading, so it is refused only when the document's durable copy cannot be read or decoded.
147
+ Retry it the same way.
148
+
@@ -0,0 +1,82 @@
1
+ # Before and after: an ask, a message, and a draft
2
+
3
+ `skill://dispatch` sends you here for worked examples: a decision written as clickable options, a
4
+ message that should not be sent, and a draft placed where the human reads it.
5
+
6
+ ## Before / after
7
+
8
+ Before — a wall of text hides the decision and makes the choices unclickable:
9
+
10
+ ```text
11
+ We need to settle the release gate because the deploy branch has the migration and the
12
+ dashboard changes, I checked the staging result and it is fine except the release notes are
13
+ not reviewed, so should we ship today, wait for docs, or cut the dashboard from this release?
14
+ I think waiting is safest but the customer demo is tomorrow and the list above is probably stale.
15
+ ```
16
+
17
+ After — anchor the decision and make each option a button:
18
+
19
+ ```ts
20
+ dispatch_ask({
21
+ issue: "LEGION-815",
22
+ question:
23
+ "Choose the release gate. Recommendation: ship after release-note review, since the tested deployment is otherwise ready.",
24
+ options: [
25
+ { label: "Review notes, then ship", description: "Keeps the release intact and reviewed." },
26
+ { label: "Ship now", description: "Meets the demo deadline; release notes follow later." },
27
+ ],
28
+ urgency: "high",
29
+ anchor: { artifact: "spec", quote: "Release requires reviewed operator instructions before deployment." },
30
+ })
31
+ ```
32
+
33
+ Before — a progress note that nobody needs, posted where humans look for decisions:
34
+
35
+ ```ts
36
+ dispatch_message({ issue: "LEGION-815", body: "Merged the release PR, moving to docs next." })
37
+ ```
38
+
39
+ After — nothing. The merge is visible on the pull request; the docs work shows up as its own deliverable. Post a message only when
40
+ a human must act or a deliverable is theirs to use:
41
+
42
+ ```ts
43
+ dispatch_message({
44
+ issue: "LEGION-815",
45
+ body: "Release 1.4 is live on the devbox (dispatch://LEGION-815/artifact/release-notes). Nothing needed from you.",
46
+ })
47
+ ```
48
+
49
+ Before — a draft the human must read is uploaded as a separate file, the spec only names it, and
50
+ the ask does not point at it, so the reader has to go looking:
51
+
52
+ ```ts
53
+ dispatch_artifact({ issue: "OPS-52", name: "cu-update-2026-09-15.md", content: "Hi team, ..." })
54
+ dispatch_doc_edit({ issue: "OPS-52", artifact: "spec", ops: [
55
+ { op: "insert", after: "## Context", markdown: "## Draft (artifact cu-update-2026-09-15.md)" },
56
+ ]})
57
+ dispatch_ask({ issue: "OPS-52", question: "Send the customer update as drafted?", options: [...] })
58
+ ```
59
+
60
+ After — the draft is a section of the spec, and the ask anchors there. If it really must be a
61
+ file (something to send as-is), the spec and the ask both link the slug from the upload result:
62
+
63
+ ```ts
64
+ dispatch_doc_edit({ issue: "OPS-52", artifact: "spec", ops: [
65
+ { op: "insert", after: "## Context", markdown: "## Draft\n\nHi team, ..." },
66
+ ]})
67
+ dispatch_ask({
68
+ issue: "OPS-52",
69
+ question: "Send the customer update as drafted?",
70
+ options: [...],
71
+ anchor: { artifact: "spec", quote: "Hi team," },
72
+ })
73
+ // or, for a real file — the spec links it where the reader needs it, and so does the ask:
74
+ dispatch_doc_edit({ issue: "OPS-52", artifact: "spec", ops: [
75
+ { op: "insert", after: "## Context", markdown: "## Draft\n\nThe update to send as-is: dispatch://OPS-52/artifact/cu-update-2026-09-15-md" },
76
+ ]})
77
+ dispatch_ask({
78
+ issue: "OPS-52",
79
+ question: "Send this customer update as-is? dispatch://OPS-52/artifact/cu-update-2026-09-15-md",
80
+ options: [...],
81
+ })
82
+ ```
@@ -0,0 +1,213 @@
1
+ # Working an issue: choosing, claiming, status, priority, and the backlog
2
+
3
+ `skill://dispatch` sends you here when you pick your next issue, claim or release one, get
4
+ `409 ISSUE_CLAIMED`, move an issue's status or priority, reorder a board, reassign an issue, sync a
5
+ project's architecture model, or list or audit a project's backlog.
6
+
7
+ ## Choosing what to work on
8
+
9
+ When you finish an issue, or are told to work on the next thing, take the top ready issue of the
10
+ whole backlog, across every project: status `todo`, highest priority first, then board rank. There
11
+ are no areas: a standing role, a product owner and a lane each take the top issue like everyone
12
+ else (Sami, 2026-09-27, dispatch://AGENTC-34/ask/01ed2956-73cc-48d2-8ed4-7a86c6d439b1). `todo`
13
+ means ready: specced, unblocked, and waiting on neither a deploy nor a decision. An issue that
14
+ waits on one belongs in `backlog`, with what it waits on said on the issue.
15
+
16
+ Hold at most three issues in flight (`in_progress`, `testing`, `needs_review` or `retro`), of any
17
+ kind (Sami, 2026-09-27, answering dispatch://AGENTC-34/ask/1aeb8f2e-0950-4eaa-aaac-24286c9dd3ca;
18
+ the question proposed two, and his answer set three). The limit is per agent and has nothing to do
19
+ with the week's priorities (Sami, 2026-09-28, reply a7647eb0 on
20
+ dispatch://AGENTC-393/ask/b773d9f6): the priorities decide only what you pull next. Past three:
21
+ push any unfinished work, say where in one comment on the issue, move it to `backlog` and clear
22
+ its route. Each issue counts on its own; a child does not ride under its parent's slot.
23
+ In-flight issues with no owner at all go
24
+ back to `backlog` as well: no claim or route held by a live session, no Dispatch activity in the
25
+ last day, and no pull request moving on GitHub (an owner working there leaves no Dispatch trace).
26
+ The order keeper sweeps those. Never write the status of an issue that carries the `legion`
27
+ label, or of any issue under one: the Legion daemon writes those statuses, and moving one of its
28
+ admitted roots out of its flow parks the tree and stops its workers.
29
+
30
+ One agent keeps the backlog's order against those priorities, with Sami
31
+ (dispatch://AGENTC-34/ask/f6780f9e-8b96-49eb-9be7-7c7f2036d5cc). Setting an issue's priority
32
+ stays yours ([Priority is yours to set](#priority-is-yours-to-set)); reordering the board does not.
33
+ When the top of the backlog looks wrong, or a priority's next step is not yet a ready issue,
34
+ publish it to `notifications.role.backlog-order`, which the order keeper holds, instead of
35
+ reordering the board yourself.
36
+
37
+ ## Claim the issue before you work it
38
+
39
+ Two sessions once spent a night implementing the same issue, because nothing on it said who was
40
+ on it (Sami, 2026-09-24, verbatim: "It seems like we need a better way of tracking what's already
41
+ in progress"). So before you start implementing an issue, claim it:
42
+
43
+ ```ts
44
+ dispatch_claim({ issue: "LEGION-234" }) // I am implementing this
45
+ dispatch_claim({ issue: "LEGION-234", release: true }) // I have stopped; it is free
46
+ ```
47
+
48
+ A claim records **your** session — the one making the call, never another — and shows on every
49
+ read of the issue: the dashboard header, the issue list and board, `dispatch_read` (a
50
+ `Claimed by:` line) and `dispatch_issues` (a claim on the row). A session's claim there ends
51
+ `· not running` when the live agent registry does not list that session, as the dashboard's chip
52
+ does — that claim is free to take — and `· liveness unknown` when the registry could not be read,
53
+ which says nothing either way. `dispatch_issues` plus the dashboard's **Unclaimed** filter is how
54
+ you find work nobody is on.
55
+
56
+ - **`409 ISSUE_CLAIMED` means someone else holds this issue.** When it is another session, the
57
+ refusal names it and says it is still running: do not work the issue in parallel — message
58
+ that session (its id is in the message; `envoy_send` reaches it) or pick up something else,
59
+ and tell the human if you believe the work should be yours. When a **human** holds it, the
60
+ refusal names the person and says nothing about a session running, because there is none to
61
+ message: ask them with `dispatch_ask` instead, so the open ask appears in their Inbox, and
62
+ never assume their claim has lapsed — only a human releases or forces a human's claim.
63
+ - **`409 CLAIM_CONTENDED` means the issue changed hands twice while your call ran**, so nothing
64
+ was applied and nobody's liveness was checked. Read the issue and decide again; it is not a
65
+ refusal by a live holder.
66
+ - **A claim whose session has ended is yours to take.** If the Envoy listener no longer lists
67
+ the holder, your claim simply succeeds; the takeover is recorded on the issue and the session
68
+ that lost it is told.
69
+ - **Release it when you stop** — finished, handing over, or moving to something else. A claim is
70
+ released by its holder or any human — and by any agent once the holder's session is no longer
71
+ running, the same rule that lets you take it. Closing the issue releases it for you.
72
+ - A claim is intent to implement, not contact: reading the issue, commenting, asking, or gating
73
+ its pull request claims nothing, so a coordinator never collides with an implementer.
74
+
75
+ **Claiming and moving the status are two separate actions, and you do both.** A claim says which
76
+ session is on the work; the status says where the work has got to, and humans use it to track
77
+ that too (Sami, 2026-09-24, verbatim: "Keep them separate — Separate because humans might be
78
+ using them to keep track of work"). So when you start: `dispatch_claim({ issue })` **and**
79
+ `dispatch_issue_update({ issue, status: "in_progress" })`.
80
+
81
+ ## Issue status is yours to move
82
+
83
+ The issue's status is how a human sees delivery without asking a session. Outside Legion (where
84
+ the daemon writes it), the session doing the work moves it, the way a person moves a card:
85
+ `in_progress` when implementation starts, `testing` when the change is being proven on a
86
+ production-like surface, `needs_review` when its pull request is open and waiting on the merge
87
+ queue, `done` when the change has been driven in production (a merge is not `done`). Move child
88
+ issues you own as well as the root. An issue left at `triage` while work is underway is a defect:
89
+ Sami, 2026-09-15, on the roadmap he could not read — "I'm not even sure what their development
90
+ status is." Waiting for the deploy lane is not a status and is never announced.
91
+
92
+ ```ts
93
+ // PATCH /api/v1/issues/{key} — status, title, labels, priority, external_links (merged by URL), route, parent
94
+ dispatch_issue_update({ issue: "AGENTC-175", status: "testing" })
95
+ dispatch_issue_update({ issue: "AGENTC-175", status: "done", reason: "Shipped in owner/repo#7; verified on the production dashboard." })
96
+ dispatch_issue_update({ issue: "AGENTC-175", priority: 1 }) // 0–3; see Priority is yours to set
97
+ dispatch_issue_update({ issue: "AGENTC-175", external_links: ["https://github.com/owner/repo/pull/7"] })
98
+ dispatch_issue_update({ issue: "AGENTC-175", parent: "AGENTC-170" }) // same-project key; "" clears the parent
99
+ ```
100
+
101
+ Closing takes a `reason`, and the tool refuses `status: "done"` without one: it posts the reason on
102
+ the issue as a message, then closes it, because a closed issue refuses messages, comments, and
103
+ artifacts, so a reason left for later has nowhere to go. When the close fails after the post, the
104
+ error names the posted message; after a timeout or a server error it also says the close may have
105
+ landed, so read the issue's status first. A retry points its reason at the posted message rather
106
+ than repeating it.
107
+
108
+ The two clears differ: `priority` clears with `null`, while `parent` and `route` clear with `""`.
109
+ Guessing the other one is a refusal either way.
110
+
111
+ Link the pull request that delivers the issue in `external_links` when you open it; the issue page
112
+ renders its state and checks from that link, and `dispatch_read` lists it under `External links:`.
113
+ The call is authenticated with the same bearer as every
114
+ other `dispatch_*` tool: a Legion pane reads it from the `DISPATCH_TOKEN_FILE` path the daemon sets on
115
+ the pane; an OMP session outside Legion reads `dispatch.token` from `~/.config/opencode/envoy.json`.
116
+
117
+ A write to an issue still in `triage` answers once with `… is still in triage …`; move the status
118
+ when work has started.
119
+
120
+ ## Priority is yours to set
121
+
122
+ Priority is the coarse bucket a backlog is read by: `0` is P0, the highest, through `3`, P3, the
123
+ lowest, and `null` clears it. Sami ruled on 2026-09-24, answering "may agents set issue priority
124
+ (P0–P3), or only propose it for you?" on `dispatch://LEGION/artifact/issue-status-conventions-md`:
125
+ **"Agents may set"**. So set it — on creation, and on a grooming pass over issues that have none —
126
+ and say what you set and why; he overrides anything he disagrees with from the dashboard. A closed
127
+ issue takes only `rank`, `components`, and a reopening `status` (any status but `done`);
128
+ everything else, `priority` included, waits for the reopen (`409 ISSUE_CLOSED`). So reopen it
129
+ first, then set the priority — the two cannot go in one call. `rank` itself is not a tool field:
130
+ reorder it the way [Issue reads](#board-rank-priority-and-assignee-on-a-read) describes, through `PATCH /api/v1/issues/{key}`.
131
+
132
+ ## Board rank, priority, and assignee on a read
133
+
134
+ Issue reads include `rank`, the server-owned ordering key used by project boards; reorder through `PATCH /api/v1/issues/{key}` with `{"rank": {"before": "<key>", "after": "<key>"}}`, either neighbor optional and both in the issue's project. A bearer caller also names its own session in that body, `"actor": {"kind": "session", "id": "<your session id>"}`, or the server refuses with `ACTOR_KIND`. 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`.
135
+
136
+ ## Reading a project's backlog
137
+
138
+ To see the shape of a project rather than find a phrase, list its issues:
139
+ ```ts
140
+ dispatch_issues({ project, status?, parent?, label?, priority?, route_status?, updated_since?, limit?, offset? })
141
+ ```
142
+ Each row carries the issue key, title, status, priority, parent, labels, its open-ask count, and
143
+ when it last changed — a roadmap or backlog pass without opening every issue. Filter with `status`
144
+ (a lifecycle status), `parent` (one issue's children), `label`, `priority` (a list of `0`–`3`, with
145
+ `null` for an issue with no priority: `[0, 1]` is every P0 and P1), `route_status` (below), or
146
+ `updated_since` (an RFC3339 timestamp, for "what moved this week"). `limit` is the page size, 50 by
147
+ default and 250 at most, and `offset` is where the page starts: repeat with the next offset until a
148
+ page comes back short to list every matching issue.
149
+
150
+ This is not search: it matches no text. Use `dispatch_search` for a keyword or phrase, and
151
+ `dispatch_issues` when you want every issue in a project and its current state.
152
+
153
+ ### The owner audit
154
+
155
+ As the owner of a surface, list the project's P0 and P1 issues and staff or close each one nobody
156
+ has started:
157
+ ```ts
158
+ dispatch_issues({ project, priority: [0, 1], limit: 250 })
159
+ ```
160
+ Every unclaimed row in `triage`, `icebox`, `backlog` or `todo` is a decision: someone takes it and
161
+ builds it, or it closes. A row in `in_progress`, `testing`, `needs_review` or `retro`, or one that
162
+ carries a claim, is work under way ([Issue status is yours to move](#issue-status-is-yours-to-move))
163
+ and is not re-staffed. A todo with a finished spec reads as queued work that nobody is doing
164
+ (LEGION-173 sat in todo for two weeks with a complete spec; AGENTC-1010's v4 plan sat in backlog
165
+ with nobody building it).
166
+
167
+ A close that says the defect cannot happen cites the code that makes it impossible. An issue
168
+ closed because a rewrite forecloses it names the file and line in the rewrite that does so; a
169
+ close that cannot name one is not foreclosed, it is unread. The cheapest way for a rewrite to reach
170
+ parity is to port the code, defect included: LEGION-211's bare `git worktree prune`, filed against
171
+ the TypeScript daemon, had been ported into the Go coordinator and was live in production.
172
+
173
+ The audit finds four shapes:
174
+
175
+ - **Unstaffed work.** A plan or measurement exists, and no one is building it.
176
+ - **Unrecorded delivery.** An issue not yet in `testing` or `done`, claimed or not, has a merged PR
177
+ naming it. Check the change live, then move the issue (AGENTC-1033 sat at `triage` after its fix,
178
+ agent-c #20367, merged).
179
+ - **Unrecorded practice.** Someone does the issue's work by hand, more than once, while the issue
180
+ sits in backlog (OPS-132, done by hand on every migration merge). It leaves no plan and no PR to
181
+ find; the tell is your own messages. Doing something by hand more than once means an issue is
182
+ wearing the wrong status.
183
+ - **Unreachable route.** An open issue whose route names a role nobody holds, or a session that is
184
+ not running, reaches nobody, whatever its priority, and the priority filter above never finds
185
+ it. List it on its own:
186
+ ```ts
187
+ dispatch_issues({ project, route_status: "no_holder", limit: 250 })
188
+ ```
189
+ Each row reads `route role:sre (nobody holds it right now)` or `route session:<id> (that session
190
+ is not running right now)`. That is one read of the listener, and one read is a restart gap as
191
+ often as a vacancy: an agent box that restarts or resumes keeps the session id, but the session
192
+ is absent from the listener for minutes, and its role with it. On 2026-09-27, 58 of 63 session
193
+ routes one read showed as unreachable pointed at a single session that was moving between boxes.
194
+ So a route is unowned only when it is `no_holder` on two reads at least ten minutes apart: list
195
+ again after ten minutes and act on the issues both lists name. Confirm with the second
196
+ `dispatch_issues` read, not `envoy_role_get`: a role lookup releases the claim of a holder whose
197
+ session is absent from the registry as it answers. Then staff the role, re-route the
198
+ issue to a live holder, or clear the route and assign it (AGENTC-1065, a P2 production listener
199
+ 503, sat routed to an unheld `role:sre` with no assignee). `route_status: "unknown"` means the
200
+ listener did not answer, so a route could not be judged; a `no_holder` filter refuses rather
201
+ than answer an empty list then.
202
+
203
+ Run the audit as a step of a coordinator's loop, at each checkpoint, not as a habit: these shapes
204
+ are found by running the check, not by noticing them.
205
+
206
+ ## Syncing a project's architecture model
207
+
208
+ Import a project's architecture model from its configured source repository now (a human configures the source in Settings):
209
+ ```ts
210
+ dispatch_architecture_sync({ project: "CORE" })
211
+ ```
212
+ It returns the imported commit, or the recorded error when the model was rejected — the previous model stays up. Without a configured source it answers 404 `SOURCE_NOT_FOUND`.
213
+