@sjawhar/opencode-legion-envoy 3.11.3 → 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.
@@ -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, and a second call with the same in_reply_to posts nothing because " + "Dispatch keeps the one reply per message. Every other message names its issue. " + `Body is at most 2,000 characters. ${ISSUE_REFERENCE}`,
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 " + "contents; use dispatch_doc_read instead. Supply ref, issue, or project plus artifact. " + "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,
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: documentOwnerValidation(true)
14256
+ validation: readValidation
14246
14257
  },
14247
14258
  {
14248
14259
  name: "dispatch_search",
@@ -14459,6 +14470,7 @@ var LEGION_ROLES = [
14459
14470
 
14460
14471
  // ../contracts/src/legion-daemon-api.ts
14461
14472
  var nonEmptyString = exports_external.string().min(1);
14473
+ var appLogin = exports_external.string().regex(/^[^[\]]+\[bot\]$/);
14462
14474
  var legionRole = exports_external.enum(LEGION_ROLES);
14463
14475
  var requiredUnknown = exports_external.unknown().refine((value) => value !== undefined, {
14464
14476
  message: "Required"
@@ -14712,7 +14724,11 @@ var LegionDaemonApi = {
14712
14724
  },
14713
14725
  GitHubToken: {
14714
14726
  request: exports_external.strictObject({ grantId: nonEmptyString }),
14715
- response: exports_external.object({ token: nonEmptyString, appLogin: exports_external.string().endsWith("[bot]") })
14727
+ response: exports_external.object({
14728
+ token: nonEmptyString,
14729
+ appLogin: exports_external.string().endsWith("[bot]"),
14730
+ legionAppLogins: exports_external.object({ implement: appLogin, review: appLogin }).optional()
14731
+ })
14716
14732
  },
14717
14733
  GitCredential: {
14718
14734
  request: exports_external.strictObject({ grantId: nonEmptyString })
@@ -15205,8 +15221,8 @@ class DispatchClient {
15205
15221
  async message(issue2, input) {
15206
15222
  return this.#json("POST", ["api", "v1", "issues", await this.#resolveIssue(issue2), "messages"], input);
15207
15223
  }
15208
- async messageReply(id, input) {
15209
- 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);
15210
15226
  }
15211
15227
  async getMessage(issue2, id) {
15212
15228
  return this.#json("GET", [
@@ -15218,6 +15234,9 @@ class DispatchClient {
15218
15234
  id
15219
15235
  ]);
15220
15236
  }
15237
+ async getMessageThread(id, session) {
15238
+ return this.#json("GET", ["api", "v1", "messages", id], undefined, { session });
15239
+ }
15221
15240
  async artifact(issue2, input) {
15222
15241
  const artifactPath = ["api", "v1", "issues", await this.#resolveIssue(issue2), "artifacts"];
15223
15242
  if ("content" in input)
@@ -15892,6 +15911,13 @@ function argumentProblems(tool, args) {
15892
15911
  }
15893
15912
  break;
15894
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
+ }
15895
15921
  }
15896
15922
  return problems;
15897
15923
  }
@@ -16029,6 +16055,9 @@ async function resolveOwnerArguments(tool, input, cwd, env, exec, serverUrl, pro
16029
16055
  if (tool === "dispatch_message" && typeof replyTarget === "string" && !replyTarget.startsWith("dispatch://")) {
16030
16056
  return { args, ref, owner: null };
16031
16057
  }
16058
+ if (tool === "dispatch_read" && typeof args.message === "string") {
16059
+ return { args, ref, owner: null };
16060
+ }
16032
16061
  const legionIssue = env.LEGION_ISSUE;
16033
16062
  if (!legionIssue) {
16034
16063
  problems.push(ownerRequiredProblem);
@@ -16948,16 +16977,30 @@ ${followsAsk(askOwner)}`,
16948
16977
  const inReplyTo = messageInReplyTo(args);
16949
16978
  const body = stringArg(args, "body");
16950
16979
  if (owner === null && inReplyTo !== undefined) {
16951
- 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
+ }
16952
16987
  if (reply.body !== body) {
16953
16988
  return {
16954
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.",
16955
16990
  details: { message: reply.id, in_reply_to: inReplyTo, posted: false }
16956
16991
  };
16957
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;
16958
16996
  return {
16959
- text: `Replied to message ${inReplyTo} with message ${reply.id}`,
16960
- details: { message: reply.id, in_reply_to: inReplyTo, posted: true }
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
+ }
16961
17004
  };
16962
17005
  }
16963
17006
  const issueKey = issue2();
@@ -17129,6 +17172,21 @@ ${trailer.join(`
17129
17172
  };
17130
17173
  }
17131
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
+ }
17132
17190
  if (ownerArguments.ref?.kind === "ask") {
17133
17191
  const ref = ownerArguments.ref;
17134
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "3.11.3",
3
+ "version": "3.11.5",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -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 on your agent card. Every other message still names its issue, so keep the `issue`
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
- One reply per message they send. A second call with the same `in_reply_to` posts nothing:
902
- Dispatch answers it with the reply already stored, and the tool result says the message was
903
- already answered rather than reporting a send. That happens most often when your host answered
904
- the **BTW** automatically before you got here — read the result before writing again, and wait
905
- for their next message instead.
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, and a conflict-forced
192
- rebase legitimately sets the committer of every rebased commit, other roles' included, to the
193
- rebaser. A wrong identity on your own commit, the other App or none, is a pane-environment
194
- problem to report to the architect, not something to pin
195
- (`docs/solutions/legion/shared-main-repo-hazards-for-concurrent-issue-workspaces.md`, Hazard 1).
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
 
@@ -285,9 +291,11 @@ later phase keeps it current rather than replacing it:
285
291
  - Thread <id>: fixed in <commit-sha> — <one line>.
286
292
  - Thread <id>: not a defect — <reason>.
287
293
  `legion threads resolve --pr <n> --repo <owner>/<repo>` at <head-sha>:
288
- resolved <thread URL>
294
+ resolved <thread URL> — its opener's acceptance
295
+ resolved <thread URL> — the Legion reviewer's acceptance of a bot's thread
289
296
  left open <thread URL> — newest reply by <login> is not an acceptance
290
297
  left open <thread URL> — newest reply by <login> is an unsubmitted draft in a pending review
298
+ left open <thread URL> — newest reply by <login> is not its opener's or the Legion reviewer's acceptance
291
299
 
292
300
  **Thermo:** `ce-simplify-code` once at <head-sha>: <0 applied | applied → new head <sha>>; thermonuclear pair at the final head <sha>:
293
301
  <verdict>. (omitted entirely on a docs-only PR — there is no code for either pass, so neither runs)
@@ -329,7 +337,8 @@ this proof.
329
337
 
330
338
  - **Threads are dispositioned individually, never resolved in bulk.** Every open review
331
339
  thread gets its own line naming the fixing commit or the reason it isn't a defect. The
332
- reviewer answers each thread it opened with exactly one of `Accepted: fixed in <commit> — <one line>`,
340
+ reviewer answers each thread it opened, and each thread a bot opened that is none of Legion's
341
+ role Apps, with exactly one of `Accepted: fixed in <commit> — <one line>`,
333
342
  `Accepted: not a defect — <reason>`, or `Still open: <what remains>`; nothing else is an
334
343
  acceptance, and nobody replies after an `Accepted:` (any later reply that is not itself an
335
344
  `Accepted:` — the opener's own follow-up included — leaves the thread open, because resolution
@@ -341,9 +350,16 @@ this proof.
341
350
  In a Legion pane, the **implementer** runs the command before every push that answers a review
342
351
  (the corrective push and the final `.legion/` deletion push) and pastes its output into the
343
352
  `Threads` section. The command resolves each unresolved thread whose newest submitted comment is
344
- the opener's own `Accepted:` reply, one `resolveReviewThread` per thread, prints `resolved <url>`
345
- or `left open <url> — newest reply by <login> is not an acceptance`, and exits 1 naming the
346
- thread's URL and GitHub's message when GitHub refuses one.
353
+ the opener's own `Accepted:` reply. On a thread a bot account opened that is none of Legion's
354
+ role Apps (the daemon names them, keyed by App role), the Legion reviewer's `Accepted:` also
355
+ closes it. GitHub cannot tell a CI bot, which never accepts, from a person whose `gh` is routed
356
+ to an App, so the reviewer adjudicates such a finding, and it may accept one an App-routed person
357
+ raised. The subject of a finding never closes it: the implementer's `Fixed in <commit>: …` or
358
+ `Declined: …` answers a thread and closes none. A thread either Legion App opened, a reviewer's
359
+ finding included, still needs its opener's `Accepted:`. It makes one `resolveReviewThread` per
360
+ thread, prints `resolved <url> — <whose acceptance>` (its opener's, or the Legion reviewer's on a
361
+ bot's thread, so the ledger shows which) or `left open <url> — newest reply by <login> is …`
362
+ naming why, and exits 1 naming the thread's URL and GitHub's message when GitHub refuses one.
347
363
 
348
364
  Without a grant, page through `reviewThreads`, skip `isResolved: true`, and compare the opener
349
365
  with the newest comment. Query shape, inside `repository { pullRequest { … } }`:
@@ -353,15 +369,18 @@ this proof.
353
369
  pageInfo { hasNextPage endCursor }
354
370
  nodes {
355
371
  id isResolved
356
- opener: comments(first: 1) { nodes { author { login } } }
357
- newest: comments(last: 1) { nodes { author { login } body state } }
372
+ opener: comments(first: 1) { nodes { author { __typename login } } }
373
+ newest: comments(last: 1) { nodes { author { __typename login } body state } }
358
374
  }
359
375
  }
360
376
  ```
361
377
 
362
- Resolve only when the newest comment is submitted, its `author { login }` equals the opener's,
363
- and its `body`, after removing leading spaces, tabs, CR, and LF, begins `Accepted:`. For each
364
- such thread:
378
+ Resolve only when the newest comment is submitted, its `author` is the opener's account (the same
379
+ `__typename` and `login`: a login alone is a string anyone may register), and its `body`, after
380
+ removing leading spaces, tabs, CR, and LF, begins `Accepted:`. Without a
381
+ grant nothing names Legion's own App logins, so this route closes a bot's thread only on its
382
+ opener's `Accepted:`: leave one the Legion reviewer accepted for the implementer's or merger's
383
+ run in a pane, or report it. For each thread to resolve:
365
384
 
366
385
  ```graphql
367
386
  mutation($threadId: ID!) {
@@ -377,14 +396,17 @@ this proof.
377
396
  changes behavior, hides an error, or breaks a gate is fixed here — never deferred.
378
397
  Findings about naming, duplication, or wording are batched into the single `Fast-follow`
379
398
  line instead of iterating per push.
380
- - **Rebase only on a real conflict, except after a base retarget.** Sami, 2026-09-11, verbatim:
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:
381
401
  "Please don't do unnecessary rebases (i.e. unless there are merge conflicts). The CI queue is too long and slow."
382
- The implementer rebases the issue branch only when GitHub reports it `CONFLICTING`, the controller asks
383
- because of a conflict, or after the pull request is retargeted to a new base. Otherwise, never rebase to
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
384
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
385
405
  pushing a new commit. A conflict-forced rebase that leaves the branch's diff unchanged is a
386
- confirmation, not a new round (see *The unchanged-diff check* below). Before rebasing, record
387
- the fingerprint at the current tip; after pushing the rebased branch, record it at the new
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
388
410
  tip; post one PR comment (Legion footer):
389
411
  `rebase <old-tip-sha> → <new-tip-sha>; fingerprint <before> → <after>; unchanged|changed`.
390
412
  Every issue workspace is a `jj workspace` of the same shared repository and operation log, and
@@ -633,12 +655,19 @@ remote branch sideways onto your commit and drops theirs (jj 0.45.1:
633
655
  push is refused by jj itself (`unexpectedly moved on the remote`).
634
656
 
635
657
  **Rewriting pushed commits** — a `jj squash --into` a commit already on GitHub, or any other
636
- rewrite of a commit you already pushed — leaves the pushed tip outside `::@-`, so record
637
- that tip first, after a fetch and while your chain still descends from it:
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:
638
664
 
639
665
  ```bash
640
666
  cd -- "$LEGION_WORKSPACE" && \
641
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; }; } && \
642
671
  behind=$(jj -R "$LEGION_WORKSPACE" log --no-graph -T 'commit_id.short() ++ "\n"' \
643
672
  -r 'remote_bookmarks(exact:"legion/<KEY>", exact:"origin") ~ ::@-') && \
644
673
  { [ -z "$behind" ] || { echo "legion/<KEY>@origin is at $behind, which @- does not descend from" >&2; false; }; } && \
@@ -647,6 +676,13 @@ cd -- "$LEGION_WORKSPACE" && \
647
676
  >"${TMPDIR:-/tmp}/legion-<KEY>-$LEGION_ROLE-rewritten-tip"
648
677
  ```
649
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
+
650
686
  Then rewrite, resolve, and push with the procedure above. It lets the remote branch sit on the
651
687
  tip you recorded, which the rewrite replaced, and on nothing else: when another role pushed after
652
688
  you recorded it, the push is refused. The push deletes the file.