@sjawhar/opencode-legion-envoy 8.16.0 → 8.17.1

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.
@@ -1,53 +1,21 @@
1
1
  # Authentication and the HTTP API
2
2
 
3
- `skill://dispatch` sends you here when you set up a Dispatch token, or need a route the `dispatch`
4
- commands do not cover.
3
+ Read this to set up a Dispatch token, or to call a route no `dispatch` command covers.
5
4
 
6
5
  ## Agent authentication
7
6
 
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` or `dispatch.token` in the `envoy.json` the agent's
10
- session reads. Under an Oh My Pi profile other than `default`, a Claude Code `CLAUDE_CONFIG_DIR` or
11
- an OpenCode `OPENCODE_CONFIG_DIR`, that is the `envoy.json` in the session's own directory; any
12
- other session reads the file `ENVOY_CONFIG_FILE` names when it is set, else
13
- `~/.config/legion/envoy.json` (under `$XDG_CONFIG_HOME` when set). The plugin hands the path it
14
- chose to the session's commands in `ENVOY_CONFIG_FILE` beside `DISPATCH_HOST`, and the `dispatch`
15
- command reads the file `ENVOY_CONFIG_FILE` names whenever `DISPATCH_HOST` is set, else its host
16
- plugin's rule; in a Claude Code Bash that lacks both, as a subagent's can,
17
- `CLAUDE_CODE_CHILD_SESSION=1` makes the host `claude`, so the command reads `CLAUDE_CONFIG_DIR`'s
18
- file. A `dispatch.token` written `!<command>` is a command whose output is the token. A checkout's
19
- `.envoy/envoy.json` may set `dispatch.serverUrl` only with the `dispatch.token` beside it, and
20
- `dispatch.token` only with its own `dispatch.serverUrl` beside it; an `envoy` section there is not
21
- read. The file's token goes only to its own `dispatch.serverUrl`: a
22
- `DISPATCH_URL` naming another server needs `DISPATCH_TOKEN` or `DISPATCH_TOKEN_FILE`. The server
23
- records the minting human as the owner of that session's writes.
24
- `DISPATCH_AGENT_TOKEN` is the shared devbox fallback; do not configure it for an individual agent.
25
-
26
- The deployed Dispatch server's browser origin is configured separately with
27
- `DISPATCH_SERVER_URL` in the deployment `compose/.env`. Do not change an
28
- agent's `envoy.json` to set the sign-in callback origin: the value must be the
29
- exact URL humans type in their browser, and `<DISPATCH_SERVER_URL>/auth/callback`
30
- is the callback registered on Dispatch's sign-in client (`DISPATCH_SIGNIN_CLIENT_ID`):
31
- the sign-in pool's app client, or the Google web client.
32
-
33
- ### Finding a route
34
-
35
- The `dispatch` commands cover the everyday surface. For anything else, ask the server: `GET /api/v1` (no
36
- credential) returns every route as `{method, path, auth, description}` sorted by path — `auth`
37
- is `public`, `any` (a human or a bearer), `human` (a bearer gets `403 HUMAN_ONLY`), or `bearer`.
38
- A path Dispatch does not serve under `/api` or `/v1` answers
39
- `404 {"code":"NOT_FOUND","error":"no route for GET /v1/issues","hint":"GET /api/v1 lists every
40
- route"}`; when you see that, you typed the path wrong — read the index rather than guessing. Every
41
- `/api/v1` error body carries a `code`; branch on the code, never on the text.
42
-
43
- `GET /api/v1/issues` answers every matching issue as an array, or one page when you name `limit`
44
- (1–250) or `offset` (alone it pages 50): `{issues, total, limit, offset}`, where `total` counts
45
- every issue your filters match. Walking the pages with the next `offset` is exact only while the
46
- listing does not change: an issue that enters or leaves what the filters match, or whose status or
47
- rank changes, between two reads shifts rows across a page boundary, and one issue comes back twice
48
- and another never, even when you stop at `total`. The unpaged array is the only exact set one read
49
- gives. `cursor` is `400 INVALID_QUERY`. `dispatch issues` pages for you.
50
- The event logs page with `after` or `before` and `limit`, and `GET /api/v1/search` takes a `limit`
51
- of at most 50 and has no next page. Every other route answers without paging and ignores a paging
52
- parameter.
53
-
7
+ - Use a personal token. A human mints it in Dispatch **Settings → Agent tokens**. Supply it as `DISPATCH_TOKEN` or as `dispatch.token` in the session's `envoy.json`. The server records that human as the owner of your writes.
8
+ - Which `envoy.json`: the session's own directory under an Oh My Pi profile other than `default`, a Claude Code `CLAUDE_CONFIG_DIR` or an OpenCode `OPENCODE_CONFIG_DIR`. Otherwise the file `ENVOY_CONFIG_FILE` names, else `~/.config/legion/envoy.json` (under `$XDG_CONFIG_HOME` when set).
9
+ - The plugin passes its choice in `ENVOY_CONFIG_FILE` with `DISPATCH_HOST`. In a Claude Code Bash that lacks both, `CLAUDE_CODE_CHILD_SESSION=1` makes the command read `CLAUDE_CONFIG_DIR`'s file. A Legion pane reads the `DISPATCH_TOKEN_FILE` path the daemon sets.
10
+ - A `dispatch.token` written `!<command>` is a command that prints the token.
11
+ - A checkout's `.envoy/envoy.json` sets `dispatch.serverUrl` and `dispatch.token` only together. Its `envoy` section is not read. A file's token goes only to its own server: a `DISPATCH_URL` for another server needs `DISPATCH_TOKEN` or `DISPATCH_TOKEN_FILE`.
12
+ - Never configure `DISPATCH_AGENT_TOKEN` (the shared devbox fallback) for one agent.
13
+ - Never set the sign-in callback origin in an agent's `envoy.json`. It is `DISPATCH_SERVER_URL` in the deployment `compose/.env`: the exact URL humans type. `<DISPATCH_SERVER_URL>/auth/callback` is registered on the sign-in client (`DISPATCH_SIGNIN_CLIENT_ID`).
14
+
15
+ ## Finding a route
16
+
17
+ - `GET /api/v1` (no credential) lists every route as `{method, path, auth, description}`. `auth` is `public`, `any`, `human` (a bearer gets `403 HUMAN_ONLY`) or `bearer`.
18
+ - `404 NOT_FOUND` with "GET /api/v1 lists every route" means a wrong path. Read the index, do not guess.
19
+ - Branch on an error's `code`, never its text.
20
+ - `GET /api/v1/issues` returns every match unpaged, or one page with `limit` (1–250) or `offset`: `{issues, total, limit, offset}`. Only the unpaged array is exact: a page walk can repeat or skip an issue that changes. `cursor` is `400 INVALID_QUERY`. `dispatch issues` pages for you.
21
+ - Event logs page with `after` or `before` and `limit`. `GET /api/v1/search` takes `limit` up to 50 with no next page. Other routes do not page.
@@ -1,22 +1,18 @@
1
1
  # Asks after they open: who answers, editing, resolving, replying, and following
2
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`.
3
+ Read this once an ask is open. Whether to ask, and how to write it, is "Before you ask" in `skill://dispatch`.
7
4
 
8
5
  ## Who answers an ask
9
6
 
10
- An ask goes to the issue's assignee: their Inbox opens on the asks on the issues they hold, so a question on an issue someone is assigned is in front of that person. An ask on an issue nobody is assigned is in nobody's default Inbox list and in nobody's `Needs you` count; it is reached by adding `Unassigned` to the Inbox's assignee filter, or from the issue's own page. Setting the issue's assignee is what puts a question in front of a person. Find out who Dispatch takes you for with `dispatch whoami`.
11
- It prints whom the session acts for: the lowercase email of the person whose personal token you run under, or, under the shared token or a verified service token, that it has no owner — naming the service as `<namespace>/<name>` for a service token (`system:serviceaccount:<namespace>:<name>`), which is also how 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.
7
+ - An ask goes to the issue's assignee's Inbox. On an unassigned issue it is in nobody's default Inbox or `Needs you` count.
8
+ - `dispatch whoami` prints whom you act for: your token owner's email, or no owner (a service token shows as `<namespace>/<name>`, as your writes do). An issue you create goes to your owner, else inherits its parent's assignee, else stays unassigned.
9
+ - On an unassigned issue where the answer matters, assign it to your owner, or name in the question who should answer. Assign with `PATCH /api/v1/issues/{key}` and `{"assignee": "<email>"}`. An email nobody signed in with gets `ASSIGNEE_NOT_ALLOWED`.
10
+ - Never reassign an issue a human holds to get a faster answer. That is the human's call.
12
11
 
13
12
  ## Handing a human a to-do, editing, resolving, and replying
14
13
 
15
- A to-do handed to a human still follows the question and options contract that `skill://dispatch`'s
16
- "Writing for the human" states. For a human-only action, what it changes and risks constrains the
17
- answer. There is no fixed vocabulary and the server treats no label specially.
18
- If an outcome needs a reason, say so in that option's description, and the human's free-text answer
19
- carries it:
14
+ - A to-do follows the question-and-options contract of "Writing for the human". For a human-only action, what it changes and risks constrains the answer. No label is special. If an outcome needs a reason, say so in its description.
15
+
20
16
  ```bash
21
17
  dispatch ask --issue DSP-42 \
22
18
  --question 'The fix is merged but cannot run in production: the signing certificate it needs expired, and only its owner can renew it in the registrar console. Renewing replaces the certificate every client checks. How should we proceed? Recommendation: renew it now because the fix cannot run without it.' \
@@ -24,78 +20,23 @@ dispatch ask --issue DSP-42 \
24
20
  --option 'Leave it expired: Changes nothing now but leaves the fix unable to run.'
25
21
  ```
26
22
 
27
- Correct or refine an open ask in place instead of opening a second question, with
28
- `dispatch edit-ask --ask <id>` and any of `--question`, `--option` (repeated), `--multiple` and `--urgency`.
29
- At least one of them is required. Use this only while the same decision remains open: it keeps the prior text in the event
30
- log and invalidates any answer draft against the prior `edited_at` revision, so the human sees the new wording and explicitly reconfirms.
31
- An answered or resolved ask cannot be edited. If the decision is moot or superseded, retract the old ask and open a new one.
32
- A human can change an answer they gave. The change arrives as another `ask.answered` frame whose
33
- `previous_answer` names the answer it replaced, through the same route the first answer took, and
34
- `dispatch read --ref dispatch://KEY/ask/<id>` lists every earlier answer. Act on the newest answer.
35
-
36
- 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
37
- block along with the row, so the edit stands and the document reads the same. It changes only the fields you name: pass `--urgency`
38
- alone and the question's own wording, formatting, links and comment anchors are untouched. Pass `--question` or `--option` and that part
39
- 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
40
- writes a new version, which on a spec awaiting approval closes the design gate until the new version is approved. Editing the block
41
- with `dispatch doc-edit` works too and is the way to change anything else about it, including adding formatting to a question.
42
- Re-sending a field unchanged rewrites nothing, so retrying the whole ask is safe.
43
- Text the block cannot carry back unchanged is refused outright, naming the field and writing nothing - an option label containing
44
- `": "`, the separator between a label and its description, is one example of text that cannot survive the round trip. Blank lines separate paragraphs;
45
- a single newline is kept as a line break.
23
+ - Correct an open ask in place with `dispatch edit-ask`, not a second ask, while the same decision stays open. The human then reconfirms. You cannot edit an answered or resolved ask. If the decision changed, retract it and open a new one.
24
+ - `edit-ask` on a block ask writes the block too, so it mints a version and closes a spec's design gate until approved. Change anything else about the block with `dispatch doc-edit`. A retry with unchanged fields is safe.
25
+ - A human can change an answer. A new `ask.answered` carries `previous_answer`. Act on the newest.
26
+ - Retract a moot or superseded ask, or resolve one whose answer you found:
46
27
 
47
- An ask stays open until a human answers, unless its question no longer needs that answer. Retract a moot or superseded question, or
48
- self-resolve one after finding the answer:
49
28
  ```bash
50
29
  dispatch resolve-ask --ask <id> --kind retracted --reason 'A newer ask supersedes this question.'
51
30
  ```
52
- Use `retracted` when the question is obsolete and `resolved` when you found the answer. Include the reason because the question remains
53
- in its Conversation card and reply thread; a reason beginning `removed from the document in version` is refused, because that is how a
54
- retraction the document's own settlement wrote is recognised. Resolving a block ask records it in the block too, so it stays resolved
55
- however the document moves afterwards. `dispatch doc-edit` refuses deleting the block of an open ask; a block removed another way, by a
56
- 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
57
- 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
58
- `dispatch comment` (mutually exclusive with `--reply-to`).
59
- A review comment you opened has its own closer, `dispatch resolve-comment` — see
60
- [Comments and suggestions](skill://dispatch/references/documents.md).
61
-
62
- A human answers or asks back from the same field; a question-shaped free-text answer is offered as a clarification first. A human
63
- reply while your ask is still open (the delivered `comment.created` carries `ask_state: open`) is a request for clarification, not
64
- an answer: the human did not understand the question or needs more before choosing. The ask now waits on you in their Inbox. Answer
65
- in the same thread with `dispatch comment --reply-to-ask <id>`, or reword the question itself with `dispatch edit-ask` when the wording
66
- was the problem; either puts the ask back in front of them. Do not open a second ask.
67
31
 
68
- 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
69
- (`--turn` omitted, or `--turn human`) hands the turn to the human: the ask returns to their `Waiting on you`. When you are not done
70
- yet — "dispatched two auditors, back with results", "checking the release branch, back shortly", any working-on-it note — reply with
71
- `--turn agent`: the note lands in the thread, the ask stays under `Waiting on agents`, and the human is not told to act. Use
72
- `--turn agent` for every progress note and `--turn human` (the default) only when you need them. A human's reply always hands the
73
- turn to you. The output names the state (`ask now waiting on agent` / `human`), the delivered `comment.created` carries it as
74
- `ask_waiting_on`, and every ask read carries it as `waiting_on`.
32
+ - Use `retracted` for obsolete, `resolved` for found. Always give a reason: the question stays in its card. Resolution never records a human decision. An answered ask cannot be resolved.
33
+ - `doc-edit` refuses to delete an open ask's block. A block deleted another way (an upload, the browser) closes its ask. Restoring it reopens the ask.
34
+ - Reply under an ask with `dispatch comment --reply-to-ask <id>`. Close your review comment with `dispatch resolve-comment` ([Documents](skill://dispatch/references/documents.md#comments-and-suggestions)).
35
+ - A human reply while the ask is open (`ask_state: open`) is a request for clarification, not an answer. Answer in the thread with `--reply-to-ask`, or reword with `edit-ask`. Do not open a second ask.
36
+ - Each reply sets whose turn it is. Your default reply (`--turn human`) puts the ask in the human's `Waiting on you`. For any progress or working-on-it note, use `--turn agent`. A human reply hands the turn to you. Reads show it as `waiting_on`.
75
37
 
76
38
  ## Following
77
39
 
78
- An ask has followers: every session that wrote to it — the session that opened it and every session that replied with
79
- `dispatch comment --reply-to-ask` — plus any session a human adds from the ask card. The ask's answer, edits, resolution, and
80
- 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
81
- route. The output says so (`You follow this ask: its answer and replies reach you directly.`) once per ask.
82
- Leave a thread you no longer need, or rejoin one, with:
83
-
84
- ```bash
85
- dispatch follow --ask <id> --action unfollow # or --action follow
86
- ```
87
-
88
- `--ask` is the full ask id or a `dispatch://KEY/ask/<id>` reference; `dispatch follow`, `dispatch edit-ask`, and
89
- `dispatch resolve-ask` also take an 8+ hex prefix that is unique among your own open asks, and refuse anything shorter by naming
90
- those asks. A human may also remove you from the ask card; either way you are
91
- told with an `ask.follower_removed` notice, and a human adding you arrives as `ask.follower_added`.
92
-
93
- No write subscribes you to an issue or document. Following covers your own asks and the threads you joined; a person's reply to
94
- a comment you started, and a person's new comment on a document you wrote or have a version crediting you, reach you directly as
95
- well; everything else on the owner — other sessions' asks, comments, messages, status changes — reaches you only if you subscribe
96
- to the owner topic yourself.
97
- Every write result names that line: `envoy_subscribe notifications.dispatch.issue.<KEY>.>` for an issue,
98
- `envoy_subscribe notifications.dispatch.document.<PROJECT>.<SLUG>.>` for a project document. The owner topic carries every Dispatch
99
- event; `notify` only controls agent wake and routed delivery. A human may unsubscribe you from the issue or document header; you
100
- are told with a `subscription.removed` notice when that happens.
101
-
40
+ - You follow an ask you opened or replied to, or one a human added you to. Its answer, edits, resolution and replies reach your agent topic directly.
41
+ - Leave or rejoin: `dispatch follow --ask <id> --action unfollow` (or `follow`). `--ask` takes a full id, a `dispatch://KEY/ask/<id>`, or an 8+ hex prefix unique among your open asks. Removal arrives as `ask.follower_removed`, an add as `ask.follower_added`.
42
+ - No write subscribes you to an owner. Replies to your comments and new comments on your documents also reach you. For everything else, subscribe: `envoy_subscribe notifications.dispatch.issue.<KEY>.>` or `envoy_subscribe notifications.dispatch.document.<PROJECT>.<SLUG>.>`. The topic carries every event. `notify` controls only wake and routed delivery. A human can unsubscribe you (`subscription.removed`).
@@ -1,208 +1,59 @@
1
1
  # Editing a document
2
2
 
3
- Every document Dispatch holds — 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 command's shape, how to target the text you mean, what each operation costs a block, and how to
6
- reject a stale edit.
3
+ Edit every Dispatch document (an issue's spec, a project document) in place with `dispatch doc-edit`. Never upload it again. `dispatch doc-edit --help` has the full rules for each operation: markers, task boxes, hard breaks, anchors and preconditions.
7
4
 
8
5
  ```bash
9
6
  dispatch doc-edit --issue <KEY> --artifact spec --summary '<names the version>' --ops-json-file - <<'EOF'
10
7
  [{"op": "replace", "find": "old sentence", "with": "new sentence"}]
11
8
  EOF
12
9
  ```
13
- `--project` replaces `--issue` for a project document, and `--precondition-json` guards against a
14
- stale edit (below). It prints whether the edit applied, the version it wrote, which operations
15
- changed nothing (`unchanged_ops`) or were lost to a concurrent change (`lost_ops`), and the document
16
- token this edit produced, as a `Document token: <token>` line. The ops are a JSON array of this exact `EditOp` shape:
17
10
 
18
- ```ts
19
- type EditOp = {
20
- op: "replace" | "delete" | "insert" | "retype" | "move" | "delete_row" | "delete_column";
21
- find?: string;
22
- with?: string;
23
- occurrence?: number;
24
- markdown?: string;
25
- after?: string;
26
- before?: string;
27
- block?: string;
28
- index?: number;
29
- type?: string;
30
- attributes?: Record<string, unknown>;
31
- };
32
- ```
33
-
34
- An operation takes only these keys. A key it does not declare is refused before the command sends
35
- anything, naming the operation and its keys, because every key but `op` is optional: a
36
- misspelled `with` would otherwise be dropped and the `replace` would delete the text you meant to
37
- rewrite.
11
+ - Ops: `replace`, `delete`, `insert`, `retype`, `move`, `delete_row`, `delete_column`. Keys: `op`, `find`, `with`, `occurrence`, `markdown`, `after`, `before`, `block`, `index`, `type`, `attributes`. An unknown key is refused before sending. A misspelled `with` would turn the `replace` into a delete.
12
+ - The output names the version, `unchanged_ops`, `lost_ops`, and a `Document token:`.
13
+ - Use `--summary` to name a version that records a decision.
38
14
 
39
- Target `replace`, `delete`, and quote insert anchors by a block's text as rendered: write inline
40
- code without backticks, bold without asterisks, and link text without link syntax. A table-cell
41
- anchor is its cell text. Quote code-block contents without their Markdown fences. A quote must stay
42
- within one textblock; split changes that span separate blocks into separate operations.
15
+ ## Targeting text
43
16
 
44
- `replace` requires `find` and `with`; `delete` requires `find` or `block`; `insert` requires `markdown` and exactly one of `after` or
45
- `before`; `move` requires `block` and exactly one of `after` or `before`; and `delete_row` / `delete_column` each require a table
46
- `block` plus a zero-based `index`. An insert or move anchor is a quote, `"start"`, `"end"`, `"heading:Title"`, or `"block:<id>"`.
47
- Ordinary inserts create a sibling block before or after the quote, heading, or block's enclosing document block, and a move lands the
48
- block at that same boundary; `"start"` and `"end"` select the document edges. At a table-cell quote, a body-row fragment (no header or
49
- delimiter rows) extends that table before or after the matched row instead, decided in three steps. A fragment whose every line yields
50
- 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
51
- 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
52
- 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 |`)
53
- 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
54
- 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
55
- delimiter row three hyphens or more a cell, becomes a second table, while one with any delimiter cell of one or two hyphens (`| - |`,
56
- `| --- | - |`) passes the first step, so its header and delimiter rows are inserted as rows of the table, or refused as `TABLE_WIDTH`
57
- where it is wider than the table. Deleting
58
- a cell's quoted text removes only that text. `delete_row` / `delete_column` instead mutate their named table in place, keeping the
59
- 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
60
- 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
61
- `INVALID_OP` on `index`, naming the supplied value and the table's actual dimensions before making any change. Markdown parsing
62
- canonicalizes short ragged rows by padding missing cells, so column deletion preserves every non-selected cell in the canonical table.
63
- `dispatch doc-read --issue <KEY> --blocks` (or `--project` and `--artifact`) lists every block of the live document in place of its
64
- markdown, one line each, in document order and indented under the block holding it: its id, its type, its token, how many comments
65
- and asks anchor to it (a table counts its cells' anchors) and its opening words. Under a table it lists each row by its index, what
66
- `delete_row` takes (0 is the header), and each cell by its row and its column, what `delete_column` takes, with the opening words
67
- of the header drawn above it. A cell spanning columns or rows counts once in its row, so its column is that index and not where
68
- the markdown writes it. A Dispatch that does not list table rows yet says so instead of an index. It takes no `--version`, since
69
- only the live document's ids and tokens address an edit, and an uploaded file has no blocks.
70
- `GET /api/v1/artifacts/<artifact UUID>/blocks` and `GET /api/v1/artifacts/<artifact UUID>/blocks/<block id>` answer the same
71
- facts for a reader outside the command. A row or column
72
- deletion that would remove an open ask or unresolved comment anchor is `INVALID_OP` on `index`, naming the axis and anchor ids;
73
- answered asks and resolved comments are history and do not block it. A `find` or quote anchor tolerates inline Markdown
74
- (`**bold**`, `` `code` ``) and a leading `# ` selects a heading by its text; a miss names the quote and the three nearest blocks so
75
- the next quote lands, and a `find` cut before a closing `**` or `` ` `` is refused as an unbalanced inline mark rather than reported
76
- 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
77
- the nearest headings. `replace` is inline: `with` is the new text of the matched span inside its block, so a marker of a *different*
78
- kind from the block's own (`4. Design` written into a heading, `# Title` into a paragraph) stays literal text and never turns the
79
- 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
80
- is rejected (`INVALID_OP` on `with`) — including prose that merely looks like a marker (`1999. was a year` into an ordered item),
81
- 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`
82
- plus `delete` to change the block's kind, level or number. The one exception is a heading rename whose `find` carried a heading
83
- marker: `replace(find="## Old", with="## New")` gives `## New`. A different level in `with` applies only when `find` named the
84
- heading's actual level — `find="## Old"`, `with="### New"` retitles and makes it an h3 — because `# ` is the level-blind selector,
85
- so `find="# Old"` renames the text and keeps whatever level it selected. A task item (`- [ ]` / `- [x]`) is ticked the same way:
86
- when `find` matches from the start of the item's text and `with` opens with `[x] ` or `[ ] `, the opening sets the item's
87
- checkbox and the rest is its new text — `replace(find="Write it", with="[x] Write it")` ticks it, keeping its block id — since
88
- the box is the item's attribute, not text, and written as text it would read `- [ ] [x] …`. The same opening over a plain list
89
- item or a paragraph stays literal text, as any marker of another kind does. `with` that forms more than one
90
- paragraph is rejected (`INVALID_OP` on `with`) — see the recipe for a multi-paragraph rewrite below; so is any non-empty `with` that
91
- renders to no text, which a line indented four spaces or a tab does (markdown reads that as a code block), as does whitespace
92
- alone. An empty `with` deletes the matched text on purpose; a list item, quote, typed block or footnote definition left
93
- holding only the emptied paragraph keeps it, and reads back holding it. Emptying a task item's first paragraph while
94
- another block follows it in the item is `INVALID_OP`, since the browser editor reads no such item as a task. Inside a code
95
- block none of this applies: `with` is the code's literal text, written as sent, whitespace, markdown syntax and
96
- references included, except that line breaks at the end of the code's text, and a line holding only whitespace in a
97
- list item's code, do not survive the next read. A line of colons in code inside a typed block is kept: Dispatch writes
98
- that typed block's fence longer than any such line the browser editor would end it at. Text a `replace` writes that
99
- would read as block syntax at a line start is stored escaped and reads back as the characters you sent: `---` over a
100
- paragraph is stored `\---`, not a rule, so to add a rule, `insert` it beside the paragraph (`insert` with markdown
101
- `***`). A CR LF or a lone carriage return in any text you write - an insert's markdown, a `with` in text or in code,
102
- a suggestion, an upload or a spec - is stored as a line feed, as the browser editor reads both. Use zero-based
103
- `occurrence` for a
104
- repeated target; re-read a missing or ambiguous target before retrying. Pass `--summary` to name the version when recording a decision.
17
+ - Quote text as rendered: inline code without backticks, bold without asterisks, link text without syntax, a cell by its text, code without fences. One quote stays in one textblock. Split changes that span blocks.
18
+ - A miss names the three nearest blocks. A `heading:` anchor must match the whole heading. Use zero-based `occurrence` for a repeat. Re-read before you retry a missing or ambiguous target.
19
+ - `dispatch doc-read --blocks` lists each live block's id, type, token, anchor count and opening words, and each table row and cell index. Only live ids address an edit.
105
20
 
106
- **Rewriting several paragraphs is one `replace` per paragraph, then a read-back.** `replace` is inline:
107
- each `with` is the new text of one paragraph, and outside a code block a `with` that forms two paragraphs is
108
- refused whatever the text says. Give each paragraph you rewrite its own `replace`, which keeps that paragraph's block id and every
109
- anchor outside the text you rewrite. A comment or ask anchored to the text you rewrite loses its quote but keeps
110
- its pin to the block, so the dashboard still shows it beside that paragraph; a delete (below) loses both. An
111
- anchor that straddles the boundary keeps its mark over the words you left alone, with its quote shortened to
112
- them: rewriting `charlie delta.` under a comment on `bravo charlie` leaves that comment quoting `bravo `. When
113
- the new text has more paragraphs than the old, write them as **one** `insert` whose `markdown` holds them all,
114
- anchored on the last paragraph you rewrote with `after` quoting it exactly as it now reads (the operations in
115
- one batch apply in order): each separate `insert` at the same anchor lands directly after it, so two of them
116
- come out in the reverse of the order you wrote. An insert lands after the top-level block that holds the quote,
117
- so beside a paragraph inside a list item or a typed block it goes after the whole list or block. When it has fewer,
118
- `delete` each leftover paragraph, with its whole text as `find` or its id as `block`. All of that holds while
119
- the new text is paragraphs: `replace` keeps a block's kind, so any block that is not a paragraph — a heading,
120
- list, table, blockquote, rule, code fence or typed block — cannot be replaced into place. Its marker is written
121
- as literal text, except a backtick fence, which becomes an inline code span whose text is everything inside it,
122
- info string and line breaks included (```` ```go\nx := 1``` ```` becomes the code span `go` + a line break +
123
- `x := 1`), and a tilde fence, which stays literal.
21
+ ## Rewriting paragraphs
124
22
 
125
- 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
126
- (`INVALID_OP` on `with`), because the schema carries no block HTML. `<div>x</div>` and `<!-- note -->` open one
127
- 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
128
- 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
129
- heading is inline HTML and is kept as written.
23
+ - **One `replace` per paragraph, then a read-back.** A `with` of two paragraphs is refused. Each `replace` keeps the block id and the anchors outside the text you change. An anchor on changed text loses its quote but keeps its pin.
24
+ - More new paragraphs than old: add the rest as **one** `insert` holding them all, `after` the last rewritten paragraph as it now reads. Two inserts at one anchor come out reversed. An insert lands after the top-level block that holds the quote.
25
+ - Fewer: `delete` each leftover paragraph by its whole text or `block` id.
26
+ - `replace` keeps a block's kind. When the new text adds a non-paragraph block (heading, list, table, quote, rule, fence or typed block) beside paragraphs, `insert` it beside the paragraph you replaced. Only when no paragraph of the new text stays, `delete` the old block, then `insert` the new one, anchored on the block before or after it.
27
+ - An empty `with` deletes the matched text on purpose and leaves an empty block. Read the passage and its neighbours back with `dispatch doc-read`. A grep for your new words is not a check.
28
+ - Dispatch refuses block HTML (`<div>`, `<!-- -->`, a lone `<br>` paragraph) in `with`. Inline HTML stays.
29
+ - To add a rule, `insert` `***`. A `---` in `with` is stored as `\---`.
130
30
 
131
- A hard line break in `with` (two trailing spaces or a backslash before a newline) is refused in a heading or a
132
- table cell (`INVALID_OP` on `with`): both are written on one line, so the break would end the block there. A line
133
- break inside a code span or inline HTML there is refused the same way. Write the text without the break, or
134
- `insert` a new block after this one.
31
+ ## Blocks and ids
135
32
 
136
- When the new text adds a block that is not a paragraph beside paragraphs, `insert` it beside the
137
- paragraph you replaced, which keeps that paragraph's id; only when no paragraph of the new text is left to take
138
- the old block's place is it a `delete` of the old block and then an `insert` of the new one, anchored on the
139
- block before or after it. The delete is what costs the id (below); a typed block keeps its id when the insert
140
- carries it, which works only in that order, because an insert carrying an id the document still holds is refused.
141
- The tools refuse a `delete` or `retype` that would take out an `ask` block whose ask is still open, delete-then-insert included:
142
- 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
143
- batch that moves an open block out of a container and then deletes the container is refused; make them two calls (the check reads
144
- the document as it stood before the batch).
145
- Then read the document back with
146
- `dispatch doc-read` and read the passage and its neighbours, not a grep for the words you added: an empty
147
- `with` deletes the matched text on purpose, so a `replace` whose `with` you meant to fill
148
- empties that paragraph — the block and its id stay, holding nothing — and only a read shows what the document now says.
33
+ - A block loses its id only when deleted: by `delete`, by `delete_row` / `delete_column` (its cells), or by a `move` that empties its container. Its anchors go with it. `replace`, `insert`, `move` and `retype` keep ids.
34
+ - `delete` of a block's whole text deletes the block. A partial match keeps it. A bullet with a nested list hoists the list. A bullet with other content needs `delete {block:"<item id>"}`.
35
+ - Dispatch refuses a `delete` or `retype` that takes out an open ask's block, delete-then-insert included. Reword with `replace`, relocate with `move`, or use `dispatch edit-ask`. Moving an open block out of a container and deleting the container takes two calls.
36
+ - `retype` turns a paragraph or typed block into a typed block in place, keeping its id. Use it when a paragraph is the question that should become a decision.
37
+ - An ask block has two ids. The block id (`:::ask{#<id> …}`) goes bare in `block` for `move` / `delete` (`block:<id>` only in `before` / `after`). The ask id goes to `dispatch open-asks`, `dispatch read` and `--reply-to-ask`. `dispatch://KEY/ask/<block-id>` answers `not found`.
149
38
 
150
- A batch that leaves the document's semantic identity unchanged — including its inline anchor marks, so an edit that only orphans a
151
- comment or ask anchor still mints its version — mints no version, named or not: the response carries
152
- `changed: false` with `unchanged_ops` naming each operation that did nothing, and the output says nothing changed. A `--summary`
153
- does not force a version for such a batch; `POST /api/v1/artifacts/<id>/versions`, which names the current state on purpose, still does.
39
+ ## Tables
154
40
 
155
- A `delete` whose `find` is a block's entire text removes the block itself — the bullet, paragraph, or heading, not just its words — and
156
- 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
157
- 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
158
- (paragraphs, code, tables) is refused with `INVALID_OP` naming `delete {block:"<item id>"}`, which removes the item with its content.
159
- `delete` with `block` removes any block by id (paragraph, heading, list, list item, table, or typed block; an answered `ask` block keeps
160
- 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
161
- 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:
162
- `delete` by text or by id removes the block and any container it empties; `delete_row` / `delete_column` remove their cells' ids,
163
- 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
164
- blockquote or list item removes that emptied container, the list too when no item remains, and each enclosing container that
165
- held nothing else (`> - Only.` loses the blockquote as well as the item and the list). The moved block itself keeps its id,
166
- as do `replace` (an accepted empty `with` and a heading-level change included), `insert` and `retype`; `retype` carries the
167
- paragraph's id onto the typed block it becomes. Block ids are the `#id` a typed block renders
168
- (`:::ask{#5467e5ce-…}`) and, for every block including untyped ones, the ids `dispatch doc-read --blocks` lists. A later operation in the same atomic batch that names a block removed by
169
- an earlier `delete {block}` fails as `INVALID_OP` naming the earlier operation and the parent block that cascaded the removal. A move
170
- whose anchor lies inside the moved block, or a delete that would leave a typed block without the body its content rule requires, is
171
- `INVALID_OP` naming the field and the rule.
41
+ - `delete_row` / `delete_column` take the table `block` and a zero-based `index`. Row 0 is the header. The last body row and a row's last column cannot go. Open asks or unresolved comments on the cells block it.
42
+ - Inserting bare body rows (no header or delimiter) at a cell quote extends that table. A wider row is `TABLE_WIDTH`. A full table with a header pasted there becomes a second table.
172
43
 
173
- `dispatch doc-read --blocks` lists a full-state `token` on every block, including
174
- inline marks. To reject a stale edit, pass `--precondition-json` with exactly one of
175
- `{"document": "<document token>"}` or
176
- `{"blocks": [{"id": "<block id>", "token": "<block token>"}]}`. A document token comes from
177
- `dispatch doc-read` or from the previous `dispatch doc-edit`, whose output carries the token its
178
- own edit produced: a run of guarded edits passes each output's token to the next one and reads the
179
- document once, not once per edit. The server resolves the whole batch before
180
- mutation: a block guard must cover every content block it changes, or Dispatch returns
181
- `400 INVALID_PRECONDITION` without applying anything. Use a document token for insert and move because they
182
- depend on document order. A block token lets other sections change concurrently; a new anchored ask or comment
183
- changes the relevant token. A stale guard returns `409 PRECONDITION_FAILED` with each mismatch and current
184
- token; Dispatch applies no part of that batch. It is the hashline `#TAG` property applied to stable block ids,
185
- not line numbers: canonical Markdown lines shift under concurrent edits and rendering changes, while block ids
186
- survive moves and retyping.
44
+ ## Stale and concurrent edits
187
45
 
188
- A browser editing the same document while your edit is in flight never makes your edit a silent
189
- no-op. The two changes merge, and a human's deletion of the paragraph you are rewriting wins — but
190
- you are told. `409 EDIT_LOST_TO_CONCURRENT_CHANGE` means the whole batch was refused and nothing
191
- was written: re-read the document and decide again, as with `PRECONDITION_FAILED`. A success whose
192
- `lost_ops` names operations means the version was written and the live document already lacks what
193
- those operations wrote, because the deletion landed after the version: re-read before building on
194
- it. `lost_ops: []` is the ordinary outcome, and a result that says it could not confirm the edit
195
- survived means the room is reloading or holds a tree too deep to read — re-read, and a read that
196
- answers `DOC_SCHEMA` says which. An operation that only removes text (`delete`, `delete_row`,
197
- `delete_column`, a `replace` that shortens) is never reported lost: a concurrent deletion cannot
198
- undo a removal.
46
+ - Guard with `--precondition-json`: `{"document": "<token>"}` or `{"blocks": [{"id": "<id>", "token": "<token>"}]}`. Use a document token for `insert` and `move`. A block guard must cover every block the batch changes.
47
+ - Chain guarded edits by passing each output's token to the next. Read once, not once per edit.
48
+ - `409 PRECONDITION_FAILED` or `409 EDIT_LOST_TO_CONCURRENT_CHANGE`: nothing written. Re-read and decide again. Non-empty `lost_ops` on success: a browser deleted that text after the write. Re-read before you build on it. A result that could not check survival: re-read.
49
+ - A batch that changes nothing mints no version, even with `--summary`. `POST /api/v1/artifacts/<id>/versions` names the current state as a version on purpose.
199
50
 
200
- `retype` turns the paragraph or typed block with `block` into the named typed `type` in place. It keeps the
201
- block id, keeps a typed block's body, and uses `attributes` for client-owned typed attributes. Use it when
202
- an existing paragraph is the question that should become a decision.
51
+ ## Edge cases
203
52
 
204
- An ask block has two ids: the block id, shown as `:::ask{#<id> …}` in the rendered document and
205
- taken bare by `move`/`delete` in `block` (the `block:<id>` form is only for `before`/`after`
206
- anchors), and the ask id, which `dispatch open-asks`, the dashboard's ask links (`/asks/<id>`,
207
- `?ask=`), `dispatch read` and `dispatch comment --reply-to-ask` use. They differ;
208
- `dispatch://KEY/ask/<block-id>` answers `not found`.
53
+ - Inside a code block, `with` is the code's literal text. Trailing line breaks, and whitespace-only lines in a list item's code, do not survive a read.
54
+ - A CR LF or lone CR in any text you write is stored as a line feed.
55
+ - Replacing a non-paragraph block writes its marker as text. A backtick fence becomes an inline code span. A tilde fence stays literal.
56
+ - An anchor that straddles the rewritten text keeps its mark on the words you left, with a shorter quote.
57
+ - Emptying a task item's first paragraph while another block follows it is `INVALID_OP`.
58
+ - A later op in one batch that names a block an earlier `delete {block}` took out fails as `INVALID_OP`. So does a move anchored inside the moved block, or a delete that leaves a typed block without its required body.
59
+ - An op that only takes text out is never reported lost.