@sjawhar/opencode-legion-envoy 3.11.4 → 3.11.5
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.
- package/dist/src/server.js +62 -9
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +14 -6
- package/skills/legion-worker/SKILL.md +35 -12
package/dist/src/server.js
CHANGED
|
@@ -13917,6 +13917,16 @@ var messageValidation = {
|
|
|
13917
13917
|
},
|
|
13918
13918
|
message: "issue is required unless in_reply_to names a message delivered to this session, which is the one message with no issue."
|
|
13919
13919
|
};
|
|
13920
|
+
var readOwner = documentOwnerValidation(true);
|
|
13921
|
+
var readValidation = {
|
|
13922
|
+
check: (value) => {
|
|
13923
|
+
const input = value;
|
|
13924
|
+
if (typeof input.message !== "string")
|
|
13925
|
+
return readOwner.check(value);
|
|
13926
|
+
return input.issue === undefined && input.project === undefined && input.artifact === undefined && input.ref === undefined;
|
|
13927
|
+
},
|
|
13928
|
+
message: `${readOwner.message} message stands alone: it names the conversation, so name no issue, project, artifact, or ref with it.`
|
|
13929
|
+
};
|
|
13920
13930
|
var ISSUE_COMPONENTS_MODES = ["inherit", "explicit", "none"];
|
|
13921
13931
|
function componentsArgument(z2) {
|
|
13922
13932
|
return z2.object({
|
|
@@ -14141,7 +14151,7 @@ var dispatchToolSpecs = [
|
|
|
14141
14151
|
{
|
|
14142
14152
|
name: "dispatch_message",
|
|
14143
14153
|
example: { issue: "DSP-1", body: "Implementation started." },
|
|
14144
|
-
description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A blocker only a human can " + "clear is an ask (dispatch_ask), so it lands in their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a decision (dispatch_ask) or document feedback (dispatch_comment). To answer a human's direct message to this session - " + "one sent from the Agents page, which names no issue - pass that message's bare id as in_reply_to and no issue; " + "the reply lands in that conversation
|
|
14154
|
+
description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A blocker only a human can " + "clear is an ask (dispatch_ask), so it lands in their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a decision (dispatch_ask) or document feedback (dispatch_comment). To answer a human's direct message to this session - " + "one sent from the Agents page, which names no issue - pass that message's bare id as in_reply_to and no issue; " + "the reply lands in that conversation. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch_read({message}) reads " + "that conversation back. Every other message names its issue. " + `Body is at most 2,000 characters. ${ISSUE_REFERENCE}`,
|
|
14145
14155
|
arguments: (z2) => ({
|
|
14146
14156
|
issue: z2.string().describe(`${ISSUE_REFERENCE} Omit it only when in_reply_to answers a human's direct message to this session.`).optional(),
|
|
14147
14157
|
body: z2.string({ max: 2000 }).describe("Update text, at most 2,000 characters."),
|
|
@@ -14235,14 +14245,15 @@ var dispatchToolSpecs = [
|
|
|
14235
14245
|
{
|
|
14236
14246
|
name: "dispatch_read",
|
|
14237
14247
|
example: { issue: "DSP-1" },
|
|
14238
|
-
description: "Read an issue or project-document summary, targeted ask, or targeted comment reply chain. Do not use it for document
|
|
14248
|
+
description: "Read an issue or project-document summary, targeted ask, or targeted comment reply chain, or the conversation " + "a message belongs to. Do not use it for document contents; use dispatch_doc_read instead. Supply ref, issue, " + "or project plus artifact; or message alone, which reads a human's direct message to this session and every " + "reply to it (they belong to no issue). " + "Every read ends with `Referenced by:` (what cites or hangs off this node, each with its dispatch:// address, " + "an excerpt, and when) and `Links:` (what it cites), so tracing provenance is one call. " + OWNER_REFERENCE,
|
|
14239
14249
|
arguments: (z2) => ({
|
|
14240
14250
|
issue: z2.string().describe(ISSUE_REFERENCE).optional(),
|
|
14241
14251
|
project: z2.string().describe("Project key owning the document.").optional(),
|
|
14242
14252
|
artifact: z2.string().describe("Project document artifact id, slug, or filename.").optional(),
|
|
14243
|
-
ref: z2.string().describe("Optional dispatch:// issue or document reference.").optional()
|
|
14253
|
+
ref: z2.string().describe("Optional dispatch:// issue or document reference.").optional(),
|
|
14254
|
+
message: z2.string().describe("A message id (uuid): reads the conversation it belongs to, the root message and every " + "reply. Name nothing else with it.").optional()
|
|
14244
14255
|
}),
|
|
14245
|
-
validation:
|
|
14256
|
+
validation: readValidation
|
|
14246
14257
|
},
|
|
14247
14258
|
{
|
|
14248
14259
|
name: "dispatch_search",
|
|
@@ -15210,8 +15221,8 @@ class DispatchClient {
|
|
|
15210
15221
|
async message(issue2, input) {
|
|
15211
15222
|
return this.#json("POST", ["api", "v1", "issues", await this.#resolveIssue(issue2), "messages"], input);
|
|
15212
15223
|
}
|
|
15213
|
-
async messageReply(id, input) {
|
|
15214
|
-
return this.#json("POST", ["api", "v1", "messages", id, "reply"], input);
|
|
15224
|
+
async messageReply(id, input, options = {}) {
|
|
15225
|
+
return this.#json("POST", ["api", "v1", "messages", id, "reply"], input, options.followUp === true ? { follow_up: "true" } : undefined);
|
|
15215
15226
|
}
|
|
15216
15227
|
async getMessage(issue2, id) {
|
|
15217
15228
|
return this.#json("GET", [
|
|
@@ -15223,6 +15234,9 @@ class DispatchClient {
|
|
|
15223
15234
|
id
|
|
15224
15235
|
]);
|
|
15225
15236
|
}
|
|
15237
|
+
async getMessageThread(id, session) {
|
|
15238
|
+
return this.#json("GET", ["api", "v1", "messages", id], undefined, { session });
|
|
15239
|
+
}
|
|
15226
15240
|
async artifact(issue2, input) {
|
|
15227
15241
|
const artifactPath = ["api", "v1", "issues", await this.#resolveIssue(issue2), "artifacts"];
|
|
15228
15242
|
if ("content" in input)
|
|
@@ -15897,6 +15911,13 @@ function argumentProblems(tool, args) {
|
|
|
15897
15911
|
}
|
|
15898
15912
|
break;
|
|
15899
15913
|
}
|
|
15914
|
+
case "dispatch_read": {
|
|
15915
|
+
const message = optionalString(args, "message");
|
|
15916
|
+
if (message !== undefined && messageIdOf(message) === undefined) {
|
|
15917
|
+
problems.push("message must be a full message id (uuid) or a dispatch://KEY/message/<id> reference");
|
|
15918
|
+
}
|
|
15919
|
+
break;
|
|
15920
|
+
}
|
|
15900
15921
|
}
|
|
15901
15922
|
return problems;
|
|
15902
15923
|
}
|
|
@@ -16034,6 +16055,9 @@ async function resolveOwnerArguments(tool, input, cwd, env, exec, serverUrl, pro
|
|
|
16034
16055
|
if (tool === "dispatch_message" && typeof replyTarget === "string" && !replyTarget.startsWith("dispatch://")) {
|
|
16035
16056
|
return { args, ref, owner: null };
|
|
16036
16057
|
}
|
|
16058
|
+
if (tool === "dispatch_read" && typeof args.message === "string") {
|
|
16059
|
+
return { args, ref, owner: null };
|
|
16060
|
+
}
|
|
16037
16061
|
const legionIssue = env.LEGION_ISSUE;
|
|
16038
16062
|
if (!legionIssue) {
|
|
16039
16063
|
problems.push(ownerRequiredProblem);
|
|
@@ -16953,16 +16977,30 @@ ${followsAsk(askOwner)}`,
|
|
|
16953
16977
|
const inReplyTo = messageInReplyTo(args);
|
|
16954
16978
|
const body = stringArg(args, "body");
|
|
16955
16979
|
if (owner === null && inReplyTo !== undefined) {
|
|
16956
|
-
const reply = await client.messageReply(inReplyTo, { body, attempt: 1, actor });
|
|
16980
|
+
const reply = await client.messageReply(inReplyTo, { body, attempt: 1, actor }, { followUp: true });
|
|
16981
|
+
if (reply.duplicate === true && reply.body === body) {
|
|
16982
|
+
return {
|
|
16983
|
+
text: `Dispatch already has this exact text in the conversation (message ${reply.id}); ` + "nothing new was posted. Send different text if you have more to say.",
|
|
16984
|
+
details: { message: reply.id, in_reply_to: inReplyTo, posted: false, duplicate: true }
|
|
16985
|
+
};
|
|
16986
|
+
}
|
|
16957
16987
|
if (reply.body !== body) {
|
|
16958
16988
|
return {
|
|
16959
16989
|
text: `Message ${inReplyTo} was already answered by message ${reply.id}; Dispatch kept ` + "that reply and posted nothing. Wait for their next message rather than answering " + "this one again.",
|
|
16960
16990
|
details: { message: reply.id, in_reply_to: inReplyTo, posted: false }
|
|
16961
16991
|
};
|
|
16962
16992
|
}
|
|
16993
|
+
const readBack = `dispatch_read({message: "${inReplyTo}"}) reads the conversation back.`;
|
|
16994
|
+
const parent = reply.in_reply_to ?? undefined;
|
|
16995
|
+
const follows = parent === inReplyTo ? undefined : parent;
|
|
16963
16996
|
return {
|
|
16964
|
-
text: `Replied to message ${inReplyTo} with message ${reply.id}`,
|
|
16965
|
-
details: {
|
|
16997
|
+
text: follows === undefined ? `Replied to message ${inReplyTo} with message ${reply.id}. ${readBack}` : `Replied to message ${inReplyTo} with message ${reply.id}, a follow-up threaded ` + `under your reply ${follows}. ${readBack}`,
|
|
16998
|
+
details: {
|
|
16999
|
+
message: reply.id,
|
|
17000
|
+
in_reply_to: inReplyTo,
|
|
17001
|
+
posted: true,
|
|
17002
|
+
...follows === undefined ? {} : { follows }
|
|
17003
|
+
}
|
|
16966
17004
|
};
|
|
16967
17005
|
}
|
|
16968
17006
|
const issueKey = issue2();
|
|
@@ -17134,6 +17172,21 @@ ${trailer.join(`
|
|
|
17134
17172
|
};
|
|
17135
17173
|
}
|
|
17136
17174
|
case "dispatch_read": {
|
|
17175
|
+
const message = optionalString(args, "message");
|
|
17176
|
+
if (message !== undefined) {
|
|
17177
|
+
const sessionId = input.sessionId?.trim();
|
|
17178
|
+
if (!sessionId)
|
|
17179
|
+
throw new Error("host session id is required for dispatch_read({message})");
|
|
17180
|
+
const thread = await client.getMessageThread(messageIdOf(message), sessionId);
|
|
17181
|
+
const issueKey2 = thread.message.issue_key;
|
|
17182
|
+
return {
|
|
17183
|
+
text: messageSummary(thread, issueKey2 === null ? [] : await graphSections(client, dispatchChildRef(dispatchIssueRef(issueKey2), "message", thread.message.id))),
|
|
17184
|
+
details: {
|
|
17185
|
+
message: thread.message.id,
|
|
17186
|
+
...issueKey2 === null ? {} : { issue: issueKey2 }
|
|
17187
|
+
}
|
|
17188
|
+
};
|
|
17189
|
+
}
|
|
17137
17190
|
if (ownerArguments.ref?.kind === "ask") {
|
|
17138
17191
|
const ref = ownerArguments.ref;
|
|
17139
17192
|
const id = await resolveIdPrefix(input.tool, "ask", ref.id, refOwnerName(ref), async () => ref.owner.kind === "issue" ? client.listIssueAsks(ref.owner.issue) : client.getArtifactAsks((await resolveDocument(ref.owner, ref.artifact)).artifact.id, "all"));
|
package/package.json
CHANGED
package/skills/dispatch/SKILL.md
CHANGED
|
@@ -894,15 +894,23 @@ dispatch_message({
|
|
|
894
894
|
|
|
895
895
|
Leave `issue` out — there is no issue to post into, and naming one would file your answer on
|
|
896
896
|
unrelated work. Dispatch threads the reply under their message in the same conversation, and the
|
|
897
|
-
human sees it
|
|
897
|
+
human sees it in your conversation on the Agents page, where it shows as an unread reply until
|
|
898
|
+
they read it. Every other message still names its issue, so keep the `issue`
|
|
898
899
|
the frame gave you whenever it gave you one; a `dispatch://KEY/message/<id>` reference names the
|
|
899
900
|
issue its message lives on, so that form is a reply on that issue, not a direct message.
|
|
900
901
|
|
|
901
|
-
|
|
902
|
-
Dispatch
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
902
|
+
Have more to say after you answered? Call it again with the same `in_reply_to` and the new text:
|
|
903
|
+
Dispatch threads that follow-up under your first reply, and the tool result names the reply it
|
|
904
|
+
follows. The same text again posts nothing, so a retry is safe. Your host may already have
|
|
905
|
+
answered a **BTW** automatically before you got here; a second call is then your follow-up to
|
|
906
|
+
that answer, so read the result before writing again.
|
|
907
|
+
|
|
908
|
+
Read the whole conversation back — their message and every reply, yours included — with the
|
|
909
|
+
message id alone; it has no issue:
|
|
910
|
+
|
|
911
|
+
```ts
|
|
912
|
+
dispatch_read({ message: "<the direct message's id, or any reply's>" })
|
|
913
|
+
```
|
|
906
914
|
|
|
907
915
|
## Following
|
|
908
916
|
|
|
@@ -188,11 +188,17 @@ reviewer, or an architect (in Legion's own deployment, `legion-implementer[bot]`
|
|
|
188
188
|
check
|
|
189
189
|
`jj -R "$LEGION_WORKSPACE" log -r 'main@origin..@' -T 'author.email() ++ " | " ++ committer.email() ++ " " ++ description.first_line() ++ "\n"'`
|
|
190
190
|
shows your role's App in both columns **on every commit you made** — not on the whole list:
|
|
191
|
-
earlier phases' commits are legitimately authored by their own role's App
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
earlier phases' commits are legitimately authored by their own role's App. Their *committer* is
|
|
192
|
+
a different matter, and no longer noise to accept. Resolving a conflict rewrites nothing now —
|
|
193
|
+
it is the forward merge above — so it changes no committer at all, and the one rewrite still
|
|
194
|
+
open to you (*Rewriting pushed commits*, below) resets the committer only of commits on your own
|
|
195
|
+
chain that descend from the commit you named, after its guard cleared. Another role's commit
|
|
196
|
+
carrying you as committer, which you did not rewrite that way, is evidence that something
|
|
197
|
+
rewrote commits it should not have — the observable symptom of LEGION-118. Stop and send the
|
|
198
|
+
architect that log; do not accept it as a side effect. A wrong identity on your own commit, the
|
|
199
|
+
other App or none, is a pane-environment problem to report to the architect, not something to
|
|
200
|
+
pin (`docs/solutions/legion/shared-main-repo-hazards-for-concurrent-issue-workspaces.md`,
|
|
201
|
+
Hazard 1).
|
|
196
202
|
Your session receives the credential capability it needs; invoke GitHub through the
|
|
197
203
|
credential helper:
|
|
198
204
|
|
|
@@ -390,14 +396,17 @@ this proof.
|
|
|
390
396
|
changes behavior, hides an error, or breaks a gate is fixed here — never deferred.
|
|
391
397
|
Findings about naming, duplication, or wording are batched into the single `Fast-follow`
|
|
392
398
|
line instead of iterating per push.
|
|
393
|
-
- **
|
|
399
|
+
- **Reintegrate the base only on a real conflict, except after a base retarget — and with a
|
|
400
|
+
merge, never `jj rebase`.** Sami, 2026-09-11, verbatim:
|
|
394
401
|
"Please don't do unnecessary rebases (i.e. unless there are merge conflicts). The CI queue is too long and slow."
|
|
395
|
-
The implementer
|
|
396
|
-
because of a conflict, or after the pull request is retargeted to a new base. Otherwise, never
|
|
402
|
+
The implementer merges the base into the issue branch only when GitHub reports it `CONFLICTING`, the controller asks
|
|
403
|
+
because of a conflict, or after the pull request is retargeted to a new base. Otherwise, never reintegrate the base to
|
|
397
404
|
pick up `main` or refresh CI. A single failed CI job is re-run on its own with `legion gh -- run rerun <run-id> --failed`, never by
|
|
398
405
|
pushing a new commit. A conflict-forced rebase that leaves the branch's diff unchanged is a
|
|
399
|
-
confirmation, not a new round (see *The unchanged-diff check* below)
|
|
400
|
-
|
|
406
|
+
confirmation, not a new round (see *The unchanged-diff check* below); that name is the event's,
|
|
407
|
+
kept by the rules below and the learnings that cite it, and the operation it names is always
|
|
408
|
+
the merge here. Before merging, record
|
|
409
|
+
the fingerprint at the current tip; after pushing the merged branch, record it at the new
|
|
401
410
|
tip; post one PR comment (Legion footer):
|
|
402
411
|
`rebase <old-tip-sha> → <new-tip-sha>; fingerprint <before> → <after>; unchanged|changed`.
|
|
403
412
|
Every issue workspace is a `jj workspace` of the same shared repository and operation log, and
|
|
@@ -646,12 +655,19 @@ remote branch sideways onto your commit and drops theirs (jj 0.45.1:
|
|
|
646
655
|
push is refused by jj itself (`unexpectedly moved on the remote`).
|
|
647
656
|
|
|
648
657
|
**Rewriting pushed commits** — a `jj squash --into` a commit already on GitHub, or any other
|
|
649
|
-
rewrite of a commit you already pushed —
|
|
650
|
-
|
|
658
|
+
rewrite of a commit you already pushed — is the LEGION-118 hazard in a second shape: jj rebases
|
|
659
|
+
every descendant of any commit it rewrites, and in the one shared repository a descendant can be
|
|
660
|
+
another tree's branch stacked on your pushed commit, which then moves, with its bookmark, onto a
|
|
661
|
+
rewritten copy. So look for a descendant outside your own chain first, and record the pushed tip
|
|
662
|
+
— which the rewrite leaves outside `::@-` — after a fetch and while your chain still descends
|
|
663
|
+
from it:
|
|
651
664
|
|
|
652
665
|
```bash
|
|
653
666
|
cd -- "$LEGION_WORKSPACE" && \
|
|
654
667
|
jj -R "$LEGION_WORKSPACE" git fetch && \
|
|
668
|
+
foreign=$(jj -R "$LEGION_WORKSPACE" log --no-graph -T 'commit_id.short() ++ "\n"' \
|
|
669
|
+
-r 'descendants(<the commit you are about to rewrite>) ~ ::@') && \
|
|
670
|
+
{ [ -z "$foreign" ] || { echo "not mine, and descends from the commit to rewrite: $foreign" >&2; false; }; } && \
|
|
655
671
|
behind=$(jj -R "$LEGION_WORKSPACE" log --no-graph -T 'commit_id.short() ++ "\n"' \
|
|
656
672
|
-r 'remote_bookmarks(exact:"legion/<KEY>", exact:"origin") ~ ::@-') && \
|
|
657
673
|
{ [ -z "$behind" ] || { echo "legion/<KEY>@origin is at $behind, which @- does not descend from" >&2; false; }; } && \
|
|
@@ -660,6 +676,13 @@ cd -- "$LEGION_WORKSPACE" && \
|
|
|
660
676
|
>"${TMPDIR:-/tmp}/legion-<KEY>-$LEGION_ROLE-rewritten-tip"
|
|
661
677
|
```
|
|
662
678
|
|
|
679
|
+
`descendants(<commit>) ~ ::@` is everything built on the commit you are about to rewrite that is
|
|
680
|
+
not on your own chain. Non-empty means the rewrite would move work that is not yours: do not
|
|
681
|
+
rewrite it. Put the change in a new commit on top instead, and report the listed commits to the
|
|
682
|
+
architect. On a two-workspace rig of this shape a `jj squash --into` a pushed commit reported
|
|
683
|
+
`Rebased 13 descendant commits` and moved a second issue's twelve commits and its bookmark; the
|
|
684
|
+
check above listed those thirteen and refused before anything moved.
|
|
685
|
+
|
|
663
686
|
Then rewrite, resolve, and push with the procedure above. It lets the remote branch sit on the
|
|
664
687
|
tip you recorded, which the rewrite replaced, and on nothing else: when another role pushed after
|
|
665
688
|
you recorded it, the push is refused. The push deletes the file.
|