@sjawhar/pi-envoy 0.0.0 → 8.0.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,116 @@
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 email of the person 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": "<email>"}`; any authenticated caller may reassign, and an email nobody has signed in with 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 still follows the question and options contract that `skill://dispatch`'s
19
+ "Writing for the human" states. For a human-only action, what it changes and risks constrains the
20
+ answer. There is no fixed vocabulary and the server treats no label specially.
21
+ If an outcome needs a reason, say so in that option's description, and the human's free-text answer
22
+ carries it:
23
+ ```ts
24
+ dispatch_ask({
25
+ issue: "DSP-42",
26
+ question:
27
+ "The verified fix remains unavailable in production because deployment is pending. Deploying changes production and could expose a deployment problem. How should we proceed? Recommendation: deploy the verified fix now because it restores the intended behavior.",
28
+ options: [
29
+ { label: "Deploy the verified fix", description: "Restores the fix but can expose a deployment problem." },
30
+ { label: "Hold the deploy", description: "Avoids a production change now but leaves production without the fix." },
31
+ ],
32
+ })
33
+ ```
34
+
35
+ Correct or refine an open ask in place instead of opening a second question:
36
+ ```ts
37
+ dispatch_edit_ask({
38
+ ask,
39
+ question?,
40
+ options?: { label, description? }[],
41
+ multiple?,
42
+ urgency?,
43
+ })
44
+ ```
45
+ 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
46
+ log and invalidates any answer draft against the prior `edited_at` revision, so the human sees the new wording and explicitly reconfirms.
47
+ An answered or resolved ask cannot be edited. If the decision is moot or superseded, retract the old ask and open a new one.
48
+
49
+ 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
50
+ block along with the row, so the edit stands and the document reads the same. It changes only the fields you name: pass `urgency`
51
+ alone and the question's own wording, formatting, links and comment anchors are untouched. Pass `question` or `options` and that part
52
+ 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
53
+ writes a new version, which on a spec awaiting approval closes the design gate until the new version is approved. Editing the block
54
+ with `dispatch_doc_edit` works too and is the way to change anything else about it, including adding formatting to a question.
55
+ Re-sending a field unchanged rewrites nothing, so retrying the whole ask is safe.
56
+ Text the block cannot carry back unchanged is refused outright, naming the field and writing nothing - an option label containing
57
+ `": "`, the separator between a label and its description, is one example of text that cannot survive the round trip. Blank lines separate paragraphs;
58
+ a single newline is kept as a line break.
59
+
60
+ An ask stays open until a human answers, unless its question no longer needs that answer. Retract a moot or superseded question, or
61
+ self-resolve one after finding the answer:
62
+ ```ts
63
+ dispatch_resolve_ask({
64
+ ask,
65
+ kind: "retracted",
66
+ reason: "A newer ask supersedes this question.",
67
+ })
68
+ ```
69
+ Use `retracted` when the question is obsolete and `resolved` when you found the answer. Include the reason because the question remains
70
+ in its Conversation card and reply thread; a reason beginning `removed from the document in version` is refused, because that is how a
71
+ retraction the document's own settlement wrote is recognised. Resolving a block ask records it in the block too, so it stays resolved
72
+ however the document moves afterwards. `dispatch_doc_edit` refuses deleting the block of an open ask; a block removed another way, by a
73
+ whole-document `dispatch_artifact` replace or by a person in the browser, closes its ask, and putting the block back reopens it. Resolution is not an answer: it never records a human decision, and an answered ask cannot be
74
+ 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
75
+ `dispatch_comment` (mutually exclusive with `reply_to`).
76
+ A review comment you opened has its own closer, `dispatch_resolve_comment` — see
77
+ [Comments and suggestions](skill://dispatch/references/documents.md).
78
+
79
+ A human answers or asks back from the same field; a question-shaped free-text answer is offered as a clarification first. A human
80
+ reply while your ask is still open (the delivered `comment.created` carries `ask_state: open`) is a request for clarification, not
81
+ an answer: the human did not understand the question or needs more before choosing. The ask now waits on you in their Inbox. Answer
82
+ in the same thread with `dispatch_comment({ reply_to_ask })`, or reword the question itself with `dispatch_edit_ask` when the wording
83
+ was the problem; either puts the ask back in front of them. Do not open a second ask.
84
+
85
+ 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
86
+ (`turn` omitted, or `turn: "human"`) hands the turn to the human: the ask returns to their `Waiting on you`. When you are not done
87
+ yet — "dispatched two auditors, back with results", "checking the release branch, back shortly", any working-on-it note — reply with
88
+ `turn: "agent"`: the note lands in the thread, the ask stays under `Waiting on agents`, and the human is not told to act. Use
89
+ `turn: "agent"` for every progress note and `turn: "human"` (the default) only when you need them. A human's reply always hands the
90
+ turn to you. The result names the state (`ask now waiting on agent` / `human`), the delivered `comment.created` carries it as
91
+ `ask_waiting_on`, and every ask read carries it as `waiting_on`.
92
+
93
+ ## Following
94
+
95
+ An ask has followers: every session that wrote to it — the session that opened it and every session that replied with
96
+ `dispatch_comment({ reply_to_ask })` — plus any session a human adds from the ask card. The ask's answer, edits, resolution, and
97
+ 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
98
+ route. The tool result says so (`You follow this ask: its answer and replies reach you directly.`) and carries `details.follows.ask`;
99
+ the host tells you once per ask. Leave a thread you no longer need, or rejoin one, with:
100
+
101
+ ```ts
102
+ dispatch_follow({ ask, action: "follow" | "unfollow" })
103
+ ```
104
+
105
+ `ask` is the full ask id or a `dispatch://KEY/ask/<id>` reference; `dispatch_follow`, `dispatch_edit_ask`, and
106
+ `dispatch_resolve_ask` also take an 8+ hex prefix that is unique among your own open asks, and refuse anything shorter by naming
107
+ those asks. A human may also remove you from the ask card; either way you are
108
+ told with an `ask.follower_removed` notice, and a human adding you arrives as `ask.follower_added`.
109
+
110
+ No write subscribes you to an issue or document. Following covers your own asks and the threads you joined; everything else on the
111
+ owner — other sessions' asks, comments, messages, status changes — reaches you only if you subscribe to the owner topic yourself.
112
+ Every write result names that line: `envoy_subscribe notifications.dispatch.issue.<KEY>.>` for an issue,
113
+ `envoy_subscribe notifications.dispatch.document.<PROJECT>.<SLUG>.>` for a project document. The owner topic carries every Dispatch
114
+ event; `notify` only controls agent wake and routed delivery. A human may unsubscribe you from the issue or document header; you
115
+ are told with a `subscription.removed` notice when that happens.
116
+
@@ -0,0 +1,202 @@
1
+ # Editing a document
2
+
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" in `skill://dispatch` sends you here for
5
+ the tool's shape, how to target the text you mean, what each operation costs a block, and how to
6
+ reject a stale edit.
7
+
8
+ ```ts
9
+ dispatch_doc_edit({ issue?, project?, artifact, ops, precondition?, summary? })
10
+ ```
11
+ It returns issue or project-document owner details plus `applied`, optional `version`, `changed`,
12
+ `unchanged_ops`, `lost_ops`, and the document token this edit produced, rendered as a
13
+ `Document token: <token>` line. `ops` is an array of this exact `EditOp` shape:
14
+
15
+ ```ts
16
+ type EditOp = {
17
+ op: "replace" | "delete" | "insert" | "retype" | "move" | "delete_row" | "delete_column";
18
+ find?: string;
19
+ with?: string;
20
+ occurrence?: number;
21
+ markdown?: string;
22
+ after?: string;
23
+ before?: string;
24
+ block?: string;
25
+ index?: number;
26
+ type?: string;
27
+ attributes?: Record<string, unknown>;
28
+ };
29
+ ```
30
+
31
+ An operation takes only these keys. A key it does not declare is refused before the call leaves
32
+ your process, naming the operation and its keys, because every key but `op` is optional: a
33
+ misspelled `with` would otherwise be dropped and the `replace` would delete the text you meant to
34
+ rewrite.
35
+
36
+ Target `replace`, `delete`, and quote insert anchors by a block's text as rendered: write inline
37
+ code without backticks, bold without asterisks, and link text without link syntax. A table-cell
38
+ anchor is its cell text. Quote code-block contents without their Markdown fences. A quote must stay
39
+ within one textblock; split changes that span separate blocks into separate operations.
40
+
41
+ `replace` requires `find` and `with`; `delete` requires `find` or `block`; `insert` requires `markdown` and exactly one of `after` or
42
+ `before`; `move` requires `block` and exactly one of `after` or `before`; and `delete_row` / `delete_column` each require a table
43
+ `block` plus a zero-based `index`. An insert or move anchor is a quote, `"start"`, `"end"`, `"heading:Title"`, or `"block:<id>"`.
44
+ Ordinary inserts create a sibling block before or after the quote, heading, or block's enclosing document block, and a move lands the
45
+ block at that same boundary; `"start"` and `"end"` select the document edges. At a table-cell quote, a body-row fragment (no header or
46
+ delimiter rows) extends that table before or after the matched row instead, decided in three steps. A fragment whose every line yields
47
+ a cell and an unescaped `|` (a lone `|` yields no cell, and a line whose only pipe is `\|` has no separator), with no line a delimiter
48
+ row of three hyphens or more a cell, is parsed as rows of that table. What that parse refuses is refused: a row holding text past the
49
+ table's width as `TABLE_WIDTH` (blank cells there are dropped), a line the table cannot hold as a row (indented code, `2. | a | b |`)
50
+ as `INVALID_OP`. When it refuses nothing and reads every line as a row, the rows are inserted, short ones padded. Any other fragment is
51
+ read on its own as blocks, which are inserted after the table or refused as such blocks would be: a whole table you paste there, its
52
+ delimiter row three hyphens or more a cell, becomes a second table, while one with any delimiter cell of one or two hyphens (`| - |`,
53
+ `| --- | - |`) passes the first step, so its header and delimiter rows are inserted as rows of the table, or refused as `TABLE_WIDTH`
54
+ where it is wider than the table. Deleting
55
+ a cell's quoted text removes only that text. `delete_row` / `delete_column` instead mutate their named table in place, keeping the
56
+ table's block id. A row index includes the header: row `0` is the header and its deletion promotes the first body row. The last body
57
+ row and any row's last column cannot be deleted. An index is required. A missing, non-integer, negative, or out-of-range index is
58
+ `INVALID_OP` on `index`, naming the supplied value and the table's actual dimensions before making any change. Markdown parsing
59
+ canonicalizes short ragged rows by padding missing cells, so column deletion preserves every non-selected cell in the canonical table.
60
+ `GET /api/v1/artifacts/<artifact UUID>/blocks` reports a table's own references plus its descendant cell anchors.
61
+ `GET /api/v1/artifacts/<artifact UUID>/blocks/<block id>` places one block: its path from the top-level block down, and for a cell
62
+ the row index (0 is the header, what `delete_row` takes), the column index (what `delete_column` takes), the text of the header cell
63
+ drawn above it (with colspans or rowspans, the column the cell is drawn in) and the row's cells. A row or column
64
+ deletion that would remove an open ask or unresolved comment anchor is `INVALID_OP` on `index`, naming the axis and anchor ids;
65
+ answered asks and resolved comments are history and do not block it. A `find` or quote anchor tolerates inline Markdown
66
+ (`**bold**`, `` `code` ``) and a leading `# ` selects a heading by its text; a miss names the quote and the three nearest blocks so
67
+ the next quote lands, and a `find` cut before a closing `**` or `` ` `` is refused as an unbalanced inline mark rather than reported
68
+ as a miss. A `heading:` anchor matches the whole heading text exactly — a prefix of a longer heading is a miss, naming the anchor and
69
+ the nearest headings. `replace` is inline: `with` is the new text of the matched span inside its block, so a marker of a *different*
70
+ kind from the block's own (`4. Design` written into a heading, `# Title` into a paragraph) stays literal text and never turns the
71
+ block into a list or heading. A `with` that opens with a marker of the *same* kind as the matched block's own would write it twice and
72
+ is rejected (`INVALID_OP` on `with`) — including prose that merely looks like a marker (`1999. was a year` into an ordered item),
73
+ which is written as text with a backslash escape (`1999\. was a year`) — omit the marker to replace the block's text, or use `insert`
74
+ plus `delete` to change the block's kind, level or number. The one exception is a heading rename whose `find` carried a heading
75
+ marker: `replace(find="## Old", with="## New")` gives `## New`. A different level in `with` applies only when `find` named the
76
+ heading's actual level — `find="## Old"`, `with="### New"` retitles and makes it an h3 — because `# ` is the level-blind selector,
77
+ so `find="# Old"` renames the text and keeps whatever level it selected. A task item (`- [ ]` / `- [x]`) is ticked the same way:
78
+ when `find` matches from the start of the item's text and `with` opens with `[x] ` or `[ ] `, the opening sets the item's
79
+ checkbox and the rest is its new text — `replace(find="Write it", with="[x] Write it")` ticks it, keeping its block id — since
80
+ the box is the item's attribute, not text, and written as text it would read `- [ ] [x] …`. The same opening over a plain list
81
+ item or a paragraph stays literal text, as any marker of another kind does. `with` that forms more than one
82
+ paragraph is rejected (`INVALID_OP` on `with`) — see the recipe for a multi-paragraph rewrite below; so is any non-empty `with` that
83
+ renders to no text, which a line indented four spaces or a tab does (markdown reads that as a code block), as does whitespace
84
+ alone. An empty `with` deletes the matched text on purpose; a list item, quote, typed block or footnote definition left
85
+ holding only the emptied paragraph keeps it, and reads back holding it. Emptying a task item's first paragraph while
86
+ another block follows it in the item is `INVALID_OP`, since the browser editor reads no such item as a task. Inside a code
87
+ block none of this applies: `with` is the code's literal text, written as sent, whitespace, markdown syntax and
88
+ references included, except that line breaks at the end of the code's text, and a line holding only whitespace in a
89
+ list item's code, do not survive the next read. A line of colons in code inside a typed block is kept: Dispatch writes
90
+ that typed block's fence longer than any such line the browser editor would end it at. Text a `replace` writes that
91
+ would read as block syntax at a line start is stored escaped and reads back as the characters you sent: `---` over a
92
+ paragraph is stored `\---`, not a rule, so to add a rule, `insert` it beside the paragraph (`insert` with markdown
93
+ `***`). A CR LF or a lone carriage return in any text you write - an insert's markdown, a `with` in text or in code,
94
+ a suggestion, an upload or a spec - is stored as a line feed, as the browser editor reads both. Use zero-based
95
+ `occurrence` for a
96
+ repeated target; re-read a missing or ambiguous target before retrying. Pass `summary` to name the version when recording a decision.
97
+
98
+ **Rewriting several paragraphs is one `replace` per paragraph, then a read-back.** `replace` is inline:
99
+ each `with` is the new text of one paragraph, and outside a code block a `with` that forms two paragraphs is
100
+ refused whatever the text says. Give each paragraph you rewrite its own `replace`, which keeps that paragraph's block id and every
101
+ anchor outside the text you rewrite. A comment or ask anchored to the text you rewrite loses its quote but keeps
102
+ its pin to the block, so the dashboard still shows it beside that paragraph; a delete (below) loses both. An
103
+ anchor that straddles the boundary keeps its mark over the words you left alone, with its quote shortened to
104
+ them: rewriting `charlie delta.` under a comment on `bravo charlie` leaves that comment quoting `bravo `. When
105
+ the new text has more paragraphs than the old, write them as **one** `insert` whose `markdown` holds them all,
106
+ anchored on the last paragraph you rewrote with `after` quoting it exactly as it now reads (the operations in
107
+ one batch apply in order): each separate `insert` at the same anchor lands directly after it, so two of them
108
+ come out in the reverse of the order you wrote. An insert lands after the top-level block that holds the quote,
109
+ so beside a paragraph inside a list item or a typed block it goes after the whole list or block. When it has fewer,
110
+ `delete` each leftover paragraph, with its whole text as `find` or its id as `block`. All of that holds while
111
+ the new text is paragraphs: `replace` keeps a block's kind, so any block that is not a paragraph — a heading,
112
+ list, table, blockquote, rule, code fence or typed block — cannot be replaced into place. Its marker is written
113
+ as literal text, except a backtick fence, which becomes an inline code span whose text is everything inside it,
114
+ info string and line breaks included (```` ```go\nx := 1``` ```` becomes the code span `go` + a line break +
115
+ `x := 1`), and a tilde fence, which stays literal.
116
+
117
+ Outside a code block, HTML is not written as text at all, and a `replace` whose HTML would open a block where it lands is refused
118
+ (`INVALID_OP` on `with`), because the schema carries no block HTML. `<div>x</div>` and `<!-- note -->` open one
119
+ at the start of a paragraph or a list item and on the line after a hard break; a tag such as `<br>` opens one
120
+ only when it stands alone as a paragraph or a list item. The same HTML inside a line, in a table cell or in a
121
+ heading is inline HTML and is kept as written.
122
+
123
+ A hard line break in `with` (two trailing spaces or a backslash before a newline) is refused in a heading or a
124
+ table cell (`INVALID_OP` on `with`): both are written on one line, so the break would end the block there. A line
125
+ break inside a code span or inline HTML there is refused the same way. Write the text without the break, or
126
+ `insert` a new block after this one.
127
+
128
+ When the new text adds a block that is not a paragraph beside paragraphs, `insert` it beside the
129
+ paragraph you replaced, which keeps that paragraph's id; only when no paragraph of the new text is left to take
130
+ the old block's place is it a `delete` of the old block and then an `insert` of the new one, anchored on the
131
+ block before or after it. The delete is what costs the id (below); a typed block keeps its id when the insert
132
+ carries it, which works only in that order, because an insert carrying an id the document still holds is refused.
133
+ The tools refuse a `delete` or `retype` that would take out an `ask` block whose ask is still open, delete-then-insert included:
134
+ reword it with `replace`, relocate it with `move`, or change its question, options, urgency or `multiple` with `dispatch_edit_ask` if you asked it. A single
135
+ batch that moves an open block out of a container and then deletes the container is refused; make them two calls (the check reads
136
+ the document as it stood before the batch).
137
+ Then read the document back with
138
+ `dispatch_doc_read` and read the passage and its neighbours, not a grep for the words you added: an empty
139
+ `with` deletes the matched text on purpose, so a `replace` whose `with` you meant to fill
140
+ empties that paragraph — the block and its id stay, holding nothing — and only a read shows what the document now says.
141
+
142
+ A batch that leaves the document's semantic identity unchanged — including its inline anchor marks, so an edit that only orphans a
143
+ comment or ask anchor still mints its version — mints no version, named or not: the response carries
144
+ `changed: false` with `unchanged_ops` naming each operation that did nothing, and the tool result says nothing changed. A `summary`
145
+ does not force a version for such a batch; `POST /api/v1/artifacts/<id>/versions`, which names the current state on purpose, still does.
146
+
147
+ A `delete` whose `find` is a block's entire text removes the block itself — the bullet, paragraph, or heading, not just its words — and
148
+ a list emptied of every item disappears with it; a partial match keeps the block with its remaining text. Deleting the text of a bullet
149
+ that holds a nested list hoists that list's items into the bullet's place (as an outliner does); a bullet with any other content
150
+ (paragraphs, code, tables) is refused with `INVALID_OP` naming `delete {block:"<item id>"}`, which removes the item with its content.
151
+ `delete` with `block` removes any block by id (paragraph, heading, list, list item, table, or typed block; an answered `ask` block keeps
152
+ its answer as the record, and a resolved one, which carries no answer, its resolution), and `move` with `block` relocates one, keeping its id and
153
+ attributes — a moved `ask` keeps its ask and answer. **A block loses its id only when it is removed**, and its anchors go with it:
154
+ `delete` by text or by id removes the block and any container it empties; `delete_row` / `delete_column` remove their cells' ids,
155
+ which is why they are refused while an open ask or unresolved comment sits on them; and a `move` that takes the last block out of a
156
+ blockquote or list item removes that emptied container, the list too when no item remains, and each enclosing container that
157
+ held nothing else (`> - Only.` loses the blockquote as well as the item and the list). The moved block itself keeps its id,
158
+ as do `replace` (an accepted empty `with` and a heading-level change included), `insert` and `retype`; `retype` carries the
159
+ paragraph's id onto the typed block it becomes. Block ids are the `#id` a typed block renders
160
+ (`:::ask{#5467e5ce-…}`) and, for every block including untyped ones, the `id` rows from
161
+ `GET /api/v1/artifacts/<artifact UUID>/blocks` (or `/api/v1/issues/{key}/artifacts/{slug}/blocks`), each with its `type` and byte range
162
+ in canonical markdown; the UUID route does not accept a slug. A later operation in the same atomic batch that names a block removed by
163
+ an earlier `delete {block}` fails as `INVALID_OP` naming the earlier operation and the parent block that cascaded the removal. A move
164
+ whose anchor lies inside the moved block, or a delete that would leave a typed block without the body its content rule requires, is
165
+ `INVALID_OP` naming the field and the rule.
166
+
167
+ `GET /api/v1/artifacts/<artifact UUID>/blocks` includes a full-state `token` on every block, including
168
+ inline marks. To reject a stale edit, pass `precondition` with exactly one of
169
+ `{ document: "<document token>" }` or
170
+ `{ blocks: [{ id: "<block id>", token: "<block token>" }] }`. A document token comes from
171
+ `dispatch_doc_read` or from the previous `dispatch_doc_edit`, whose result carries the token its
172
+ own edit produced: a run of guarded edits passes each result's token to the next one and reads the
173
+ document once, not once per edit. The server resolves the whole batch before
174
+ mutation: a block guard must cover every content block it changes, or Dispatch returns
175
+ `400 INVALID_PRECONDITION` without applying anything. Use a document token for insert and move because they
176
+ depend on document order. A block token lets other sections change concurrently; a new anchored ask or comment
177
+ changes the relevant token. A stale guard returns `409 PRECONDITION_FAILED` with each mismatch and current
178
+ token; Dispatch applies no part of that batch. It is the hashline `#TAG` property applied to stable block ids,
179
+ not line numbers: canonical Markdown lines shift under concurrent edits and rendering changes, while block ids
180
+ survive moves and retyping.
181
+
182
+ A browser editing the same document while your edit is in flight never makes your edit a silent
183
+ no-op. The two changes merge, and a human's deletion of the paragraph you are rewriting wins — but
184
+ you are told. `409 EDIT_LOST_TO_CONCURRENT_CHANGE` means the whole batch was refused and nothing
185
+ was written: re-read the document and decide again, as with `PRECONDITION_FAILED`. A success whose
186
+ `lost_ops` names operations means the version was written and the live document already lacks what
187
+ those operations wrote, because the deletion landed after the version: re-read before building on
188
+ it. `lost_ops: []` is the ordinary outcome, and a result that says it could not confirm the edit
189
+ survived means the room is reloading or holds a tree too deep to read — re-read, and a read that
190
+ answers `DOC_SCHEMA` says which. An operation that only removes text (`delete`, `delete_row`,
191
+ `delete_column`, a `replace` that shortens) is never reported lost: a concurrent deletion cannot
192
+ undo a removal.
193
+
194
+ `retype` turns the paragraph or typed block with `block` into the named typed `type` in place. It keeps the
195
+ block id, keeps a typed block's body, and uses `attributes` for client-owned typed attributes. Use it when
196
+ an existing paragraph is the question that should become a decision.
197
+
198
+ An ask block has two ids: the block id, shown as `:::ask{#<id> …}` in the rendered document and
199
+ taken bare by `move`/`delete` in `block` (the `block:<id>` form is only for `before`/`after`
200
+ anchors), and the ask id, which `dispatch_open_asks`, the dashboard's `?ask=` link, `dispatch_read`
201
+ and `dispatch_comment({ reply_to_ask })` use. They differ; `dispatch://KEY/ask/<block-id>` answers
202
+ `not found`.
@@ -0,0 +1,230 @@
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, request a document's approval, or get
5
+ `DOC_SCHEMA`, `INVALID_ASK_BLOCK` or `DOC_SERVICE_UNAVAILABLE` back. Changing a document's text,
6
+ tables included, is [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. An
15
+ opening line that continues a paragraph instead of standing on its own (indented four or more
16
+ columns under the paragraph's text) is refused, naming the line's number and text - `INVALID_OP` on an edit's `markdown` or `with`, `INVALID_MARKDOWN` on any other write - since it
17
+ would be stored as the paragraph's text; start the block on its own line. An opening written inside
18
+ a line is stored as text, and `dispatch_issue` and `dispatch_artifact` quote it back as a
19
+ typed-block opening that is text, not a block. For
20
+ a new typed block, omit `#block-id`; Dispatch mints it. When editing an existing typed block, retain
21
+ its id and every rendered attribute. Never copy an existing block's id into new markdown: an id
22
+ names one block, so an insert, upload or suggestion whose markdown names an id the document holds
23
+ outside the text it replaces is refused naming the id: `INVALID_OP` for an insert,
24
+ `INVALID_MARKDOWN` for any other write. To rewrite such a block whole, `delete` it and then
25
+ `insert` the new one carrying its id, anchored on the block before or after it, in that order and
26
+ in one batch: an insert carrying an id the document still holds is refused. An `ask` block whose ask
27
+ is still open cannot be rewritten that way: the tools refuse the `delete`, so reword it with
28
+ `replace`, relocate it with `move`, or change its question, options, urgency or `multiple` with
29
+ `dispatch_edit_ask` if you asked it ([Editing a document](skill://dispatch/references/document-edits.md)).
30
+
31
+ Use only the type names, content rule, attributes, and enum values returned by the schema. Values are
32
+ quoted: `:::callout{kind="warning" title="Risk"}`. Do not write Pandoc-style `::: {.callout}`, leaf
33
+ `::name` directives, or text `:name` directives; those strings are literal when quoted inside a code
34
+ block. Do not set attributes the schema marks `server: true`; the server ignores them and reasserts
35
+ its authoritative value at settlement.
36
+
37
+ Questions about a document must be `ask` blocks, never an `Open questions` prose section. An ask
38
+ body is one or more question paragraphs followed by an optional bullet list of options, where each
39
+ item is `Label: description`. A spec, an uploaded document or an uploaded version holding an ask that
40
+ breaks that shape - a code block, heading or quote in it, a paragraph after its options, a second
41
+ list - is refused with `INVALID_ASK_BLOCK`; so is an edit that writes one. An ask someone left
42
+ unreadable in the browser refuses nothing it is carried through unchanged by. For example:
43
+
44
+ ```md
45
+ :::ask{urgency="high" multiple="false"}
46
+ Today's release is blocked by a database migration. The maintenance window closes in two hours;
47
+ whether production data needs an index rebuild is unknown. How should we complete the migration?
48
+ Recommendation: rehearse on a production snapshot, then apply in the window, because it finds the
49
+ unknown cost before production while keeping today's release possible.
50
+
51
+ - Apply now: Meets today's release, but recovery may be slower if the index rebuild is needed.
52
+ - Rehearse then apply: Costs rehearsal time, but exposes the rebuild and rollback cost before production.
53
+ - Defer the release: Avoids migration risk today, but leaves the release and its fixes unavailable.
54
+ :::
55
+ ```
56
+
57
+ When a human answers a decision written as an ask block, the answer lives on that ask. Use
58
+ `dispatch_resolve_ask` when the decision is resolved without a human response, or preserve the
59
+ human's answer; never rewrite the question into its answer or blank its options. An edit that leaves
60
+ an ask block without a question or with a blank option is rejected with `INVALID_ASK_BLOCK`.
61
+
62
+ When `dispatch_issue` or `dispatch_artifact` answers `This spec holds no ask blocks …`, read it as a
63
+ question: either no decision is needed and you say nothing, or you forgot to make it a block. When
64
+ it answers `… typed-block openings in this document are text, not blocks`, the quoted openings are
65
+ blocks stored as prose (inside a line, or a paste with something before every line): fix the markdown
66
+ and upload again; a mention on purpose belongs in code. Neither answer sees a spec wrapped whole in a
67
+ code fence (take the fence off), a malformed opening inside a line (`::ask{`, `:::ask {`: an ask opens
68
+ only as `:::ask{…}` on a line of its own), or any `dispatch_doc_edit`: after an edit that writes an
69
+ ask, `dispatch_doc_read` the section and check it renders as `:::ask{#<id> …}` on its own line.
70
+
71
+ ## Comments and suggestions
72
+
73
+ Add feedback with:
74
+
75
+ ```ts
76
+ dispatch_comment({ issue?, project?, artifact?, ref?, quote?, occurrence?, body, reply_to?, reply_to_ask?, turn? })
77
+ ```
78
+
79
+ It returns issue or project-document owner details plus `comment`; a `reply_to_ask` reply also returns `ask` and `follows: { ask }`,
80
+ because replying to an ask makes you one of its followers (see [Following](skill://dispatch/references/asks.md)).
81
+ `ref` names the owner (an issue or project-document reference) in place of `issue`/`project`.
82
+ `quote` requires `artifact`; its anchor is pinned to the containing block while retaining the quote
83
+ for display. Omit both for a floating issue comment. A reply (`reply_to`/`reply_to_ask`) takes no
84
+ `quote`; it belongs to its parent's anchor. Reply to any comment in a thread; the server keeps
85
+ threads flat. A reply to a resolved thread reopens it, except a decided suggestion's: a reply joins
86
+ that thread and the suggestion stays accepted or rejected. Use `reply_to_ask` to reply directly under a
87
+ question asked with `dispatch_ask`; `turn` (only with `reply_to_ask`) says who holds the turn after
88
+ the reply — `agent` for a progress note that keeps the ask waiting on you, `human` (the default) when
89
+ the human needs to act; see "Asking" in `skill://dispatch`. Comments are edited only by their author from the
90
+ dashboard. A delivered `comment.created` event carries the comment `id`; reply to it with
91
+ `dispatch_comment({ reply_to: <id> })`.
92
+
93
+ Resolve your own review comment once you have addressed it:
94
+
95
+ ```ts
96
+ dispatch_resolve_comment({ comment })
97
+ ```
98
+
99
+ `comment` is the comment id, or a `dispatch://KEY/comment/<id>` or `dispatch://PROJECT/artifact/<slug>/comment/<id>` reference (an
100
+ 8+ character id prefix is resolved against the owner's comments). It returns the owner details plus `comment`, and takes no reason —
101
+ 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
102
+ resolve only threads you opened or were asked to close; reopening a resolved thread is human-only (from the dashboard), though your
103
+ reply to it reopens it - except a decided suggestion's, which your reply joins and leaves accepted or rejected. A reply to a pending
104
+ suggestion whose text an edit removed lands as well. Asks are closed with `dispatch_resolve_ask` instead.
105
+
106
+ An exact replacement for document text is a suggestion (`dispatch_suggest`), never a comment; a
107
+ comment is for a question or a note the human answers in words. A human accepts a suggestion with
108
+ one click and cannot accept a comment, so propose the replacement instead of describing it:
109
+
110
+ ```ts
111
+ dispatch_suggest({ issue?, project?, artifact, ref?, quote, replace_with, body?, occurrence? })
112
+ ```
113
+
114
+ It returns issue or project-document owner details plus `comment`. A human accepts or rejects a suggestion.
115
+ Errors: `TARGET_AMBIGUOUS` (add `occurrence`), `TARGET_NOT_FOUND` (re-read first), `INVALID_ANCHOR`/`ANCHOR_MISSING`/`ANCHOR_ORPHANED`
116
+ (bad, unwritten, or stale quote), `INVALID_MARKDOWN`/`DOC_SCHEMA` (malformed content), `CAP_EXCEEDED`, `ISSUE_CLOSED`.
117
+ Suggest only what the quoted block can hold. The accept, not the suggestion, checks that: an
118
+ accept whose `replace_with` would break an ask block that was readable before it is refused with
119
+ `INVALID_ASK_BLOCK` - a question given a code block, text after an ask's options, a second option
120
+ list, or an emptied question; one that names an id the document holds outside the text it
121
+ replaces, an ask under a held id included, with `INVALID_MARKDOWN`; and one no part of the
122
+ document can hold where it sits (a code block over a table cell's whole text), or whose quote runs
123
+ 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
124
+ stays open until someone rejects or replaces it.
125
+
126
+ ## Artifacts
127
+
128
+ Attach an image, diagram, or local file with:
129
+
130
+ ```ts
131
+ dispatch_artifact({ issue?, project?, name, path, summary? })
132
+ ```
133
+
134
+ Or, when the text is already in the call, post a Markdown document directly:
135
+
136
+ ```ts
137
+ dispatch_artifact({ issue?, project?, name: "load-test-results.md", content: "# Load test\n..." })
138
+ ```
139
+
140
+ Exactly one of `issue` and `project` is required. A project upload creates an unlinked project document; it must not include `artifact`.
141
+ Exactly one of `path` and `content` is required. It returns issue or project-document owner details plus `artifact` and `version`.
142
+ Uploading the same `name` creates its next version — so uploading `spec.md` **replaces the issue's own specification**
143
+ 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
144
+ 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
145
+ slug also arrives on `artifact.created` events. Dispatch suffixes a slug two documents would share, so one document's
146
+ filename can be another's slug (`plan v2` takes `plan-v2`, then a document named `plan-v2` takes `plan-v2-2`): a bare
147
+ `artifact` that names both is refused with each one's id, while a `dispatch://` reference's document part is always the slug.
148
+
149
+ A picture meant to be seen inline in a message, comment or ask is not uploaded with `dispatch_artifact`: pass its local
150
+ path in that tool's `images` (PNG, JPEG, GIF or WebP by its bytes, at most 25 MiB each). Each is uploaded to the issue (a
151
+ project document's project; for a reply to a direct message, your own conversation) and appended after a blank line as
152
+ one `![<file name>](dispatch://<owner>/artifact/<slug>@vN)` line, about 60 characters that count toward the body or
153
+ question cap; a call the lines would carry over the cap is refused with the count before anything uploads, and again,
154
+ with nothing posted, in the rare case where Dispatch gives an upload a longer address than that (a later version of a
155
+ name the owner already holds, or a slug another file took first). The `@vN` pins the version the upload made (the same
156
+ file name again makes the next version). A plain `dispatch://KEY/artifact/<slug>` reference to an image stays a link
157
+ with a thumbnail.
158
+
159
+ `dispatch_read` hands you the pictures the messages, asks and comments it shows embed, newest first, at most 8 and
160
+ 10 MiB per read, and its `Pictures:` section names each one shown, in order, and the rest by reference for
161
+ `dispatch_doc_read`, which shows one picture (at most 3,750,000 bytes, the 5 MB of base64 a model provider takes; a
162
+ larger one or an SVG is described). A picture in a
163
+ conversation on the Agents page belongs to that conversation, `dispatch://agent/<session id>/artifact/<slug>@vN`, and
164
+ `dispatch_doc_read` reads it by that reference alone. On Oh My Pi an Inbox delivery carries its pictures the same way; in
165
+ Claude Code it stays text with the reference.
166
+
167
+ A session is shown each picture once. A later read or delivery that embeds a picture you were already shown names it in
168
+ `Pictures:` as `shown earlier this session` and sends no bytes, since every request carries the session's whole history
169
+ and a model provider refuses one over 32 MB; `dispatch_doc_read` always shows the picture, so ask it for one you need
170
+ again, after compaction too.
171
+
172
+ Documents are CommonMark. A bare `<https://example.com|text>` is a CommonMark autolink and is normalised: the angle brackets are
173
+ dropped and the URL keeps `|text`. A backslash-escaped `\<https://example.com|text>` displays as `<https://example.com|text>` in the
174
+ document but comes back re-escaped (`\<`) from `dispatch_doc_read`. A Slack mrkdwn draft, or any other payload that is not Markdown,
175
+ still belongs inside a fenced code block, where it survives verbatim both ways.
176
+
177
+ A table cell ends at every `|` not written `\|`, inside inline code and links too, so write
178
+ `` `x: Promise<void> \| undefined` ``, never `` `x: Promise<void> | undefined` ``, in a cell. A row
179
+ holding text in a cell past its table's width is refused rather than stored without it, naming the
180
+ row: write a `|` inside a cell as `\|`, or, where the row really has more cells, give the header and
181
+ delimiter rows as many. Blank cells past the width are dropped, on every path. A spec, an upload or
182
+ a version answers `INVALID_MARKDOWN`, an insert of blocks `INVALID_OP` on `markdown`, and an insert
183
+ of bare table rows `TABLE_WIDTH`, which names the cell counts only.
184
+
185
+ ## Approval requests
186
+
187
+ ```
188
+ dispatch_request_approval({ issue?, project?, artifact?, summary })
189
+ ```
190
+
191
+ The call opens an approval ask with the options `Approve` and `Request changes`, its question
192
+ "Approve spec.md (version N)?" followed by `summary`, where N is the document's latest version. It
193
+ is refused, with nothing sent, while that version holds a decision block open, and the refusal
194
+ names each block and its ask. An answer or a `dispatch_resolve_ask` closes the ask at once but
195
+ reaches a version only when the document settles, about two seconds later, or with your next
196
+ `dispatch_doc_edit`: fold the answer into the text (or, for a waiver, write the human's decision
197
+ in) before you request. A block written in the last few seconds counts as open before Dispatch has
198
+ opened its ask. A document holds one open request. A new version moves it to that version, keeping
199
+ its thread and summary, and leaves it waiting on you, as a human's reply in its thread does; a move
200
+ your own edit made sends you no event, and `dispatch_read` and `dispatch_doc_read` show it as
201
+ `Approval: awaiting, waiting on agent`. Call again when "Approval of a spec" in `skill://dispatch`
202
+ allows: that hands the same request back to the human, reworded first when `summary` is new. Your
203
+ own reply in its thread hands it back too, with no call and the summary it already holds, unless
204
+ you post it with `reply_to_ask` and `turn: "agent"` (`reply_to` carries no turn, so its reply takes
205
+ the default, `human`) or a new version has moved the request since your last
206
+ `dispatch_request_approval`. While it waits on the human, a call with the same `summary` changes
207
+ nothing, and one with a different `summary` is refused, since it would rewrite the card they are
208
+ reading. The answer reaches you as `artifact.approved` or `artifact.changes_requested` with the
209
+ pinned `version` and closes the request, so the next call opens a new one; `changes_requested`
210
+ carries the reason, which is your next piece of work. Those reads show the document's approval
211
+ state; `stale` means it was approved and then edited.
212
+
213
+ ## A document that is reloading
214
+
215
+ These calls can answer `DOC_SERVICE_UNAVAILABLE` (HTTP 503), because each writes a document inside its
216
+ transaction: `dispatch_doc_edit`; `dispatch_ask` and `dispatch_comment` on a quote; a `dispatch_comment` reply
217
+ in a thread whose first comment is anchored; `dispatch_suggest`; `dispatch_resolve_comment` on an anchored
218
+ comment; `dispatch_artifact` replacing a document that already exists; and, on an ask that lives in a `:::ask`
219
+ block, `dispatch_edit_ask` and `dispatch_resolve_ask`, which write that block. Creating an issue with a spec,
220
+ uploading a new document, `dispatch_request_approval` and `dispatch_message` never answer it, and neither do
221
+ `dispatch_edit_ask` and `dispatch_resolve_ask` on an ask that has no block. It means that document's live room
222
+ failed and is reloading from its durable copy, so the server refused rather than wait for it; your call wrote
223
+ nothing and the document is intact. Nothing retries it for you: the Dispatch client hands a 503 straight back.
224
+ Wait a few seconds and make the same call again. A second refusal in a row is worth telling your human about,
225
+ with the document's reference.
226
+
227
+ `dispatch_doc_read` can answer it too, though it writes nothing: a read never opens a live room, and waits out
228
+ a room that is reloading, so it is refused only when the document's durable copy cannot be read or decoded.
229
+ Retry it the same way.
230
+