@sjawhar/opencode-legion-envoy 1.3.0 → 1.4.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.
@@ -13574,6 +13574,14 @@ var ArtifactVersionEventPayloadSchema = object({
13574
13574
  version: object({ number: number2().optional(), summary: string2().nullish() }).optional(),
13575
13575
  diff: string2().optional()
13576
13576
  });
13577
+ var ArtifactReviewEventPayloadSchema = object({
13578
+ artifact_id: string2().optional(),
13579
+ name: string2().optional(),
13580
+ version: number2().int().optional(),
13581
+ actor: object({ kind: string2(), id: string2() }).passthrough().optional(),
13582
+ reason: string2().nullish(),
13583
+ ask_id: string2().nullish()
13584
+ });
13577
13585
  var askEventPayloadFields = {
13578
13586
  id: string2().optional(),
13579
13587
  opened_event_id: number2().int().positive(),
@@ -13705,7 +13713,9 @@ function documentOwnerValidation(requireArtifact, alwaysRequireArtifact = false)
13705
13713
  };
13706
13714
  }
13707
13715
  var SPEC_SECTIONS = [
13716
+ "Summary",
13708
13717
  "Decisions needed",
13718
+ "New since we talked",
13709
13719
  "Acceptance",
13710
13720
  "Requirements",
13711
13721
  "Design",
@@ -13713,7 +13723,7 @@ var SPEC_SECTIONS = [
13713
13723
  "Testing",
13714
13724
  "Rejected"
13715
13725
  ];
13716
- var SPEC_WRITING_GUIDANCE = `When writing a spec, use these sections in order: ${SPEC_SECTIONS.join(", ")}. ` + "Every line is a fact, decision, or risk; use tables over prose; see skills/dispatch Writing a spec.";
13726
+ var SPEC_WRITING_GUIDANCE = `When writing a spec, use these sections in order: ${SPEC_SECTIONS.join(", ")}. ` + "Write for a reader who has not seen the code: plain sentences, every identifier expanded on " + "first use, no coined shorthand; see skills/dispatch Writing for the human and Writing a spec.";
13717
13727
  var ASK_URGENCIES = ["low", "med", "high", "blocking"];
13718
13728
  var DOC_EDIT_OPS = ["replace", "delete", "insert"];
13719
13729
  var dispatchToolSpecs = [
@@ -13855,6 +13865,16 @@ var dispatchToolSpecs = [
13855
13865
  }),
13856
13866
  validation: documentOwnerValidation(true)
13857
13867
  },
13868
+ {
13869
+ name: "dispatch_request_approval",
13870
+ description: "Ask a human to approve a document at its current version - the exception path for a spec " + "that departs from what was settled or proposes children, not a step for every issue. Opens an " + "approval ask (Approve / Request changes) in the human's Inbox; the answer pins a review to the " + "document version and arrives as artifact.approved or artifact.changes_requested. A later edit " + "makes an approval stale; request again for the new version. Idempotent while a request is open. " + OWNER_REFERENCE,
13871
+ arguments: (z) => ({
13872
+ issue: z.string().describe(ISSUE_REFERENCE).optional(),
13873
+ project: z.string().describe("Project key owning the document.").optional(),
13874
+ artifact: z.string().describe("Artifact slug or id; primary document by default for an issue.").optional()
13875
+ }),
13876
+ validation: documentOwnerValidation(true)
13877
+ },
13858
13878
  {
13859
13879
  name: "dispatch_artifact",
13860
13880
  description: "Attach a local file or inline text as an issue artifact or project document. Do not use it to edit a live document; use " + `dispatch_doc_edit instead. Exactly one of path or content is required; artifacts are limited to 25 MiB. ${OWNER_REFERENCE}`,
@@ -14598,6 +14618,9 @@ class DispatchClient {
14598
14618
  async resolveAsk(id, input) {
14599
14619
  return this.#json("POST", ["api", "v1", "asks", id, "resolve"], input);
14600
14620
  }
14621
+ async requestApproval(artifactID, input) {
14622
+ return this.#json("POST", ["api", "v1", "artifacts", artifactID, "approval-requests"], input);
14623
+ }
14601
14624
  async editAsk(id, input) {
14602
14625
  return this.#json("PATCH", ["api", "v1", "asks", id], input);
14603
14626
  }
@@ -15083,13 +15106,31 @@ function toolActor(origin, input) {
15083
15106
  }
15084
15107
  };
15085
15108
  }
15109
+ function approvalLine(artifact) {
15110
+ const approval = artifact.approval;
15111
+ if (approval === undefined || approval.state === "draft")
15112
+ return;
15113
+ switch (approval.state) {
15114
+ case "awaiting":
15115
+ return `Approval: awaiting (requested by ${approval.requested_by?.id ?? "unknown"}, ask ${approval.ask_id ?? "?"})`;
15116
+ case "approved":
15117
+ return `Approval: approved v${approval.version} by ${approval.by?.id ?? "unknown"}`;
15118
+ case "stale":
15119
+ return `Approval: approved v${approval.version} by ${approval.by?.id ?? "unknown"}, edited since (now v${approval.latest_version}) - request approval again`;
15120
+ case "changes_requested":
15121
+ return `Approval: changes requested on v${approval.version} by ${approval.by?.id ?? "unknown"}: ${approval.reason ?? ""}`;
15122
+ }
15123
+ }
15086
15124
  function issueSummary(issue, events, references) {
15087
15125
  const asks = issue.open_asks;
15126
+ const spec = issue.artifacts?.find((artifact) => artifact.primary);
15127
+ const specApproval = spec === undefined ? undefined : approvalLine(spec);
15088
15128
  return [
15089
15129
  `Title: ${issue.title}`,
15090
15130
  `Key: ${issue.key}`,
15091
15131
  `Status: ${issue.status}`,
15092
15132
  `Route: ${issue.route ?? "none"}`,
15133
+ ...specApproval === undefined ? [] : [`Spec ${specApproval.replace(/^Approval/, "approval")}`],
15093
15134
  "Open asks:",
15094
15135
  ...asks.length === 0 ? ["- none"] : asks.map((ask) => `- ${ask.id}: ${ask.question}`),
15095
15136
  "References:",
@@ -15416,16 +15457,42 @@ async function executeDispatchTool(input) {
15416
15457
  const version = optionalNumber(args, "version") ?? ownerArguments.ref?.version;
15417
15458
  const document = await client.docRead(resolved.artifact.id, version);
15418
15459
  const marks = await openArtifactMarks(client, resolved);
15460
+ const approval = approvalLine(resolved.artifact);
15461
+ const trailer = [
15462
+ ...marks.length === 0 ? [] : [`Open anchored asks/comments: ${marks.join(", ")}`],
15463
+ ...approval === undefined ? [] : [approval]
15464
+ ];
15419
15465
  return {
15420
- text: marks.length === 0 ? document.markdown : `${document.markdown}
15466
+ text: trailer.length === 0 ? document.markdown : `${document.markdown}
15421
15467
 
15422
- Open anchored asks/comments: ${marks.join(", ")}`,
15468
+ ${trailer.join(`
15469
+ `)}`,
15423
15470
  details: resolved.owner.kind === "project" ? {
15424
15471
  project: resolved.artifact.project,
15425
15472
  document: `${resolved.artifact.project}/${resolved.artifact.slug}`
15426
15473
  } : { issue: resolved.issue?.key }
15427
15474
  };
15428
15475
  }
15476
+ case "dispatch_request_approval": {
15477
+ const artifactReference = optionalString(args, "artifact") ?? (ownerArguments.ref?.kind === "spec" || ownerArguments.ref?.kind === "artifact" ? ownerArguments.ref.id : undefined);
15478
+ const resolved = await resolveArtifact(client, documentOwner(), artifactReference);
15479
+ const result = await client.requestApproval(resolved.artifact.id, { actor });
15480
+ if (result.ask === null) {
15481
+ return {
15482
+ text: `${resolved.artifact.name} is already approved at version ${result.version} by ${result.approval.by?.id ?? "unknown"}; no new request was opened. An edit after approval makes it stale, so request again only for a new version.`,
15483
+ details: {
15484
+ ...resolved.owner.kind === "project" ? documentResultDetails(resolved.artifact) : { issue: resolved.issue?.key },
15485
+ artifact: resolved.artifact.id,
15486
+ version: result.version
15487
+ }
15488
+ };
15489
+ }
15490
+ const details = await askResultDetails(client, result.ask, resolved);
15491
+ return {
15492
+ text: `Approval requested for ${resolved.artifact.name} at version ${result.version} (ask ${result.ask.id}). The answer arrives as artifact.approved or artifact.changes_requested; an edit after approval makes it stale, so request again for the new version.`,
15493
+ details: { ...details, artifact: resolved.artifact.id, version: result.version }
15494
+ };
15495
+ }
15429
15496
  case "dispatch_artifact": {
15430
15497
  const summary = optionalString(args, "summary");
15431
15498
  const name = stringArg(args, "name");
@@ -15488,7 +15555,8 @@ Open anchored asks/comments: ${marks.join(", ")}`,
15488
15555
  text: [
15489
15556
  `Document: ${resolved.artifact.project} / ${resolved.artifact.name}`,
15490
15557
  `Reference: dispatch://${resolved.artifact.project}/artifact/${resolved.artifact.slug}`,
15491
- `Versions: ${resolved.artifact.versions.length}`
15558
+ `Versions: ${resolved.artifact.versions.length}`,
15559
+ ...approvalLine(resolved.artifact) === undefined ? [] : [approvalLine(resolved.artifact)]
15492
15560
  ].join(`
15493
15561
  `),
15494
15562
  details: {
@@ -15746,12 +15814,20 @@ class EnvoyApiError extends Error {
15746
15814
  function createEnvoyClient(config) {
15747
15815
  const baseUrl = normalizeEnvoyUrl(config.baseUrl);
15748
15816
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
15817
+ const apiToken = process.env["ENVOY_TOKEN"];
15749
15818
  const request = async (path, init) => {
15750
15819
  const url = `${baseUrl}${path}`;
15751
15820
  for (let attempt = 0;attempt < 2; attempt += 1) {
15752
15821
  let response;
15753
15822
  try {
15754
- response = await config.fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
15823
+ const headers = new Headers(init.headers);
15824
+ if (apiToken)
15825
+ headers.set("Authorization", `Bearer ${apiToken}`);
15826
+ response = await config.fetch(url, {
15827
+ ...init,
15828
+ headers,
15829
+ signal: AbortSignal.timeout(timeoutMs)
15830
+ });
15755
15831
  } catch (error) {
15756
15832
  if (attempt === 0) {
15757
15833
  await waitForRetry();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -13,31 +13,49 @@ The server enforces high signal: an ask question is at most 800 characters with
13
13
  most 2,000 characters; an artifact is at most 25 MiB. It refuses over-limit input; it never truncates it. GitHub threads and markers no
14
14
  longer exist.
15
15
 
16
+ ## Writing for the human
17
+
18
+ Sami, 2026-09-12, on what Legion had been producing: "It's completely incomprehensible. It's just
19
+ compressed jargon nonsense. I have no idea what the fuck it's saying." Every spec, ask, comment,
20
+ message, and PR body is read by a person who has not read the code, does not share this session's
21
+ vocabulary, and is often on a phone. Write for that person.
22
+
23
+ - Plain English, full sentences, one idea per sentence. Never repo shorthand or nouns you coined:
24
+ not "fix 8c", "READY-target", "PR B", "spec@v3", "the pair", "the packet" — say what the thing is.
25
+ - Expand every identifier the first time it appears: an issue key gets its title, a PR number its
26
+ title, a file what it is for, a session id who it is. Link a URL rather than pasting a bare id.
27
+ - Frame a request as current state → desired state → proposed change, with at least two options,
28
+ what each costs, and your recommendation with its reason.
29
+ - Before posting, test it: could Sami, reading only this text on his phone, know what he is being
30
+ told or asked? If not, rewrite it. Length is not the problem; density is.
31
+
16
32
  ## Writing a spec
17
33
 
18
- A spec is a decision record for the human who decides and the implementer who builds, not a transcript of your thinking. Use exactly
19
- these document headings in this order.
34
+ A spec has two readers: the human who decides reads the top; the implementer who builds reads the
35
+ rest. Use these headings in this order.
20
36
 
21
37
  | Section | Required content | Form |
22
38
  | --- | --- | --- |
23
- | **Decisions needed** | Only decisions requiring human authority, taste, or risk appetite. Each states one question, two or three options with tradeoffs, and a recommendation. Every item is an anchored `dispatch_ask`. Answered items move into Requirements with provenance, then leave this section. No other section asks the reader anything. Empty means `None.` | One decision per line; anchor each ask to that line. |
24
- | **Acceptance** | Every outcome names its check and user-facing surface. An outcome without a verification method is not acceptance criteria. | Numbered lines; browser scenario, API call, or CLI command. |
25
- | **Requirements** | Provenance is a verbatim human quote or `inferred: <reasoning>`; readers treat inferred requirements as hypotheses. Do not restate the prompt in prose. | `requirement \| provenance` table. |
26
- | **Design** | State the files, components, routes, and data flow that change. | Facts, not narrative; diagrams only for genuine structure. |
27
- | **Errors** | Name the behaviour for every error condition; never specify a silent fallback. | `condition \| behaviour` table. |
28
- | **Testing** | Map every acceptance line to the proof that exercises it. | Suite or scenario. |
29
- | **Rejected** | Record each considered alternative and why it was rejected so it is not proposed again. | One alternative per line. |
39
+ | **Summary** | The problem, what changes for whom, and how we will know it worked in plain words. | Three sentences at most. |
40
+ | **Decisions needed** | Only decisions that need human authority, taste, or risk appetite. Each is one plain question, two or three options with what each costs, and your recommendation with its reason — understandable without opening anything else. Every item is an anchored `dispatch_ask`; an answered item moves into Requirements with its provenance. If there is nothing to decide, write `None: this records what was agreed.` and do not ask for a review. | One decision per line. |
41
+ | **New since we talked** | Every design point the human did not settle in conversation, marked `inferred:` with the reasoning. Empty is fine. | One plain sentence per point. |
42
+ | **Acceptance** | Each outcome names what a user will observe and the check that proves it (browser scenario, API call, or command). An outcome without a check is not acceptance. | Numbered lines. |
43
+ | **Requirements** | What must hold, and where each came from: a quoted human sentence, or `inferred:` plus the reasoning. Readers treat inferred requirements as hypotheses. | `requirement \| where it comes from` table, or prose if the reader follows it more easily. |
44
+ | **Design** | The files, components, routes, and data flow that change. | Prose or tables; a diagram only for real structure. |
45
+ | **Errors** | The behaviour for every error condition. Never a silent fallback. | `condition \| behaviour` table. |
46
+ | **Testing** | Which proof exercises each acceptance line. | One line per acceptance item. |
47
+ | **Rejected** | Each alternative considered and why it was rejected, so it is not proposed again. | One alternative per line. |
30
48
 
31
49
  ### Rules
32
50
 
33
- - Every sentence is a fact, decision, or risk; delete the rest.
34
- - Use tables over prose and keep one idea per line.
35
- - Do not use Overview, Background, Introduction, Summary, or Conclusion sections.
36
- - Do not hedge with “might” or “could consider.”
37
- - Do not use TBD, TODO, or placeholders; an open item is a Decision needed.
51
+ - The spec is the issue's one primary document. Extend it in place — a new version that keeps the
52
+ human's own text never a second "spec" artifact beside it.
53
+ - No hedging ("might", "could consider"). No TBD, TODO, or placeholders: an open item is a
54
+ Decision needed.
38
55
  - Keep each section to one screen; work that exceeds one screen per section is two specs.
39
- - Update the spec in place as decisions land: the spec is the record, comments are the discussion.
40
- - Before sending it: no sections conflict, and every requirement has exactly one reading.
56
+ - Update the spec as decisions land: the spec is the record, comments are the discussion.
57
+ - Before sending it: no sections conflict, every requirement has exactly one reading, and the
58
+ Summary and Decisions pass the phone test above.
41
59
 
42
60
  ## Your owner
43
61
 
@@ -83,9 +101,16 @@ dispatch_ask({
83
101
  })
84
102
  ```
85
103
  It returns `details` `{ issue, topic, ask }` for an issue or `{ project, artifact, document, topic, ask }` for a project document.
86
- Options are buttons: never enumerate choices in prose. Put the recommendation in `question`, and put each selectable choice in
87
- `options`. Anchor a document question with `anchor: { artifact, quote, occurrence? }`; `occurrence` is zero-based and selects a repeated
88
- quote, and an anchor whose quote later disappears becomes orphaned but stays readable against its original document version.
104
+
105
+ An ask is read on a phone by someone who has not read the code. Open with one or two plain
106
+ sentences: what needs deciding and why it matters now. Each option is a button with a label and
107
+ one sentence saying what happens if it is chosen; never enumerate choices in prose. Put the
108
+ recommendation and its reason last, in `question`. Never put file paths, line numbers, sequence
109
+ numbers, document versions, or role tokens in the question; if the human needs that detail, anchor
110
+ the ask to the document passage instead. Apply the phone test from "Writing for the human" before
111
+ posting. Anchor a document question with `anchor: { artifact, quote, occurrence? }`; `occurrence`
112
+ is zero-based and selects a repeated quote, and an anchor whose quote later disappears becomes
113
+ orphaned but stays readable against its original document version.
89
114
 
90
115
  An ask must be answerable from its own text and its anchor alone. Anchor a question about a document passage with `anchor`; thread one
91
116
  about a comment with `reply_to`; thread a follow-up on your own ask with `reply_to_ask`; cite anything else with a `dispatch://`
@@ -123,6 +148,22 @@ clarification, not an answer: the human did not understand the question or needs
123
148
  in their Inbox. Answer in the same thread with `dispatch_comment({ reply_to_ask })`, or reword the question itself with
124
149
  `dispatch_edit_ask` when the wording was the problem; either puts the ask back in front of them. Do not open a second ask.
125
150
 
151
+ ## Approval of a spec
152
+
153
+ Approval is a property of a document, not a question you phrase: a human approves a specific version, the way a pull-request
154
+ review approves a commit, and any later edit makes that approval stale. It is the exception, not a step for every issue - reach
155
+ for it when a spec departs from what the human already settled, proposes children, or when the project has armed a design gate.
156
+
157
+ ```
158
+ dispatch_request_approval({ issue?, project?, artifact? })
159
+ ```
160
+
161
+ Opens (or returns the open) approval ask for the document at its latest version - options `Approve` and `Request changes`, in
162
+ the human's Inbox like any ask. The answer reaches you as `artifact.approved` or `artifact.changes_requested` with the pinned
163
+ `version`; `changes_requested` carries the reason, which is your next piece of work. `dispatch_read` and `dispatch_doc_read` show
164
+ the document's approval state; `stale` means it was approved and then edited - request again for the new version. Never write
165
+ "Approve" options into an ordinary `dispatch_ask`, and never approve anything yourself: only humans review.
166
+
126
167
  ## The Spec
127
168
 
128
169
  The spec holds requirements, design, acceptance, decisions, and rejected alternatives, structured per [Writing a spec](#writing-a-spec).
@@ -207,13 +248,14 @@ dispatch_artifact({ issue?, project?, name, path, summary? })
207
248
  Or, when the text is already in the call, post a Markdown document directly:
208
249
 
209
250
  ```ts
210
- dispatch_artifact({ issue?, project?, name: "spec.md", content: "# Design\n..." })
251
+ dispatch_artifact({ issue?, project?, name: "load-test-results.md", content: "# Load test\n..." })
211
252
  ```
212
253
 
213
254
  Exactly one of `issue` and `project` is required. A project upload creates an unlinked project document; it must not include `artifact`.
214
255
  Exactly one of `path` and `content` is required. It returns issue or project-document owner details plus `artifact`, `version`, and its
215
- write `topic`. Uploading the same `name` creates its next version. Address an existing artifact by the slug shown in the upload result
216
- or by its filename; the slug also arrives on `artifact.created` events.
256
+ write `topic`. Uploading the same `name` creates its next version so uploading `spec.md` **replaces the issue's own specification**
257
+ with your text. Never do that: the spec is edited in place with `dispatch_doc_edit` (see [The Spec](#the-spec)). Address an existing
258
+ artifact by the slug shown in the upload result or by its filename; the slug also arrives on `artifact.created` events.
217
259
 
218
260
  ## Messages
219
261
 
@@ -75,24 +75,39 @@ Wave releases, child closures, and your own status are visible from the issue tr
75
75
  handoffs; do not narrate them into the spec or a `dispatch_message`. A blocker only Sami can
76
76
  clear is a `dispatch_ask`.
77
77
 
78
- Write one root specification containing the accepted scope, adoption/decomposition,
79
- waves, acceptance criteria, and integration test. When the config-armed root design gate
80
- applies, run this exact sequence **before any Legion-role spawn**, including a
81
- sub-architect:
78
+ The issue's primary document **is** the root specification. Extend it in place — a new version
79
+ that keeps the human's own text and adds Summary, Decisions needed, New since we talked, the
80
+ adoption/decomposition and waves, acceptance criteria, and the integration test never a second
81
+ "spec" artifact beside it (`dispatch_artifact` with the primary document's name replaces the
82
+ human's document; do not do that). Both readers described in
83
+ [Writing for the human](../dispatch/SKILL.md#writing-for-the-human) must be able to follow it.
84
+ When the config-armed root design gate applies, run this exact sequence **before any
85
+ Legion-role spawn**, including a sub-architect:
82
86
 
83
87
  ```text
84
- dispatch_artifact({ issue: "<root issue>", name: "spec.md", content: "<root specification>", summary: "<one-line summary>" })
88
+ dispatch_doc_edit({ issue: "<root issue>", ... }) // extend the primary document in place
85
89
  askId = dispatch_ask({
86
90
  issue: "<root issue>",
87
- question: "<specification summary and the decision requested>",
88
- options: [{ label: "Approve" }, ...]
91
+ question: "<what is true today, in one sentence> <what will be true when this lands, in one sentence> <how: one issue or N child issues, and what the first step is> I recommend Approve because <one reason>.",
92
+ options: [
93
+ { label: "Approve", description: "Work starts as described; the first worker is spawned now." },
94
+ { label: "Hold", description: "Nothing starts; reply on the issue with what should change first." },
95
+ ]
89
96
  })
90
97
  legion({ op: "register_gate", issue: "<root issue>", askId })
91
98
  ```
92
99
 
100
+ Both options are required: a question with only `Approve` is not a decision. The whole ask is
101
+ read on a phone by someone who has not read the code: no file paths, line numbers, document
102
+ versions, or role tokens in it. Sami, 2026-09-12, on a gate ask that broke this rule: "I have no
103
+ idea what the fuck you're talking about."
104
+
93
105
  Then park. Do not release a wave or spawn a Legion role until a later delivered wake
94
- shows `design-approved` on the root. Approval covers the entire tree: later waves,
95
- re-scopes, and integration-failure children do not repeat this sequence.
106
+ shows `design-approved` on the root. On a deployment whose design gate is off
107
+ (`gates.design: off` in its `legion.yaml`), the daemon satisfies the gate as you register it
108
+ and `design-approved` arrives immediately — proceed; the ask remains open on Dispatch as the
109
+ record and needs no answer. Approval covers the entire tree: later waves, re-scopes, and
110
+ integration-failure children do not repeat this sequence.
96
111
 
97
112
  ## 2. Children in flight
98
113
 
@@ -44,6 +44,10 @@ into a state holder: daemon state and the Dispatch project remain authoritative.
44
44
  relevant Dispatch issue. A stale or duplicate wake may cost a read, never a wrong action.
45
45
  - **Controller state is disposable.** Do not reconstruct or preserve local controller
46
46
  bookkeeping between turns.
47
+ - **Write for a human.** Every `dispatch_comment`, `dispatch_message`, and `dispatch_ask` you
48
+ post follows the dispatch skill's "Writing for the human" rules: plain sentences, every
49
+ identifier expanded on first use, no coined shorthand. A triage note that reads like a log
50
+ line is not a triage note.
47
51
 
48
52
  ## Wake routing table
49
53