@sjawhar/pi-legion-envoy 5.1.0 → 5.2.0

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/envoy.js CHANGED
@@ -34979,8 +34979,15 @@ var ROLE_CLAIM_ENTRY = "envoy-role-claim";
34979
34979
  var OPEN_ASKS_TIMEOUT_MS = 3000;
34980
34980
  var LEGION_MANAGED_ENTRY = "legion-managed-session";
34981
34981
  var ASK_REMINDER_MESSAGE = "dispatch-ask-reminder";
34982
- var OPEN_ASKS_REMINDER = "You have no unanswered asks in Dispatch. If you are waiting for human input, open an ask. Otherwise ignore this reminder and continue with any remaining work. Do not reply just to acknowledge this reminder.";
34982
+ var ASK_SELF_CHECK_PROMPT = "Your run has just ended. Answer with exactly one word and nothing else: WAITING or PROCEEDING. " + "WAITING \u2014 you stopped because you need a decision, an approval, or information from a human. " + "PROCEEDING \u2014 you finished, you will carry on by yourself, or you are waiting only on tools, " + "subagents, or events.";
34983
+ var WAITING_VERDICT = /^\W*WAITING\b/;
34984
+ var ECHOED_CHOICE = /\bPROCEEDING\b/;
34985
+ var isWaitingVerdict = (reply) => WAITING_VERDICT.test(reply) && !ECHOED_CHOICE.test(reply);
34986
+ var ASK_SELF_CHECK_TIMEOUT_MS = 60000;
34987
+ var ASK_CHECKS_PER_PERIOD = 5;
34988
+ var UNASKED_WAIT_REMINDER = "You just said you are waiting on a human, but you have no open ask in Dispatch, so nobody knows you are waiting. Open it now with dispatch_ask \u2014 or dispatch_request_approval when what you need is approval of a document \u2014 naming exactly what you need and from whom. Do not reply just to acknowledge this reminder.";
34983
34989
  var ASK_OPENING_TOOLS = ["dispatch_ask", "dispatch_request_approval"];
34990
+ var DISPATCH_TOOL_PREFIX = "dispatch_";
34984
34991
  function isLegionManagedEntry(entry) {
34985
34992
  if (typeof entry !== "object" || entry === null)
34986
34993
  return false;
@@ -35051,11 +35058,23 @@ function envoyExtension(pi) {
35051
35058
  session_id: "",
35052
35059
  period: 0,
35053
35060
  baseline_as_of: null,
35054
- fired: false,
35055
- saw_ask: false
35061
+ check_due: false,
35062
+ checks: 0
35056
35063
  };
35057
35064
  let awarenessGeneration = 0;
35058
35065
  let askCheckInFlight = false;
35066
+ let settledDuringCheck;
35067
+ const takeSettledDuringCheck = () => {
35068
+ const settle = settledDuringCheck;
35069
+ settledDuringCheck = undefined;
35070
+ return settle;
35071
+ };
35072
+ let askCheckAbort;
35073
+ const abortSelfCheck = (reason) => {
35074
+ askCheckAbort?.abort(new Error(reason));
35075
+ askCheckAbort = undefined;
35076
+ };
35077
+ let runSeq = 0;
35059
35078
  let legionManagedTranscript = false;
35060
35079
  const legionManaged = (id) => legionManagedTranscript || legionRoleClaimBridge().managedSessions.has(id);
35061
35080
  const availabilityWarningSessionIDs = new Set;
@@ -35066,6 +35085,19 @@ function envoyExtension(pi) {
35066
35085
  availabilityWarningSessionIDs.add(sessionID2);
35067
35086
  context.ui.notify(`envoy: Dispatch open-ask check unavailable (${messageFor(error48)}); the stop-time ask reminder is off until it recovers`, "warning");
35068
35087
  };
35088
+ const askEphemeral = pi.askEphemeral;
35089
+ const overriddenTimeout = Number(process.env.ENVOY_SELF_CHECK_TIMEOUT_MS);
35090
+ const selfCheckTimeoutMs = Number.isFinite(overriddenTimeout) && overriddenTimeout > 0 ? overriddenTimeout : ASK_SELF_CHECK_TIMEOUT_MS;
35091
+ const selfCheckWarningSessionIDs = new Set;
35092
+ const logSelfCheckFailure = (failedSessionID, error48) => {
35093
+ if (selfCheckWarningSessionIDs.has(failedSessionID))
35094
+ return;
35095
+ selfCheckWarningSessionIDs.add(failedSessionID);
35096
+ logger.warn("envoy: run-end waiting self-check failed; no reminder was sent", {
35097
+ sessionID: failedSessionID,
35098
+ error: messageFor(error48)
35099
+ });
35100
+ };
35069
35101
  const queryOpenAsks = async (requestedSessionID, since) => {
35070
35102
  const config2 = activeDispatchConfig();
35071
35103
  if (config2 === null)
@@ -35086,15 +35118,18 @@ function envoyExtension(pi) {
35086
35118
  sessionID = context.sessionManager.getSessionId();
35087
35119
  activeSessionContext = context;
35088
35120
  const branch = context.sessionManager.getBranch?.() ?? [];
35089
- askAwareness = {
35090
- session_id: sessionID,
35091
- period: 0,
35092
- baseline_as_of: null,
35093
- fired: false,
35094
- saw_ask: false
35095
- };
35121
+ if (askAwareness.session_id !== sessionID) {
35122
+ abortSelfCheck(`session changed from ${askAwareness.session_id || "none"} to ${sessionID}`);
35123
+ askAwareness = {
35124
+ session_id: sessionID,
35125
+ period: 0,
35126
+ baseline_as_of: null,
35127
+ check_due: false,
35128
+ checks: 0
35129
+ };
35130
+ awarenessGeneration++;
35131
+ }
35096
35132
  legionManagedTranscript = branch.some(isLegionManagedEntry);
35097
- awarenessGeneration++;
35098
35133
  };
35099
35134
  pi.on("resources_discover", async () => ({ skillPaths: [SKILLS_DIRECTORY] }));
35100
35135
  registerEnvoyMessageRenderer(pi);
@@ -35610,69 +35645,148 @@ function envoyExtension(pi) {
35610
35645
  registerEnvoyWhoamiCommand(pi, () => sessionID);
35611
35646
  pi.on("before_agent_start", async (event, context) => {
35612
35647
  const id = context.sessionManager.getSessionId();
35613
- if (id === "" || event.prompt.trim() === "" || !context.hasUI || legionManaged(id)) {
35648
+ if (id === "" || event.prompt.trim() === "" || !context.hasUI || askEphemeral === undefined || legionManaged(id)) {
35614
35649
  return;
35615
35650
  }
35616
35651
  if (await isSubagent(context))
35617
35652
  return;
35653
+ awarenessGeneration++;
35654
+ abortSelfCheck("a new user turn superseded the self-check");
35618
35655
  try {
35619
35656
  const open = await queryOpenAsks(id);
35620
35657
  if (open !== null)
35621
- armAskAwareness(id, event.prompt, open.snapshot.as_of);
35658
+ armAskAwareness(id, open.snapshot.as_of);
35622
35659
  } catch (error48) {
35623
35660
  warnAskAvailability(context, error48);
35624
35661
  }
35625
35662
  return;
35626
35663
  });
35627
- function armAskAwareness(id, prompt, asOf) {
35628
- if (prompt.trim() === "")
35629
- return;
35630
- awarenessGeneration++;
35664
+ pi.on("agent_start", async () => {
35665
+ runSeq += 1;
35666
+ });
35667
+ function armAskAwareness(id, asOf) {
35631
35668
  askAwareness = {
35632
35669
  session_id: id,
35633
35670
  period: (askAwareness.session_id === id ? askAwareness.period : 0) + 1,
35634
35671
  baseline_as_of: asOf,
35635
- fired: false,
35636
- saw_ask: false
35672
+ check_due: true,
35673
+ checks: 0
35637
35674
  };
35638
35675
  }
35639
35676
  pi.on("agent_end", async (event, context) => {
35640
35677
  const id = context.sessionManager.getSessionId();
35641
35678
  const lastReply = event.messages?.findLast((message) => message.role === "assistant");
35642
- if (event.willContinue === true || lastReply?.stopReason !== "stop" || shuttingDown || !context.hasUI || id === "" || id !== sessionID || legionManaged(id) || askAwareness.session_id !== id || askAwareness.period === 0 || askAwareness.fired || askAwareness.saw_ask || askAwareness.baseline_as_of === null || askCheckInFlight) {
35679
+ if (event.willContinue === true || lastReply?.stopReason !== "stop" || !context.hasUI || id === "" || !checkOwed(id)) {
35680
+ return;
35681
+ }
35682
+ if (askCheckInFlight) {
35683
+ settledDuringCheck = { context, seenRun: runSeq };
35643
35684
  return;
35644
35685
  }
35645
- const period = askAwareness.period;
35646
- const generation = awarenessGeneration;
35647
35686
  askCheckInFlight = true;
35648
35687
  try {
35649
- let open;
35650
- try {
35651
- open = await queryOpenAsks(id, askAwareness.baseline_as_of);
35652
- } catch (error48) {
35653
- if (generation === awarenessGeneration)
35654
- warnAskAvailability(context, error48);
35655
- return;
35656
- }
35657
- if (open === null || shuttingDown || generation !== awarenessGeneration || sessionID !== id || context.sessionManager.getSessionId() !== id || legionManaged(id) || askAwareness.session_id !== id || askAwareness.period !== period || askAwareness.fired || askAwareness.saw_ask) {
35658
- return;
35688
+ let settle = { context, seenRun: runSeq };
35689
+ while (settle !== undefined) {
35690
+ await checkAtSettle(id, settle);
35691
+ settle = takeSettledDuringCheck();
35692
+ if (settle?.context.sessionManager.getSessionId() !== id || settle.seenRun !== runSeq || !checkOwed(id)) {
35693
+ settle = undefined;
35694
+ }
35659
35695
  }
35660
- if (open.snapshot.count > 0 || open.snapshot.opened_since)
35661
- return;
35662
- askAwareness = { ...askAwareness, fired: true };
35663
- pi.sendMessage({ customType: ASK_REMINDER_MESSAGE, content: OPEN_ASKS_REMINDER, display: false }, { deliverAs: "steer", triggerTurn: true });
35664
35696
  } finally {
35665
35697
  askCheckInFlight = false;
35698
+ settledDuringCheck = undefined;
35666
35699
  }
35667
35700
  });
35701
+ function checkOwed(id) {
35702
+ return !(shuttingDown || legionManaged(id) || askAwareness.session_id !== id || askAwareness.period === 0 || !askAwareness.check_due || askAwareness.checks >= ASK_CHECKS_PER_PERIOD || askAwareness.baseline_as_of === null || askEphemeral === undefined);
35703
+ }
35704
+ async function checkAtSettle(id, settle) {
35705
+ const { context, seenRun } = settle;
35706
+ const period = askAwareness.period;
35707
+ const generation = awarenessGeneration;
35708
+ const baseline = askAwareness.baseline_as_of;
35709
+ if (baseline === null || askEphemeral === undefined)
35710
+ return;
35711
+ const stale = () => shuttingDown || generation !== awarenessGeneration || context.sessionManager.getSessionId() !== id || legionManaged(id) || askAwareness.session_id !== id || askAwareness.period !== period || !askAwareness.check_due;
35712
+ let open;
35713
+ try {
35714
+ open = await queryOpenAsks(id, baseline);
35715
+ } catch (error48) {
35716
+ if (generation === awarenessGeneration)
35717
+ warnAskAvailability(context, error48);
35718
+ return;
35719
+ }
35720
+ if (open === null || stale())
35721
+ return;
35722
+ if (runSeq !== seenRun)
35723
+ return;
35724
+ if (open.snapshot.count > 0 || open.snapshot.opened_since) {
35725
+ askAwareness = {
35726
+ ...askAwareness,
35727
+ check_due: false,
35728
+ baseline_as_of: open.snapshot.as_of
35729
+ };
35730
+ return;
35731
+ }
35732
+ const abort = new AbortController;
35733
+ askCheckAbort = abort;
35734
+ let answered;
35735
+ try {
35736
+ answered = askEphemeral({ prompt: ASK_SELF_CHECK_PROMPT, signal: abort.signal }).then((reply) => reply.replyText, (error48) => {
35737
+ if (!abort.signal.aborted)
35738
+ logSelfCheckFailure(id, error48);
35739
+ return;
35740
+ });
35741
+ } catch (error48) {
35742
+ logSelfCheckFailure(id, error48);
35743
+ answered = Promise.resolve(undefined);
35744
+ }
35745
+ const expiry = Promise.withResolvers();
35746
+ const expire = setTimeout(() => {
35747
+ const timedOut = new Error(`self-check timed out after ${selfCheckTimeoutMs} ms`);
35748
+ abort.abort(timedOut);
35749
+ logSelfCheckFailure(id, timedOut);
35750
+ expiry.resolve(undefined);
35751
+ }, selfCheckTimeoutMs);
35752
+ let verdict;
35753
+ try {
35754
+ verdict = await Promise.race([answered, expiry.promise]);
35755
+ } finally {
35756
+ clearTimeout(expire);
35757
+ if (askCheckAbort === abort)
35758
+ askCheckAbort = undefined;
35759
+ }
35760
+ if (stale()) {
35761
+ abort.abort(new Error("the self-check was superseded before its verdict arrived"));
35762
+ return;
35763
+ }
35764
+ const superseded = runSeq !== seenRun;
35765
+ askAwareness = {
35766
+ ...askAwareness,
35767
+ check_due: superseded,
35768
+ checks: askAwareness.checks + 1,
35769
+ baseline_as_of: open.snapshot.as_of
35770
+ };
35771
+ if (superseded)
35772
+ return;
35773
+ if (verdict === undefined || !isWaitingVerdict(verdict.trim()))
35774
+ return;
35775
+ pi.sendMessage({ customType: ASK_REMINDER_MESSAGE, content: UNASKED_WAIT_REMINDER, display: false }, { deliverAs: "steer", triggerTurn: true });
35776
+ }
35668
35777
  const announceFollow = createFollowAnnouncer((text) => {
35669
35778
  pi.sendMessage({ customType: "envoy-message", content: text, display: true, details: LOCAL_ENVOY_NOTICE }, { deliverAs: "steer", triggerTurn: false });
35670
35779
  });
35671
- pi.on("tool_result", async (event) => {
35780
+ pi.on("tool_result", async (event, context) => {
35672
35781
  if (event.isError)
35673
35782
  return;
35674
- if (ASK_OPENING_TOOLS.includes(event.toolName) && askAwareness.session_id === sessionID && askAwareness.period > 0 && !askAwareness.saw_ask) {
35675
- askAwareness = { ...askAwareness, saw_ask: true };
35783
+ if (askAwareness.session_id === context.sessionManager.getSessionId() && askAwareness.period > 0) {
35784
+ if (ASK_OPENING_TOOLS.includes(event.toolName)) {
35785
+ askAwareness = { ...askAwareness, check_due: false };
35786
+ abortSelfCheck("the agent opened the ask itself");
35787
+ } else if (!event.toolName.startsWith(DISPATCH_TOOL_PREFIX)) {
35788
+ askAwareness = { ...askAwareness, check_due: true };
35789
+ }
35676
35790
  }
35677
35791
  announceFollow(event.details);
35678
35792
  });
package/dist/legion.js CHANGED
@@ -16142,7 +16142,7 @@ import { logger } from "@oh-my-pi/pi-utils";
16142
16142
  // package.json
16143
16143
  var package_default = {
16144
16144
  name: "@sjawhar/pi-legion-envoy",
16145
- version: "5.1.0",
16145
+ version: "5.2.0",
16146
16146
  type: "module",
16147
16147
  omp: {
16148
16148
  extensions: [
@@ -233,8 +233,7 @@ Preserve this order exactly:
233
233
 
234
234
  1. tester green and review cycles complete;
235
235
  2. on a clean review, `spawn_worker` the implementer once more to push only the `.legion/`
236
- deletion (only the implementer pushes the issue branch), then the reviewer approves that
237
- head. The deletion must land before that approval, which is head-pinned. An implementer
236
+ deletion, then the reviewer approves that head. The deletion must land before that approval, which is head-pinned. An implementer
238
237
  completion advances the status only from `in_progress` to `testing`; this push, like retro
239
238
  later, leaves the status where it is, so you set nothing by hand — on its `phase-complete`
240
239
  wake, `spawn_worker` the reviewer to approve that head (a finished reviewer may already be
@@ -259,8 +258,8 @@ What returns the tree to review: a changed diff — a commit above the approved
259
258
  touches anything outside `docs/solutions/`, or a rebase whose fingerprint
260
259
  (`skill://legion-worker`'s unchanged-diff check) differs from the approved head's. What does not: retro's
261
260
  `docs/solutions/` commit, and a rebase forced by a GitHub-reported conflict whose fingerprint
262
- is unchanged. For that rebase the order is: the implementer rebases and posts the before/after
263
- fingerprints; the tester re-runs the bare gates only; the reviewer confirms and approves the new
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
264
263
  head by SHA (or continues its round if it had not approved); the merger republishes READY.
265
264
  Retro does not re-run. A rebase happens only when GitHub reports `CONFLICTING`
266
265
  (`legion gh -- pr view <n> --json mergeable,mergeStateStatus`); read that on every end-game
@@ -271,8 +270,9 @@ it. Do not let the merger publish `READY` for an obsolete approval.
271
270
  If a worker reports that `legion threads resolve` exited 1 naming a review thread GitHub refused
272
271
  to resolve, open a `dispatch_ask` that names the thread's URL and GitHub's message for a human to
273
272
  resolve it by hand, with options for resolved / could not; the merger does not publish while it
274
- is open. That is the one review-thread step a human takes: the review App cannot resolve threads,
275
- and the implementer's and merger's runs of the command close every accepted one.
273
+ is open. That is the one review-thread step a human takes: the review App cannot resolve a thread
274
+ on a pull request the implementer opened, and the implementer's and merger's runs of the command
275
+ close every accepted one.
276
276
 
277
277
  ## 7. Close
278
278
 
@@ -311,7 +311,7 @@ active phase worker.
311
311
  | `worker-recovered` | Payload `{type:"worker-recovered", issue, role, fromRef, delivery?}`. The worker's tree volume was lost and the daemon replaced it from the committed handoff on `fromRef`. `delivery: "spawned"` means the current assignment was preserved on the new worker; do not resend it. `delivery: "queued"` means that preserved assignment awaits capacity; wait for `worker-started`. Without `delivery`, inspect `.legion/` and the active phase before deciding whether work needs a new assignment. |
312
312
  | `pr-ready` | Verify the live PR head, green status, and review state. Continue the review/retro/merger order only for that current head. |
313
313
  | `pr-review` | Payload `{type:"pr-review", state, author, body}`. Delivered to whichever role is currently active for the issue, falling back to you when no worker phase is active. Follows the same verdict rule as a reviewer's `phase-complete`: `state: "changes_requested"` sends the implementer back in with the review findings, then tester, then reviewer — never the reviewer again and never retro; that `spawn_worker` returns the issue to `in_progress` on its own (the daemon writes it for a corrective implementer whenever the PR's latest recorded review is changes requested, a human's after approval included), so you set nothing by hand; `state: "approved"` proceeds toward retro (step 5) once the step 6 integration/merge-gate conditions are met. `state: "approved"` on a rebased head whose body names an unchanged fingerprint is that confirmation: proceed to retro if it has not run, otherwise to the merger — never to a second retro or test round. |
314
- | `pr-blocked` | Payload `{type:"pr-blocked", pr, attempts}`. `attempts` counts heads pushed onto a red verdict that changed something outside `.legion/` — handoff-only pushes (`.legion/` paths only) never count; a push the daemon cannot classify (a listener without `changed_paths`, a list capped at 100, a push listing no commits) does. Published once per exhausted count, not on every later red verdict for that count. Read the failed CI evidence and recovery attempts. Assign a focused implementer or corrective child, then return it through testing and review; do not treat the blocked PR as final. |
314
+ | `pr-blocked` | Payload `{type:"pr-blocked", pr, attempts}`. `attempts` counts heads pushed onto a red verdict that changed something outside `.legion/` — handoff-only pushes (`.legion/` paths only) never count, a push by the review App (a planner's, tester's, reviewer's or architect's) never counts, and the head after a red the tester's red tests earned (a review-App push that changed a path outside `.legion/`, however many handoff-only pushes follow it) does not count either — so after the tester's handoff-only push onto the implementer's red, the implementer's next push does count; a push the daemon cannot classify (a listener without `changed_paths`, a list capped at 100, a push listing no commits) does. Published once per exhausted count, not on every later red verdict for that count. Read the failed CI evidence and recovery attempts. Assign a focused implementer or corrective child, then return it through testing and review; do not treat the blocked PR as final. |
315
315
  | `pr-merged` | Payload `{type:"pr-merged", pr, mergeCommitSha}`. The PR merged because a human merged it under the repository's rules. `spawn_worker` the **implementer** with the production-check task naming that merge commit (it resumes the same agent; a retired role has no live holder, so never `envoy_publish` for this). Its `phase-complete` is what brings you to step 7: verify the record on the pull request and this issue first, then sign off naming it and set the issue `done`. A merge is not the close. |
316
316
  | `pr-closed-unmerged` | Decide from current scope whether to reopen the work, send a fresh implementer, or cancel it with a reason. Delegate the repository action to the responsible phase worker and keep ownership. |
317
317
  | `issue-comment` | Interpret the comment in the issue's design context. Answer it, adjust the plan, or relay it via `envoy_publish` to the responsible worker's role token; scope and product decisions remain with you. |
@@ -232,15 +232,9 @@ commit is `plan: record handoff`.
232
232
 
233
233
  ## Implementer push and pull request
234
234
 
235
- Only the implementer creates the issue bookmark, pushes it, and opens the pull request.
236
- After its implementation commit and verification, it uses this exact branch name and push
237
- procedure:
238
-
239
- ```bash
240
- cd -- "$LEGION_WORKSPACE" && \
241
- jj -R "$LEGION_WORKSPACE" bookmark set legion/<KEY> && \
242
- jj -R "$LEGION_WORKSPACE" git push --bookmark legion/<KEY>
243
- ```
235
+ The implementer opens the pull request. After its implementation commit and verification, it
236
+ pushes the issue branch under this exact name with the one push procedure every role uses
237
+ (*Every role pushes its own commits*, below).
244
238
 
245
239
  The provisioned issue workspace configures `credential.helper` with the daemon's absolute
246
240
  credential command, so `jj -R "$LEGION_WORKSPACE" git push` authenticates transparently
@@ -286,7 +280,7 @@ Verified the implementer's proof by <re-running its command | driving the same s
286
280
  **Fast-follow:** <one named cleanup item and where it will land>, or "none".
287
281
 
288
282
  **Chain:** stacked on <base bookmark> frozen at <sha> / not stacked.
289
- **Retarget:** Retargeting a pull request to a new base does not re-run Tests; after a retarget, rebase onto the new base and push — 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, 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.
290
284
  ```
291
285
 
292
286
  **A proof** is the changed behaviour exercised on the surface a user reaches it through, recorded
@@ -314,9 +308,9 @@ this proof.
314
308
  `Accepted: not a defect — <reason>`, or `Still open: <what remains>`; nothing else is an
315
309
  acceptance, and nobody replies after an `Accepted:` (any later reply that is not itself an
316
310
  `Accepted:` — the opener's own follow-up included — leaves the thread open, since the command
317
- reads only the newest comment). The review App can reply on a thread, but GitHub refuses it
318
- `resolveReviewThread` (its token reads `viewerCanResolve: false`), and the workflow gives
319
- pushing to the implementer alone (`packages/daemon/src/daemon/AGENTS.md`, GitHub Apps) — so the
311
+ reads only the newest comment). The review App can reply on a thread but cannot resolve it:
312
+ GitHub grants resolving a review thread to the pull request's author, and the implementer opens
313
+ every Legion pull request (`packages/daemon/src/daemon/AGENTS.md`, GitHub Apps). So the
320
314
  **implementer** runs `legion threads resolve --pr <number> --repo <owner>/<repo>` before every
321
315
  push that answers a review (the corrective push and the final `.legion/` deletion push) and
322
316
  pastes its output into the `Threads` section. The command resolves each unresolved thread
@@ -341,7 +335,8 @@ this proof.
341
335
  tip; post one PR comment (Legion footer):
342
336
  `rebase <old-tip-sha> → <new-tip-sha>; fingerprint <before> → <after>; unchanged|changed`.
343
337
  Rebase the whole chain — `jj -R "$LEGION_WORKSPACE" rebase -s 'roots(main@origin..@)' -d main@origin` —
344
- so the tester's and reviewer's commits move with yours.
338
+ so the tester's and reviewer's commits move with yours. Record the pushed tip before it and
339
+ push the rebased chain with the push procedure (*Rewriting pushed commits*, below).
345
340
  - **No deferrals.** Sami, 2026-09-11, verbatim: "My rule is no deferrals." The `Fast-follow:`
346
341
  field names naming, duplication, or wording cleanup only; anything that changes behaviour,
347
342
  hides an error, or breaks a gate lands in this PR.
@@ -399,8 +394,7 @@ this proof.
399
394
  Legion footer), and a `comments[]` array of `{path, line, side, body}`, one entry per
400
395
  finding — never one `pr review` call per finding (each submission fires a `pr-review` wake).
401
396
  Then return the issue to the architect; when clean, have the architect send the implementer
402
- back to push the `.legion/` deletion (only the implementer pushes the issue branch), then review **that** head
403
- and approve it by name. After a conflict-forced rebase, compute the fingerprint at the
397
+ back to push the `.legion/` deletion, then review **that** head and approve it by name. After a conflict-forced rebase, compute the fingerprint at the
404
398
  `commit_id` of your last submitted review and at the new head. Equal and that review was
405
399
  `APPROVE`: submit one more `APPROVE` naming the new head by SHA, its body naming both SHAs
406
400
  and the fingerprint — a confirmation, not a round; no thermo pass, no thread pass. Equal and
@@ -535,29 +529,61 @@ cd -- "$LEGION_WORKSPACE" && \
535
529
  jj -R "$LEGION_WORKSPACE" split -m "<phase>: record handoff" .legion/<phase>.json
536
530
  ```
537
531
 
538
- **Only the implementer pushes the issue branch.** It acts as the code-writing App
539
- (`legion-implementer[bot]`, `appRoleForLegionRole` in `packages/daemon/src/daemon/github-apps.ts`),
540
- the one role the workflow lets push (the review App's installation holds `contents: write` too, but
541
- no role acting as it pushes; the merger acts as the implement App but pushes nothing: it
542
- verifies and publishes READY). If you are the implementer, advance the issue bookmark and push it
543
- with the provisioned credential helper. `--bookmark` also publishes the locally provisioned
544
- bookmark on its first push — a bookmark not yet tracking a remote one is tracked automatically:
532
+ **Every role pushes its own commits.** After the handoff commit — and, for the tester, the red
533
+ tests it wrote — advance the issue bookmark and push it with the provisioned credential helper,
534
+ which authenticates as your role's App (`appRoleForLegionRole` in
535
+ `packages/daemon/src/daemon/github-apps.ts`). `-r @-` puts the bookmark on the commit you just
536
+ split off: the working copy left above it has no description, and `jj git push` refuses a
537
+ commit without one. `--allow-backwards` is for that local step alone: after a split the bookmark
538
+ can sit on the undescribed working copy above `@-`. `--bookmark` also publishes the locally
539
+ provisioned bookmark on its first push — a bookmark not yet tracking a remote one is tracked
540
+ automatically. This is the one push procedure; every push of the issue branch uses it:
545
541
 
546
542
  ```bash
547
543
  cd -- "$LEGION_WORKSPACE" && \
548
- jj -R "$LEGION_WORKSPACE" bookmark set legion/<KEY> && \
549
- jj -R "$LEGION_WORKSPACE" git push --bookmark legion/<KEY>
544
+ tip_file="${TMPDIR:-/tmp}/legion-<KEY>-$LEGION_ROLE-rewritten-tip" && \
545
+ old=$(cat -- "$tip_file" 2>/dev/null || true) && \
546
+ behind=$(jj -R "$LEGION_WORKSPACE" log --no-graph -T 'commit_id.short() ++ "\n"' \
547
+ -r "remote_bookmarks(exact:\"legion/<KEY>\", exact:\"origin\") ~ (::@-${old:+ | $old})") && \
548
+ { [ -z "$behind" ] || { echo "legion/<KEY>@origin is at $behind, which @- does not descend from" >&2; false; }; } && \
549
+ jj -R "$LEGION_WORKSPACE" bookmark set legion/<KEY> -r @- --allow-backwards && \
550
+ jj -R "$LEGION_WORKSPACE" git push --bookmark legion/<KEY> && \
551
+ rm -f -- "$tip_file"
550
552
  ```
551
553
 
552
- Every other role — planner, tester, reviewer, architects — acts as the review App
553
- (`legion-reviewer[bot]`) and never pushes: the `split` above is your last step, and the commit
554
- rides the implementer's next push (the corrective push after a review, or the final `.legion/`
555
- deletion). GitHub does not stop a push from one of those roles: the review App's installation
556
- holds `contents: write`, so the push would succeed. The rule is the workflow's, and nothing but
557
- the rule enforces it.
554
+ The `behind` check refuses unless `@-` descends from `legion/<KEY>@origin` (or the branch is not
555
+ on GitHub yet). Every issue workspace shares one clone, so another role's push moves
556
+ `legion/<KEY>@origin` here at once. With the flag and no check, `jj git push` then moves the
557
+ remote branch sideways onto your commit and drops theirs (jj 0.45.1:
558
+ `bookmark: legion/K [move sideways from <theirs> to <yours>]`). A clone that has not seen the other
559
+ push is refused by jj itself (`unexpectedly moved on the remote`).
560
+
561
+ **Rewriting pushed commits** — the conflict-forced rebase, the rebase after a retarget, or a
562
+ `jj squash --into` a commit already on GitHub — leaves the pushed tip outside `::@-`, so record
563
+ that tip first, after a fetch and while your chain still descends from it:
564
+
565
+ ```bash
566
+ cd -- "$LEGION_WORKSPACE" && \
567
+ jj -R "$LEGION_WORKSPACE" git fetch && \
568
+ behind=$(jj -R "$LEGION_WORKSPACE" log --no-graph -T 'commit_id.short() ++ "\n"' \
569
+ -r 'remote_bookmarks(exact:"legion/<KEY>", exact:"origin") ~ ::@-') && \
570
+ { [ -z "$behind" ] || { echo "legion/<KEY>@origin is at $behind, which @- does not descend from" >&2; false; }; } && \
571
+ jj -R "$LEGION_WORKSPACE" log --no-graph -T 'commit_id' \
572
+ -r 'remote_bookmarks(exact:"legion/<KEY>", exact:"origin")' \
573
+ >"${TMPDIR:-/tmp}/legion-<KEY>-$LEGION_ROLE-rewritten-tip"
574
+ ```
575
+
576
+ Then rewrite, resolve, and push with the procedure above. It lets the remote branch sit on the
577
+ tip you recorded, which the rewrite replaced, and on nothing else: when another role pushed after
578
+ you recorded it, the push is refused. The push deletes the file.
579
+
580
+ Before the push, check ancestry and identity as above: the chain carries every earlier phase's
581
+ commits, and pushing them with yours is expected. A refusal, and a push the remote rejects, is a
582
+ report to the architect with the output, never a force-push. The merger makes no commit and
583
+ pushes nothing.
558
584
 
559
- Do not report phase completion until the write, existence check, and handoff commit succeed —
560
- and, for the implementer, until the push has too. This is the committed copy the next phase
585
+ Do not report phase completion until the write, existence check, handoff commit, and push
586
+ succeed. This is the committed copy the next phase
561
587
  reads after revival. It is removed once, at the end of a clean review: the implementer pushes
562
588
  that deletion at the reviewer's direction. No other phase removes it — and once it is gone
563
589
  (`jj -R "$LEGION_WORKSPACE" file list -r @- .legion` prints nothing on stdout; jj warns on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [