@sjawhar/opencode-legion-envoy 3.9.0 → 3.10.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.
@@ -15150,7 +15150,14 @@ class DispatchClient {
15150
15150
  return this.#json("POST", ["api", "v1", "projects", project, "architecture-source", "sync"]);
15151
15151
  }
15152
15152
  async getArchitectureSource(project) {
15153
- return this.#json("GET", ["api", "v1", "projects", project, "architecture-source"]);
15153
+ try {
15154
+ return await this.#json("GET", ["api", "v1", "projects", project, "architecture-source"]);
15155
+ } catch (error48) {
15156
+ if (error48 instanceof DispatchServiceError && error48.status === 404 && error48.code === "SOURCE_NOT_FOUND") {
15157
+ return null;
15158
+ }
15159
+ throw error48;
15160
+ }
15154
15161
  }
15155
15162
  async resolveAsk(id, input) {
15156
15163
  return this.#json("POST", ["api", "v1", "asks", id, "resolve"], input);
@@ -16041,13 +16048,23 @@ async function resolveOwnerArguments(tool, input, cwd, env, exec, serverUrl, pro
16041
16048
  const issue2 = `${repo2}#${legionIssue}`;
16042
16049
  return { args: { ...args, issue: issue2 }, ref, owner: { kind: "issue", issue: issue2 } };
16043
16050
  }
16044
- function artifactByReference(artifacts, artifactReference, owner) {
16045
- const byIdOrSlug = artifacts.find((candidate) => candidate.id === artifactReference) ?? artifacts.find((candidate) => candidate.slug === artifactReference);
16046
- if (byIdOrSlug !== undefined)
16047
- return byIdOrSlug;
16051
+ function artifactByReference(artifacts, artifactReference, owner, canonical) {
16052
+ const bySlug = artifacts.find((candidate) => candidate.slug === artifactReference);
16053
+ if (canonical && bySlug !== undefined)
16054
+ return bySlug;
16055
+ const byId = artifacts.find((candidate) => candidate.id === artifactReference);
16056
+ if (byId !== undefined)
16057
+ return byId;
16048
16058
  const byName = artifacts.filter((candidate) => candidate.name === artifactReference);
16059
+ if (bySlug !== undefined) {
16060
+ const named = byName.filter((candidate) => candidate.id !== bySlug.id && candidate.kind === "doc");
16061
+ if (named.length > 0) {
16062
+ throw new Error(documentReferenceProblem(artifactReference, [bySlug, ...named], owner, "id"));
16063
+ }
16064
+ return bySlug;
16065
+ }
16049
16066
  if (byName.length > 1) {
16050
- throw new Error(documentReferenceProblem(artifactReference, byName, owner, true));
16067
+ throw new Error(documentReferenceProblem(artifactReference, byName, owner, "slug"));
16051
16068
  }
16052
16069
  const [artifact] = byName;
16053
16070
  if (artifact === undefined) {
@@ -16055,32 +16072,31 @@ function artifactByReference(artifacts, artifactReference, owner) {
16055
16072
  }
16056
16073
  return artifact;
16057
16074
  }
16058
- async function resolveArtifact(client, owner, artifactReference) {
16075
+ async function resolveArtifact(client, owner, artifactReference, { canonical = false } = {}) {
16059
16076
  if (owner.kind === "project") {
16060
16077
  if (artifactReference === undefined) {
16061
16078
  throw new Error("artifact is required for a project document");
16062
16079
  }
16063
- try {
16064
- return {
16065
- owner,
16066
- artifact: await client.getProjectArtifact(owner.project, artifactReference)
16067
- };
16068
- } catch (error48) {
16080
+ const routed = await client.getProjectArtifact(owner.project, artifactReference).catch((error48) => {
16069
16081
  if (!(error48 instanceof DispatchServiceError) || error48.status !== 404)
16070
16082
  throw error48;
16071
- const artifacts = await client.listProjectArtifacts(owner.project, true);
16072
- return {
16073
- owner,
16074
- artifact: artifactByReference(artifacts, artifactReference, "project")
16075
- };
16076
- }
16083
+ return;
16084
+ });
16085
+ if (canonical && routed !== undefined)
16086
+ return { owner, artifact: routed };
16087
+ const unlinked = await client.listProjectArtifacts(owner.project, true);
16088
+ const artifacts = routed === undefined ? unlinked : [routed, ...unlinked.filter((candidate) => candidate.id !== routed.id)];
16089
+ return {
16090
+ owner,
16091
+ artifact: artifactByReference(artifacts, artifactReference, "project", canonical)
16092
+ };
16077
16093
  }
16078
16094
  const issue2 = await client.getIssue(owner.issue);
16079
16095
  let artifact;
16080
16096
  if (artifactReference === undefined || artifactReference === "spec") {
16081
16097
  artifact = issue2.artifacts.find((candidate) => candidate.primary || candidate.id === issue2.primary_artifact_id);
16082
16098
  } else {
16083
- artifact = artifactByReference(issue2.artifacts, artifactReference, "issue");
16099
+ artifact = artifactByReference(issue2.artifacts, artifactReference, "issue", canonical);
16084
16100
  }
16085
16101
  if (!artifact) {
16086
16102
  throw new Error(documentReferenceProblem(artifactReference ?? "spec", issue2.artifacts, "issue"));
@@ -16088,10 +16104,10 @@ async function resolveArtifact(client, owner, artifactReference) {
16088
16104
  return { owner, issue: issue2, artifact };
16089
16105
  }
16090
16106
  var documentHintLimit = 8;
16091
- function documentReferenceProblem(reference, documents, owner, ambiguous = false) {
16092
- const hints = documents.slice(0, documentHintLimit).map((document) => `${document.slug} (${document.name})`);
16107
+ function documentReferenceProblem(reference, documents, owner, use) {
16108
+ const hints = documents.slice(0, documentHintLimit).map((document) => use === "id" ? `${document.id} (${document.slug}, ${document.name})` : `${document.slug} (${document.name})`);
16093
16109
  const list = hints.length === 0 ? "none" : hints.join(", ");
16094
- return ambiguous ? `"${reference}" names ${documents.length} documents on this ${owner}; use a slug: ${list}` : `document "${reference}" not found by slug; this ${owner}'s documents: ${list}`;
16110
+ return use === undefined ? `document "${reference}" not found by slug; this ${owner}'s documents: ${list}` : `"${reference}" names ${documents.length} documents on this ${owner}; use ${use === "id" ? "the id" : "a slug"}: ${list}`;
16095
16111
  }
16096
16112
  var askHintLimit = 8;
16097
16113
  function askIDInputProblem(asks, scope) {
@@ -16481,6 +16497,10 @@ async function executeDispatchTool(input) {
16481
16497
  const args = parsed.success ? parsed.data : ownerArguments.args;
16482
16498
  const actor = toolActor(await resolveOrigin(env, exec, input.cwd), input);
16483
16499
  const client = dispatchClient();
16500
+ const refDocument = ownerArguments.ref?.kind === "artifact" ? ownerArguments.ref.id : ownerArguments.ref?.artifact;
16501
+ const resolveDocument = (documentOwner2, reference) => resolveArtifact(client, documentOwner2, reference, {
16502
+ canonical: reference !== undefined && reference === refDocument
16503
+ });
16484
16504
  const owner = ownerArguments.owner?.kind === "issue" ? {
16485
16505
  kind: "issue",
16486
16506
  issue: await resolveExistingIssue(client, ownerArguments.owner.issue)
@@ -16771,7 +16791,7 @@ async function executeDispatchTool(input) {
16771
16791
  const issueKey = ref.owner.issue;
16772
16792
  id = await resolveIdPrefix(input.tool, "comment", ref.id, refOwnerName(ref), () => client.getComments(issueKey));
16773
16793
  } else if (ref !== null) {
16774
- const artifact = (await resolveArtifact(client, ref.owner, ref.artifact)).artifact;
16794
+ const artifact = (await resolveArtifact(client, ref.owner, ref.artifact, { canonical: true })).artifact;
16775
16795
  document = artifact;
16776
16796
  id = await resolveIdPrefix(input.tool, "comment", ref.id, refOwnerName(ref), () => client.getArtifactComments(artifact.id));
16777
16797
  }
@@ -16783,7 +16803,7 @@ async function executeDispatchTool(input) {
16783
16803
  const anchorArgs = asObject(args.anchor);
16784
16804
  const owner2 = documentOwner();
16785
16805
  const artifactReference = optionalString(args, "artifact") ?? (anchorArgs === null ? undefined : optionalString(anchorArgs, "artifact"));
16786
- const resolved = owner2.kind === "project" || artifactReference === undefined ? owner2.kind === "project" ? await resolveArtifact(client, owner2, artifactReference) : undefined : await resolveArtifact(client, owner2, artifactReference);
16806
+ const resolved = owner2.kind === "project" || artifactReference === undefined ? owner2.kind === "project" ? await resolveDocument(owner2, artifactReference) : undefined : await resolveDocument(owner2, artifactReference);
16787
16807
  const options = args.options;
16788
16808
  const multiple = optionalBoolean(args, "multiple");
16789
16809
  const urgency = askUrgency(args);
@@ -16833,7 +16853,7 @@ ${followsAsk(askOwner)}`,
16833
16853
  case "dispatch_comment": {
16834
16854
  const artifactReference = optionalString(args, "artifact");
16835
16855
  const owner2 = documentOwner();
16836
- const resolved = owner2.kind === "project" || artifactReference === undefined ? owner2.kind === "project" ? await resolveArtifact(client, owner2, artifactReference) : undefined : await resolveArtifact(client, owner2, artifactReference);
16856
+ const resolved = owner2.kind === "project" || artifactReference === undefined ? owner2.kind === "project" ? await resolveDocument(owner2, artifactReference) : undefined : await resolveDocument(owner2, artifactReference);
16837
16857
  const anchored = resolved ? anchor(resolved.artifact, args) : undefined;
16838
16858
  const replyTo = optionalString(args, "reply_to");
16839
16859
  const replyToAskReference = optionalString(args, "reply_to_ask");
@@ -16886,7 +16906,7 @@ ${followsAsk(askOwner)}`,
16886
16906
  };
16887
16907
  }
16888
16908
  case "dispatch_suggest": {
16889
- const resolved = await resolveArtifact(client, documentOwner(), stringArg(args, "artifact"));
16909
+ const resolved = await resolveDocument(documentOwner(), stringArg(args, "artifact"));
16890
16910
  const anchored = anchor(resolved.artifact, args);
16891
16911
  if (anchored === undefined)
16892
16912
  throw new Error("quote is required");
@@ -16941,7 +16961,7 @@ ${followsAsk(askOwner)}`,
16941
16961
  };
16942
16962
  }
16943
16963
  case "dispatch_doc_edit": {
16944
- const resolved = await resolveArtifact(client, documentOwner(), stringArg(args, "artifact"));
16964
+ const resolved = await resolveDocument(documentOwner(), stringArg(args, "artifact"));
16945
16965
  const ops = args.ops;
16946
16966
  const summary = optionalString(args, "summary");
16947
16967
  const { precondition: rawPrecondition } = args;
@@ -16983,7 +17003,7 @@ ${followsAsk(askOwner)}`,
16983
17003
  }
16984
17004
  case "dispatch_doc_read": {
16985
17005
  const artifactReference = optionalString(args, "artifact") ?? (ownerArguments.ref?.kind === "spec" || ownerArguments.ref?.kind === "artifact" ? ownerArguments.ref.id : undefined);
16986
- const resolved = await resolveArtifact(client, documentOwner(), artifactReference);
17006
+ const resolved = await resolveDocument(documentOwner(), artifactReference);
16987
17007
  const version2 = optionalNumber(args, "version") ?? ownerArguments.ref?.version;
16988
17008
  const documentPromise = client.docRead(resolved.artifact.id, version2);
16989
17009
  const marksPromise = openArtifactMarks(client, resolved);
@@ -17012,7 +17032,7 @@ ${trailer.join(`
17012
17032
  }
17013
17033
  case "dispatch_request_approval": {
17014
17034
  const artifactReference = optionalString(args, "artifact") ?? (ownerArguments.ref?.kind === "spec" || ownerArguments.ref?.kind === "artifact" ? ownerArguments.ref.id : undefined);
17015
- const resolved = await resolveArtifact(client, documentOwner(), artifactReference);
17035
+ const resolved = await resolveDocument(documentOwner(), artifactReference);
17016
17036
  const result = await client.requestApproval(resolved.artifact.id, { actor });
17017
17037
  if (result.ask === null) {
17018
17038
  return {
@@ -17090,7 +17110,7 @@ ${trailer.join(`
17090
17110
  case "dispatch_read": {
17091
17111
  if (ownerArguments.ref?.kind === "ask") {
17092
17112
  const ref = ownerArguments.ref;
17093
- const id = await resolveIdPrefix(input.tool, "ask", ref.id, refOwnerName(ref), async () => ref.owner.kind === "issue" ? client.listIssueAsks(ref.owner.issue) : client.getArtifactAsks((await resolveArtifact(client, ref.owner, ref.artifact)).artifact.id, "all"));
17113
+ 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"));
17094
17114
  const askRead = await client.getAsk(id);
17095
17115
  const askRef = refTarget(ref, "ask", id);
17096
17116
  return {
@@ -17100,7 +17120,7 @@ ${trailer.join(`
17100
17120
  }
17101
17121
  if (ownerArguments.ref?.kind === "comment") {
17102
17122
  const ref = ownerArguments.ref;
17103
- const id = await resolveIdPrefix(input.tool, "comment", ref.id, refOwnerName(ref), async () => ref.owner.kind === "issue" ? client.getComments(ref.owner.issue) : client.getArtifactComments((await resolveArtifact(client, ref.owner, ref.artifact)).artifact.id));
17123
+ const id = await resolveIdPrefix(input.tool, "comment", ref.id, refOwnerName(ref), async () => ref.owner.kind === "issue" ? client.getComments(ref.owner.issue) : client.getArtifactComments((await resolveDocument(ref.owner, ref.artifact)).artifact.id));
17104
17124
  const comment = await client.getComment(id);
17105
17125
  const commentRef = refTarget(ref, "comment", id);
17106
17126
  return {
@@ -17120,7 +17140,7 @@ ${trailer.join(`
17120
17140
  };
17121
17141
  }
17122
17142
  if (documentOwner().kind === "project") {
17123
- const resolved = await resolveArtifact(client, documentOwner(), stringArg(args, "artifact"));
17143
+ const resolved = await resolveDocument(documentOwner(), stringArg(args, "artifact"));
17124
17144
  const documentRef = dispatchDocumentRef(resolved.artifact.project, resolved.artifact.slug);
17125
17145
  return {
17126
17146
  text: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "3.9.0",
3
+ "version": "3.10.1",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -603,7 +603,8 @@ dispatch_doc_read({ issue?, project?, artifact?, version?, ref? })
603
603
  ```
604
604
  It returns live or versioned markdown with open marks. A live read ends with a document token; `issue` with an
605
605
  omitted `artifact` reads the issue specification; a project needs `artifact`; and a
606
- `dispatch://PROJECT/artifact/<document-ref>` ref supplies both, where `document-ref` is the id, slug, or filename.
606
+ `dispatch://PROJECT/artifact/<document-ref>` ref supplies both, where `document-ref` is the slug (an id or a
607
+ filename resolves when no document has that slug).
607
608
 
608
609
  Editing one is [Editing a document](references/document-edits.md): the shape of `dispatch_doc_edit`,
609
610
  how to quote the text you mean, one `replace` per paragraph, preconditions against a stale edit, and
@@ -741,7 +742,9 @@ Exactly one of `path` and `content` is required. It returns issue or project-doc
741
742
  Uploading the same `name` creates its next version — so uploading `spec.md` **replaces the issue's own specification**
742
743
  with your text. Never do that: the spec is edited in place with `dispatch_doc_edit` (see [Editing a document](references/document-edits.md)). Address an existing
743
744
  artifact by the slug shown in the upload result or by its filename, and a project document by its artifact id, slug, or filename; the
744
- slug also arrives on `artifact.created` events.
745
+ slug also arrives on `artifact.created` events. Dispatch suffixes a slug two documents would share, so one document's
746
+ filename can be another's slug (`plan v2` takes `plan-v2`, then a document named `plan-v2` takes `plan-v2-2`): a bare
747
+ `artifact` that names both is refused with each one's id, while a `dispatch://` reference's document part is always the slug.
745
748
 
746
749
  **Where a deliverable goes.** Text the human must read to decide — a draft message, a proposal,
747
750
  a summary — goes in the spec as a section: the spec is the one document they open. A separate
@@ -255,16 +255,20 @@ Preserve this order exactly:
255
255
  with options for its outcomes, and the issue waits for it.
256
256
 
257
257
  What returns the tree to review: a changed diff — a commit above the approved head that
258
- touches anything outside `docs/solutions/`, or a rebase whose fingerprint
258
+ touches anything outside `docs/solutions/`, or a conflict-resolution merge whose fingerprint
259
259
  (`skill://legion-worker`'s unchanged-diff check) differs from the approved head's. What does not: retro's
260
- `docs/solutions/` commit, and a rebase forced by a GitHub-reported conflict whose fingerprint
261
- is unchanged. For that rebase the order is: the implementer rebases, pushes the rebased chain with
262
- `legion-worker`'s procedure for rewritten commits, and posts the before/after fingerprints; the tester re-runs the bare gates only; the reviewer confirms and approves the new
263
- head by SHA (or continues its round if it had not approved); the merger republishes READY.
264
- Retro does not re-run. A rebase happens only when GitHub reports `CONFLICTING`
260
+ `docs/solutions/` commit, and a merge forced by a GitHub-reported conflict whose fingerprint
261
+ is unchanged. For that merge the order is: the implementer merges the bookmark forward with the
262
+ destination (`legion-worker`'s forward-merge procedure — `jj new legion/<KEY> <destination>`,
263
+ never a rebase, since a rebase rewrites every descendant of the chain's fork point, including
264
+ another tree's branch stacked on it), pushes it with the ordinary push procedure (a genuine
265
+ fast-forward), and posts the before/after fingerprints; the tester re-runs the bare gates only;
266
+ the reviewer confirms and approves the new head by SHA (or continues its round if it had not
267
+ approved); the merger republishes READY. Retro does not re-run. This merge happens only when
268
+ GitHub reports `CONFLICTING`
265
269
  (`legion gh -- pr view <n> --json mergeable,mergeStateStatus`); read that on every end-game
266
270
  wake — `pr-ready`, `pr-review`, `phase-complete`, `catchup-overseer` — because a `CONFLICTING`
267
- PR gets no CI and no wake announces it, and send the implementer to rebase the moment you see
271
+ PR gets no CI and no wake announces it, and send the implementer to resolve it the moment you see
268
272
  it. Do not let the merger publish `READY` for an obsolete approval.
269
273
 
270
274
  If a worker reports that `legion threads resolve` exited 1 naming a review thread GitHub refused
@@ -280,7 +280,7 @@ Verified the implementer's proof by <re-running its command | driving the same s
280
280
  **Fast-follow:** <one named cleanup item and where it will land>, or "none".
281
281
 
282
282
  **Chain:** stacked on <base bookmark> frozen at <sha> / not stacked.
283
- **Retarget:** Retargeting a pull request to a new base does not re-run Tests; after a retarget, record the pushed tip, rebase onto the new base, and push with `legion-worker`'s procedure for rewritten commits — the new head runs Tests against the new merge result — and cite that run in the PR body.
283
+ **Retarget:** Retargeting a pull request to a new base does not re-run Tests; after a retarget, merge the bookmark onto the new base (`jj new legion/<KEY> <new base> -m "<message>"`) and push with `legion-worker`'s ordinary push procedure — a genuine fast-forward, never the procedure for rewritten commits — the new head runs Tests against the new merge result — and cite that run in the PR body.
284
284
  ```
285
285
 
286
286
  **A proof** is the changed behaviour exercised on the surface a user reaches it through, recorded
@@ -362,9 +362,30 @@ this proof.
362
362
  the fingerprint at the current tip; after pushing the rebased branch, record it at the new
363
363
  tip; post one PR comment (Legion footer):
364
364
  `rebase <old-tip-sha> → <new-tip-sha>; fingerprint <before> → <after>; unchanged|changed`.
365
- Rebase the whole chain — `jj -R "$LEGION_WORKSPACE" rebase -s 'roots(main@origin..@)' -d main@origin` —
366
- so the tester's and reviewer's commits move with yours. Record the pushed tip before it and
367
- push the rebased chain with the push procedure (*Rewriting pushed commits*, below).
365
+ Every issue workspace is a `jj workspace` of the same shared repository and operation log, and
366
+ jj always rebases every descendant of any commit it rewrites — a revset naming the root of your
367
+ own chain and rewriting it in place also rewrites whatever another tree has stacked on that root,
368
+ whichever selector chose it (`-s`, `-b`, and `-r` all rewrite descendants; `-r` only re-parents
369
+ them to fill the hole, which is worse). This is what happened in LEGION-118: one issue's own
370
+ conflict step moved a second issue's twelve commits and its bookmark onto a conflicted copy.
371
+ Resolve the conflict with a forward merge instead of a rewrite — merge the branch's own
372
+ bookmark with the destination in one new commit, so nothing existing is rewritten and nothing
373
+ built on your prior commits, in this tree or another, ever moves:
374
+
375
+ ```bash
376
+ jj -R "$LEGION_WORKSPACE" new legion/<KEY> main@origin -m "merge: resolve conflict against main@origin"
377
+ ```
378
+
379
+ Merge from the bookmark, never from `@`: a handoff split leaves `@` an empty, undescribed
380
+ commit above the described one the bookmark already names, and `jj git push` refuses to push
381
+ any commit without a description — merging from `@` drags that undescribed commit into the
382
+ ancestry and the push fails (`Won't push commit … since it has no description`); the bookmark
383
+ is always on a described, already-pushed commit. If the merge conflicts, resolve it in that
384
+ one commit — edit the markers directly; there is nothing to squash, since the merge is the
385
+ only new commit. Then `jj -R "$LEGION_WORKSPACE" new` to move off it, and push with the one
386
+ push procedure (*Every role pushes its own commits*, below): the merge descends from both the
387
+ bookmark's old position and the destination, so it is a genuine fast-forward and *Rewriting
388
+ pushed commits* never applies — nothing was rewritten, so there is no tip to record first.
368
389
  - **No deferrals.** Sami, 2026-09-11, verbatim: "My rule is no deferrals." The `Fast-follow:`
369
390
  field names naming, duplication, or wording cleanup only; anything that changes behaviour,
370
391
  hides an error, or breaks a gate lands in this PR.
@@ -586,8 +607,8 @@ remote branch sideways onto your commit and drops theirs (jj 0.45.1:
586
607
  `bookmark: legion/K [move sideways from <theirs> to <yours>]`). A clone that has not seen the other
587
608
  push is refused by jj itself (`unexpectedly moved on the remote`).
588
609
 
589
- **Rewriting pushed commits** — the conflict-forced rebase, the rebase after a retarget, or a
590
- `jj squash --into` a commit already on GitHub — leaves the pushed tip outside `::@-`, so record
610
+ **Rewriting pushed commits** — a `jj squash --into` a commit already on GitHub, or any other
611
+ rewrite of a commit you already pushed — leaves the pushed tip outside `::@-`, so record
591
612
  that tip first, after a fetch and while your chain still descends from it:
592
613
 
593
614
  ```bash