@sjawhar/opencode-legion-envoy 1.44.0 → 1.45.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.
@@ -15436,6 +15436,36 @@ function notSubscribed(owner) {
15436
15436
  function followsAsk(owner) {
15437
15437
  return `You follow this ask: its answer and replies reach you directly. For every event on ${owner.label}: envoy_subscribe ${owner.topic}`;
15438
15438
  }
15439
+ var triageAdviceShown = new Set;
15440
+ function renderAdvice(tool, key, advice, opts) {
15441
+ if (advice === undefined)
15442
+ return [];
15443
+ const hasIssueAdvice = advice.issue_status !== undefined;
15444
+ const openAsks = advice.your_open_asks;
15445
+ const writesSinceHuman = advice.session_writes_since_human;
15446
+ const lines = [];
15447
+ if (advice.decision_blocks === 0 && opts.isPrimarySpec === true && (tool === "dispatch_issue" || tool === "dispatch_artifact")) {
15448
+ lines.push('No decision blocks in this spec \u2014 nothing here reaches a human\'s inbox. Want human feedback? See the `dispatch` skill, "Decision blocks".');
15449
+ }
15450
+ if (hasIssueAdvice && writesSinceHuman !== undefined && writesSinceHuman >= 3 && (tool === "dispatch_message" || tool === "dispatch_ask" || tool === "dispatch_comment" && opts.isAskReply !== true)) {
15451
+ const middle = writesSinceHuman >= 6 ? "Stop posting here until a human replies." : "Progress ledger or scratchpad? If so, stop.";
15452
+ lines.push(`You've sent ${writesSinceHuman} messages on ${key} with no human response. ${middle} See the \`dispatch\` skill, "Structure over stream".`);
15453
+ }
15454
+ if (advice.issue_status === "triage" && tool !== "dispatch_issue" && !(tool === "dispatch_issue_update" && opts.setsStatus === true) && !triageAdviceShown.has(key)) {
15455
+ triageAdviceShown.add(key);
15456
+ lines.push(`${key} is still in triage \u2014 nobody can see its development status. See the \`dispatch\` skill, "Issue status is yours to move".`);
15457
+ }
15458
+ if (hasIssueAdvice && openAsks !== undefined && openAsks.length > 0 && (tool === "dispatch_message" || tool === "dispatch_doc_edit" || tool === "dispatch_issue_update" || tool === "dispatch_comment" && opts.replyToOwnAsk !== true)) {
15459
+ const askCount = Math.min(openAsks.length, 2);
15460
+ for (let index = 0;index < askCount; index += 1) {
15461
+ const ask = openAsks[index];
15462
+ if (ask === undefined)
15463
+ break;
15464
+ lines.push(`You still have an open ask on ${key}: "${ask.question.slice(0, 80)}" (${ask.id}). Still needed? See the \`dispatch\` skill, "Close what you opened".`);
15465
+ }
15466
+ }
15467
+ return lines;
15468
+ }
15439
15469
  function documentResultDetails(artifact) {
15440
15470
  return {
15441
15471
  project: artifact.project,
@@ -16324,9 +16354,19 @@ async function executeDispatchTool(input) {
16324
16354
  ...Array.isArray(labels) ? { labels } : {},
16325
16355
  actor
16326
16356
  });
16357
+ const adviceLines = renderAdvice(input.tool, created.key, created.advice, {
16358
+ isPrimarySpec: spec !== undefined
16359
+ });
16327
16360
  return {
16328
- text: `Created ${created.key}: ${created.title} ${notSubscribed(issueTopic(created.key))}`,
16329
- details: { issue: created.key }
16361
+ text: [
16362
+ `Created ${created.key}: ${created.title} ${notSubscribed(issueTopic(created.key))}`,
16363
+ ...adviceLines
16364
+ ].join(`
16365
+ `),
16366
+ details: {
16367
+ issue: created.key,
16368
+ ...created.advice === undefined ? {} : { advice: created.advice }
16369
+ }
16330
16370
  };
16331
16371
  } catch (error) {
16332
16372
  if (!(error instanceof DispatchServiceError) || error.code !== "POSSIBLE_DUPLICATE") {
@@ -16383,12 +16423,20 @@ async function executeDispatchTool(input) {
16383
16423
  ...parent === undefined ? [] : [after.parent === null ? "parent cleared" : `parent -> ${after.parent}`],
16384
16424
  ...components === undefined ? [] : [componentsChange(components, after.components)]
16385
16425
  ];
16426
+ const adviceLines = renderAdvice(input.tool, after.key, after.advice, {
16427
+ setsStatus: status !== undefined
16428
+ });
16386
16429
  return {
16387
- text: `${after.key}: ${changes.join("; ")} ${notSubscribed(issueTopic(after.key))}`,
16430
+ text: [
16431
+ `${after.key}: ${changes.join("; ")} ${notSubscribed(issueTopic(after.key))}`,
16432
+ ...adviceLines
16433
+ ].join(`
16434
+ `),
16388
16435
  details: {
16389
16436
  issue: after.key,
16390
16437
  status: after.status,
16391
- external_links: after.external_links.map((link) => link.url)
16438
+ external_links: after.external_links.map((link) => link.url),
16439
+ ...after.advice === undefined ? {} : { advice: after.advice }
16392
16440
  }
16393
16441
  };
16394
16442
  } catch (error) {
@@ -16519,10 +16567,18 @@ async function executeDispatchTool(input) {
16519
16567
  };
16520
16568
  const ask = resolved?.owner.kind === "project" ? await client.artifactAsk(resolved.artifact.id, askInput) : await client.ask(issue(), askInput);
16521
16569
  const askOwner = ask.issue_key !== null ? issueTopic(ask.issue_key) : resolved === undefined ? issueTopic(issue()) : documentTopic(resolved.artifact);
16570
+ const adviceLines = renderAdvice(input.tool, askOwner.label, ask.advice, {});
16522
16571
  return {
16523
- text: `Asked ${ask.id} on ${askOwner.label} (urgency ${ask.urgency}): ${ask.question}
16572
+ text: [
16573
+ `Asked ${ask.id} on ${askOwner.label} (urgency ${ask.urgency}): ${ask.question}
16524
16574
  ${followsAsk(askOwner)}`,
16525
- details: await followedAskDetails(client, ask, resolved?.artifact)
16575
+ ...adviceLines
16576
+ ].join(`
16577
+ `),
16578
+ details: {
16579
+ ...await followedAskDetails(client, ask, resolved?.artifact),
16580
+ ...ask.advice === undefined ? {} : { advice: ask.advice }
16581
+ }
16526
16582
  };
16527
16583
  }
16528
16584
  case "dispatch_edit_ask": {
@@ -16567,21 +16623,34 @@ ${followsAsk(askOwner)}`,
16567
16623
  const comment = resolved?.owner.kind === "project" ? await client.artifactComment(resolved.artifact.id, commentInput) : await client.comment(issue(), commentInput);
16568
16624
  const commentOwner = resolved === undefined ? issueTopic(issue()) : resolvedTopic(resolved);
16569
16625
  const commentDetails = resolved === undefined ? { issue: comment.issue_key, comment: comment.id } : writeResultDetails(resolved, { comment: comment.id });
16626
+ const adviceLines = renderAdvice(input.tool, commentOwner.label, comment.advice, {
16627
+ isAskReply: replyToAsk !== undefined,
16628
+ replyToOwnAsk: replyToAsk !== undefined && (comment.advice?.your_open_asks?.some((ask) => ask.id === replyToAsk) ?? false)
16629
+ });
16570
16630
  if (replyToAsk !== undefined) {
16571
16631
  const askState = comment.turn === null ? "" : `; ask now waiting on ${comment.turn}`;
16572
16632
  return {
16573
- text: `Replied on ask ${replyToAsk} (comment ${comment.id}${askState}). ${followsAsk(commentOwner)}`,
16633
+ text: [
16634
+ `Replied on ask ${replyToAsk} (comment ${comment.id}${askState}). ${followsAsk(commentOwner)}`,
16635
+ ...adviceLines
16636
+ ].join(`
16637
+ `),
16574
16638
  details: {
16575
16639
  ...commentDetails,
16576
16640
  ask: replyToAsk,
16577
16641
  follows: { ask: replyToAsk },
16578
- ...comment.turn === null ? {} : { ask_waiting_on: comment.turn }
16642
+ ...comment.turn === null ? {} : { ask_waiting_on: comment.turn },
16643
+ ...comment.advice === undefined ? {} : { advice: comment.advice }
16579
16644
  }
16580
16645
  };
16581
16646
  }
16582
16647
  return {
16583
- text: `Posted comment ${comment.id} ${notSubscribed(commentOwner)}`,
16584
- details: commentDetails
16648
+ text: [`Posted comment ${comment.id} ${notSubscribed(commentOwner)}`, ...adviceLines].join(`
16649
+ `),
16650
+ details: {
16651
+ ...commentDetails,
16652
+ ...comment.advice === undefined ? {} : { advice: comment.advice }
16653
+ }
16585
16654
  };
16586
16655
  }
16587
16656
  case "dispatch_suggest": {
@@ -16611,9 +16680,18 @@ ${followsAsk(askOwner)}`,
16611
16680
  actor
16612
16681
  });
16613
16682
  const messageRef = dispatchChildRef(dispatchIssueRef(issueKey), "message", message.id);
16683
+ const adviceLines = renderAdvice(input.tool, issueKey, message.advice, {});
16614
16684
  return {
16615
- text: `Posted message ${message.id} (${messageRef}) ${notSubscribed(issueTopic(issueKey))}`,
16616
- details: { issue: issueKey, message: message.id }
16685
+ text: [
16686
+ `Posted message ${message.id} (${messageRef}) ${notSubscribed(issueTopic(issueKey))}`,
16687
+ ...adviceLines
16688
+ ].join(`
16689
+ `),
16690
+ details: {
16691
+ issue: issueKey,
16692
+ message: message.id,
16693
+ ...message.advice === undefined ? {} : { advice: message.advice }
16694
+ }
16617
16695
  };
16618
16696
  }
16619
16697
  case "dispatch_doc_edit": {
@@ -16631,11 +16709,14 @@ ${followsAsk(askOwner)}`,
16631
16709
  const retyped = ops.filter((operation) => operation.op === "retype").length;
16632
16710
  const versionText = edited.version === null ? "no new version" : `version ${edited.version.number}`;
16633
16711
  const applied = retyped === 0 ? `Applied ${edited.applied} ops (${versionText})` : `Applied ${edited.applied} ops; retyped ${retyped} block${retyped === 1 ? "" : "s"} (${versionText})`;
16712
+ const adviceLines = renderAdvice(input.tool, resolvedTopic(resolved).label, edited.advice, {});
16634
16713
  return {
16635
- text: `${applied} ${notSubscribed(resolvedTopic(resolved))}`,
16714
+ text: [`${applied} ${notSubscribed(resolvedTopic(resolved))}`, ...adviceLines].join(`
16715
+ `),
16636
16716
  details: writeResultDetails(resolved, {
16637
16717
  applied: edited.applied,
16638
- ...edited.version === null ? {} : { version: edited.version.number }
16718
+ ...edited.version === null ? {} : { version: edited.version.number },
16719
+ ...edited.advice === undefined ? {} : { advice: edited.advice }
16639
16720
  })
16640
16721
  };
16641
16722
  }
@@ -16707,15 +16788,24 @@ ${trailer.join(`
16707
16788
  const result = artifactOwner.kind === "project" ? await client.projectArtifact(artifactOwner.project, artifactInput) : await client.artifact(issue(), artifactInput);
16708
16789
  const artifactRef = dispatchDocumentRef(artifactOwner.kind === "project" ? artifactOwner.project : issue(), result.artifact.slug);
16709
16790
  const uploadOwner = artifactOwner.kind === "project" ? documentTopic(result.artifact) : issueTopic(issue());
16791
+ const adviceLines = renderAdvice(input.tool, uploadOwner.label, result.advice, {
16792
+ isPrimarySpec: result.artifact.primary || result.artifact.name === "spec.md"
16793
+ });
16710
16794
  return {
16711
- text: `Uploaded ${result.artifact.name} as version ${result.version.number} (artifact slug ${result.artifact.slug}; ${artifactRef}) ${notSubscribed(uploadOwner)}`,
16795
+ text: [
16796
+ `Uploaded ${result.artifact.name} as version ${result.version.number} (artifact slug ${result.artifact.slug}; ${artifactRef}) ${notSubscribed(uploadOwner)}`,
16797
+ ...adviceLines
16798
+ ].join(`
16799
+ `),
16712
16800
  details: artifactOwner.kind === "project" ? {
16713
16801
  ...documentResultDetails(result.artifact),
16714
- version: result.version.number
16802
+ version: result.version.number,
16803
+ ...result.advice === undefined ? {} : { advice: result.advice }
16715
16804
  } : {
16716
16805
  issue: issue(),
16717
16806
  artifact: result.artifact.id,
16718
- version: result.version.number
16807
+ version: result.version.number,
16808
+ ...result.advice === undefined ? {} : { advice: result.advice }
16719
16809
  }
16720
16810
  };
16721
16811
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "1.44.0",
3
+ "version": "1.45.1",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dispatch
3
- description: "Use when asking Sami a question, updating the spec, commenting on a document, attaching an artifact, or calling a dispatch_* tool."
3
+ description: "Use before posting a message, a status update, or a periodic status update; before asking a question that references another message, artifact, or eval; and when asking Sami a question, updating the spec, commenting on a document, attaching an artifact, or calling a dispatch_* tool."
4
4
  ---
5
5
 
6
6
  # Dispatch
@@ -75,13 +75,11 @@ route"}`; when you see that, you typed the path wrong — read the index rather
75
75
 
76
76
  ## Writing a spec
77
77
 
78
- A spec has two readers: the human who decides reads the top; the implementer who builds reads the
79
- rest. Use these headings in this order.
78
+ A spec has two readers: the human who decides reads the **Summary** and **New since we talked** at the top, then each decision through its [`:::ask` block](#decision-blocks) where it arises; the implementer who builds reads the rest. Use these headings in this order.
80
79
 
81
80
  | Section | Required content | Form |
82
81
  | --- | --- | --- |
83
82
  | **Summary** | The problem, what changes for whom, and how we will know it worked — in plain words. | Three sentences at most. |
84
- | **Decisions needed** | Only decisions that need human authority, taste, or risk appetite: each one plain question, two or three options with what each costs, and your recommendation with its reason — written as an `ask` block directly under those options, so it is answered in context (see [Design changes are brainstormed here](#design-changes-are-brainstormed-here)). An answered item moves into Requirements with its provenance. | One `ask` block per decision; empty is fine. |
85
83
  | **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. |
86
84
  | **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. |
87
85
  | **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. |
@@ -94,13 +92,33 @@ rest. Use these headings in this order.
94
92
 
95
93
  - The spec is the issue's one primary document. Extend it in place — a new version that keeps the
96
94
  human's own text — never a second "spec" artifact beside it.
97
- - No hedging ("might", "could consider"). No TBD, TODO, or placeholders: an open item is either a
98
- Decision needed or a question for the platform PO whose ruling becomes a Requirement (see
95
+ - No hedging ("might", "could consider"). No TBD, TODO, or placeholders: an open item is either
96
+ an ask block or a question for the platform PO whose ruling becomes a Requirement (see
99
97
  [Before you ask](#before-you-ask) under Asking).
100
98
  - Keep each section to one screen; work that exceeds one screen per section is two specs.
101
99
  - Update the spec as decisions land: the spec is the record, comments are the discussion.
102
100
  - Before sending it: no sections conflict, every requirement has exactly one reading, and the
103
- Summary and Decisions pass the phone test above.
101
+ Summary and every ask block pass the phone test above.
102
+
103
+ ## Decision blocks
104
+
105
+ A decision a human must make is an `:::ask` block where the decision arises in the spec: inside
106
+ the section whose content it is about, never gathered into a list at the top or bottom. Sami,
107
+ LEGION-204 comment, 2026-09-20 14:47Z, verbatim: "Adding a bunch of decision blocks at the top is
108
+ terrible!! Decisions should be in context in the spec".
109
+
110
+ The block is what reaches the human's Inbox. A question phrased as prose in the spec reaches
111
+ nobody. A spec with no ask blocks is fine only when the issue genuinely needs no human decision.
112
+ When `dispatch_issue` or `dispatch_artifact` answers `No decision blocks in this spec …`, read it
113
+ as a question, not an error: either no decision is needed and you say nothing, or you forgot to
114
+ make the decision a block and must fix the spec.
115
+
116
+ **Wrong:** a **Decisions needed** list at the top of the spec with three bullets.
117
+ **Right:** put each decision in the design section it belongs to as an `:::ask{#slug}` block, with
118
+ 2–4 options, a recommendation, and surrounding prose that explains the trade-off.
119
+
120
+ See [Typed blocks](#typed-blocks) for the syntax and [Before you ask](#before-you-ask) under
121
+ [Asking](#asking) to decide whether the question is a real decision at all.
104
122
 
105
123
  ## Your owner
106
124
 
@@ -152,6 +170,9 @@ renders its state and checks from that link. The call is authenticated with the
152
170
  other `dispatch_*` tool: a Legion pane reads it from the `DISPATCH_TOKEN_FILE` path the daemon sets on
153
171
  the pane; an OMP session outside Legion reads `dispatch.token` from `~/.config/opencode/envoy.json`.
154
172
 
173
+ A write to an issue still in `triage` answers once with `… is still in triage …`; move the status
174
+ when work has started.
175
+
155
176
  ## Search first
156
177
 
157
178
  Before you create an issue or start a design document, search:
@@ -353,6 +374,26 @@ yet — "dispatched two auditors, back with results", "checking the release bran
353
374
  turn to you. The result names the state (`ask now waiting on agent` / `human`), the delivered `comment.created` carries it as
354
375
  `ask_waiting_on`, and every ask read carries it as `waiting_on`.
355
376
 
377
+ ## Close what you opened
378
+
379
+ An ask you opened is yours until it is answered or you resolve it. When the answer arrives some
380
+ other way — Sami said it live, a later comment settled it, or the question became moot because the
381
+ design moved — resolve it yourself with `dispatch_resolve_ask` in the same turn you learn that.
382
+ Never leave it for the human to clear.
383
+
384
+ Sami, AGENTC-27 `rules-derivation-2026-09-17.md` row R04, verbatim: "I think this was answered
385
+ live. If not, please reask." The same pattern left him closing asks as `Dismissed`, `Settled`, and
386
+ `Resolved I think`: noise the human had to clear.
387
+
388
+ Every later write on the issue answers `You still have an open ask on …` and names it. Treat that
389
+ as the checklist: if it is still needed, leave it; if it was answered elsewhere, resolve it with
390
+ the resolving fact as the reason. Before posting a new ask, inspect your open ones. If the new ask
391
+ supersedes one, retract the old one with `dispatch_resolve_ask` and kind `retracted` in the same
392
+ turn.
393
+
394
+ See [Following](#following) for why you receive what happens to asks you open and
395
+ [What comes back](#what-comes-back) for finding them again with `dispatch_open_asks`.
396
+
356
397
  ## Approval of a spec
357
398
 
358
399
  Approval is a property of a document, not a question you phrase: a human approves a specific version, the way a pull-request
@@ -592,6 +633,10 @@ high-signal, structured conversation"). The structure IS the product:
592
633
  - **Never split one deliverable across a message + an ask that points at it.** Ask the question
593
634
  with the document reference in the question text; the reader lands on the content in one click.
594
635
 
636
+ The tool result answers your third consecutive message on an issue with no human reply with
637
+ `You've sent N messages …`. That is the ledger pattern being named; stop and either wait or ask
638
+ once.
639
+
595
640
  ## Messages
596
641
 
597
642
  Dispatch is a high-signal record for humans, not a log of what you are doing. A message is a reply to a human's message, or a
@@ -678,6 +723,8 @@ title is invisible to the graph.
678
723
 
679
724
  ## References
680
725
 
726
+ **Every reference is a link, never an unlinked mention.** If you name a thing that has an address, link it: another issue, ask, comment, spec, or message (the `dispatch://` forms below), an artifact (`dispatch://KEY/artifact/<slug>`), an eval (its viewer URL), a Slack message (its permalink), a Drive file (its share link). Bare phrases like "see this eval", "his 09-04 run", "the comment above", or "per the spec" with no link are banned: they make the reader hunt for what you already had in hand, and nothing can be traversed from them. Linking every reference is what makes a body both consumable and navigable. If a thing genuinely has no linkable address, say so; otherwise the link is not optional.
727
+
681
728
  Use these in document, ask, comment, and message bodies. In the dashboard, a reference renders
682
729
  as an inline link whose text is the target's title (an issue's title, an ask's question, a
683
730
  comment's first line, a document's name) once it resolves; a body that is only a bare reference