@sjawhar/pi-legion-envoy 1.23.0 → 1.24.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/legion.js CHANGED
@@ -34715,6 +34715,112 @@ function isSingleLegionCommand(command) {
34715
34715
  return false;
34716
34716
  return trimmed.split(/\s+/, 1)[0] === "legion";
34717
34717
  }
34718
+ var JJ_LOG_REWRITE_WORDS = ["undo", "abandon"];
34719
+ var JJ_OP_WORDS = ["op", "operation"];
34720
+ var JJ_OP_LOG_REWRITE_WORDS = ["restore", "revert"];
34721
+ var JJ_MENTION = /\bjj\b/;
34722
+ var JJ_LOG_REWRITE_MENTION = /\b(?:undo|abandon)\b|\b(?:op|operation)\b\W+(?:restore|revert)\b/;
34723
+ function jjLogRewriteMention(text) {
34724
+ if (!JJ_MENTION.test(text))
34725
+ return;
34726
+ const match = JJ_LOG_REWRITE_MENTION.exec(text);
34727
+ return match === null ? undefined : match[0].replace(/\W+/g, " ");
34728
+ }
34729
+ function splitShellCommands(command) {
34730
+ const commands = [];
34731
+ let words = [];
34732
+ let text = "";
34733
+ let inWord = false;
34734
+ let quote;
34735
+ const endWord = () => {
34736
+ if (inWord)
34737
+ words.push(text);
34738
+ text = "";
34739
+ inWord = false;
34740
+ };
34741
+ const endCommand = () => {
34742
+ endWord();
34743
+ if (words.length > 0)
34744
+ commands.push(words);
34745
+ words = [];
34746
+ };
34747
+ for (let i = 0;i < command.length; i += 1) {
34748
+ const char = command.charAt(i);
34749
+ if (quote !== undefined) {
34750
+ if (char === quote)
34751
+ quote = undefined;
34752
+ else if (quote === '"' && char === "\\" && command.charAt(i + 1) === `
34753
+ `)
34754
+ i += 1;
34755
+ else if (quote === '"' && char === "\\" && i + 1 < command.length) {
34756
+ i += 1;
34757
+ text += command.charAt(i);
34758
+ } else
34759
+ text += char;
34760
+ continue;
34761
+ }
34762
+ if (char === '"' || char === "'") {
34763
+ quote = char;
34764
+ inWord = true;
34765
+ } else if (char === "\\") {
34766
+ if (command.charAt(i + 1) === `
34767
+ `)
34768
+ i += 1;
34769
+ else if (i + 1 < command.length) {
34770
+ i += 1;
34771
+ text += command.charAt(i);
34772
+ inWord = true;
34773
+ }
34774
+ } else if (char === " " || char === "\t" || char === "<" || char === ">") {
34775
+ endWord();
34776
+ } else if (char === "&" && (command.charAt(i - 1) === ">" || command.charAt(i - 1) === "<" || command.charAt(i + 1) === ">")) {
34777
+ endWord();
34778
+ } else if (";&|\n()`".includes(char)) {
34779
+ endCommand();
34780
+ } else {
34781
+ text += char;
34782
+ inWord = true;
34783
+ }
34784
+ }
34785
+ if (quote !== undefined)
34786
+ return;
34787
+ endCommand();
34788
+ return commands;
34789
+ }
34790
+ function jjLogRewriteInvocation(command) {
34791
+ const commands = splitShellCommands(command);
34792
+ if (commands === undefined) {
34793
+ return jjLogRewriteMention(command) === undefined ? undefined : command.trim();
34794
+ }
34795
+ for (const words of commands) {
34796
+ const mentioned = words.find((word) => jjLogRewriteMention(word) !== undefined);
34797
+ if (mentioned !== undefined)
34798
+ return mentioned;
34799
+ const jj = words.findIndex((word) => word === "jj" || word.endsWith("/jj"));
34800
+ if (jj === -1)
34801
+ continue;
34802
+ const args = words.slice(jj + 1);
34803
+ const rewritesLog = args.some((arg) => JJ_LOG_REWRITE_WORDS.includes(arg)) || args.some((arg) => JJ_OP_WORDS.includes(arg)) && args.some((arg) => JJ_OP_LOG_REWRITE_WORDS.includes(arg));
34804
+ if (rewritesLog)
34805
+ return words.slice(jj).join(" ");
34806
+ }
34807
+ return;
34808
+ }
34809
+ function jjLogRewriteAttempt(toolCall) {
34810
+ const { toolName, input } = toolCall;
34811
+ if (toolName === "bash") {
34812
+ return typeof input.command === "string" ? jjLogRewriteInvocation(input.command) : undefined;
34813
+ }
34814
+ let text;
34815
+ if (toolName === "eval" && typeof input.code === "string")
34816
+ text = input.code;
34817
+ else if (toolName === "hub") {
34818
+ text = [input.application, ...Array.isArray(input.args) ? input.args : [], input.text].filter((part) => typeof part === "string").join(" ");
34819
+ } else
34820
+ return;
34821
+ const mention = jjLogRewriteMention(text);
34822
+ return mention === undefined ? undefined : `${toolName}: jj ${mention}`;
34823
+ }
34718
34824
  var LEGION_LOADED_MARKER = Symbol.for("legion.pi-envoy.legion-loaded");
34719
34825
  var CODE_MUTATION_TOOLS = ["edit", "write", "apply_patch"];
34720
34826
  var MERGER_BLOCKED_TOOLS = [...CODE_MUTATION_TOOLS, "task"];
@@ -34738,6 +34844,7 @@ function legionExtension(pi) {
34738
34844
  subagentSession ??= isSubagentSession(context);
34739
34845
  return subagentSession;
34740
34846
  };
34847
+ let phaseWorkerPane;
34741
34848
  const roleDaemon = () => {
34742
34849
  return createLegionDaemonClient(requiredEnvironment(process.env, "LEGION_DAEMON_URL"), fetch, {
34743
34850
  recoveryToken: (sessionId) => {
@@ -35009,6 +35116,16 @@ function legionExtension(pi) {
35009
35116
  toolCallId: toolCall.toolCallId,
35010
35117
  toolName: toolCall.toolName
35011
35118
  });
35119
+ phaseWorkerPane ??= classifySession(process.env).kind === "phase-worker";
35120
+ if (phaseWorkerPane) {
35121
+ const jjAttempt = jjLogRewriteAttempt(toolCall);
35122
+ if (jjAttempt !== undefined) {
35123
+ return {
35124
+ block: true,
35125
+ reason: `refused \`${jjAttempt}\`: jj undo, jj abandon, and jj op restore/revert/abandon/undo ` + "rewrite the jj operation log, which every Legion issue workspace shares (each is a jj " + "workspace of one clone), so they rewrite other trees' commits too. Recover forward with " + "a new commit or `jj restore <paths>` of files; anything else, stop and send the owning " + 'architect the `jj -R "$LEGION_WORKSPACE" log` evidence.'
35126
+ };
35127
+ }
35128
+ }
35012
35129
  if (await checkSubagentSession(context))
35013
35130
  return;
35014
35131
  const sessionID = context.sessionManager.getSessionId();
@@ -204,13 +204,13 @@ legion({
204
204
  op: "spawn_worker",
205
205
  issue: "LEGION-40",
206
206
  role: "implementer",
207
- task: "Run the legion-retro skill now. Capture durable learnings and post the issue comment; do not create a .legion handoff file."
207
+ task: "Run the legion-retro skill now. Capture durable learnings and post the retro message on the Dispatch issue with dispatch_message; do not create a .legion handoff file."
208
208
  })
209
209
  ```
210
210
 
211
211
  Wait for the implementer to report its durable retro result. Retro output is
212
- `docs/solutions/` plus an issue comment; it must not create a `.legion` file or rewrite
213
- the reviewer-approved head after cleanup.
212
+ `docs/solutions/` plus one `dispatch_message` on the issue; it must not create a `.legion`
213
+ file or rewrite the reviewer-approved head after cleanup.
214
214
 
215
215
  ## 6. Architect sign-off and merge
216
216
 
@@ -17,7 +17,8 @@ retrospective's durable output.
17
17
  1. Tester green and all code-review cycles finish.
18
18
  2. The implementer pushes the `.legion/` deletion at the reviewer's direction, and the reviewer
19
19
  approves that head.
20
- 3. Run this retro: commit durable learnings to `docs/solutions/` and post the issue comment.
20
+ 3. Run this retro: commit durable learnings to `docs/solutions/` and post the retro message on
21
+ the Dispatch issue.
21
22
  Retro writes **no `.legion` file**, so it never re-dirties the cleaned handoff tree.
22
23
  4. The merger verifies the tip is the approved head plus commits that change only
23
24
  `docs/solutions/` — `jj diff --from <approved-sha> --to <tip-sha> --summary`, quoted in READY —
@@ -66,18 +67,22 @@ date: YYYY-MM-DD
66
67
  status: active
67
68
  module: affected-module
68
69
  related_issues:
69
- - "owner/repo#123"
70
+ - "LEGION-123" # the Dispatch issue
71
+ - "owner/repo#456" # the pull request
70
72
  ---
71
73
  ```
72
74
 
73
75
  Commit the documentation on the existing issue branch, advance its existing bookmark, and push
74
- that branch. Do not create a replacement branch or bookmark. Then post an issue comment naming
75
- the documents and the one-to-three most useful takeaways. The comment must carry this revived
76
- implementer's structured attribution footer with `phase` set to `retro`:
76
+ that branch. Do not create a replacement branch or bookmark. Then post one Dispatch message on
77
+ the issue — `issue` is your `LEGION_ISSUE`; Legion issues live on Dispatch, never on a GitHub
78
+ issue, and the `gh` shim refuses every GitHub-issue write — naming the documents and the
79
+ one-to-three most useful takeaways. The message must carry this revived implementer's structured
80
+ attribution footer with `phase` set to `retro`; the body is capped at 2,000 characters:
77
81
 
78
- ```bash
79
- legion gh -- issue comment <issue-number> \
80
- --body $'## Retro Complete
82
+ ```ts
83
+ dispatch_message({
84
+ issue: "<KEY>",
85
+ body: `## Retro Complete
81
86
 
82
87
  **Learnings documented in:**
83
88
  - docs/solutions/<path>.md
@@ -85,11 +90,11 @@ legion gh -- issue comment <issue-number> \
85
90
  **Key takeaways:**
86
91
  - <reusable lesson>
87
92
 
88
- <!-- legion: {"session":"<session-id>","phase":"retro"} -->' \
89
- --repo <owner>/<repo>
93
+ <!-- legion: {"session":"<session-id>","phase":"retro"} -->`,
94
+ })
90
95
  ```
91
96
 
92
- The issue comment and `docs/solutions/` commit are the only retro outputs. Never write a
97
+ The Dispatch message and the `docs/solutions/` commit are the only retro outputs. Never write a
93
98
  handoff, phase artifact, local feedback log, or completion label; `.legion/` was deleted before
94
99
  retro and nothing recreates it. Report completion with `legion handoff complete` alone (its
95
100
  summary: two sentences for the architect) — no `legion handoff write`.
@@ -100,7 +105,7 @@ Before returning, verify all of the following:
100
105
 
101
106
  - The reviewer cleanup commit remains below the retro documentation commit, and the reviewer's
102
107
  approval of that cleanup head stands: the merger accepts the approved head plus this commit.
103
- - The learning documents and issue comment both exist.
108
+ - The learning documents and the Dispatch message both exist.
104
109
  - No `.legion` file was created or modified by retro.
105
110
  - The fresh-eyes analysis was considered alongside the implementer's context.
106
111
  - The merger remains a subsequent step, not work performed by retro.
@@ -120,10 +120,21 @@ transcript, the committed file wins: it is the copy that survived.
120
120
  - **Before pushing, check ancestry:** `jj -R "$LEGION_WORKSPACE" log -r 'ancestors(@, 5)'`
121
121
  — verify only your issue's commits are in the chain, not unrelated work.
122
122
 
123
- **Shared operation safety:** Never run `jj op restore` in a Legion workspace. It rewrites
124
- the shared operation log. If a mistake reaches that point, stop and send the owning
125
- architect the `jj -R "$LEGION_WORKSPACE" log` evidence; recover only through the approved,
126
- path-scoped workflow.
123
+ **Shared operation safety:** Every Legion issue workspace is a `jj workspace` of one shared
124
+ clone, so they all share one operation log: `jj undo`, `jj abandon`, and
125
+ `jj op restore|revert|abandon|undo` rewrite it for every tree at once (on 2026-09-12 one
126
+ worker's `jj undo` rewrote nine of another tree's commits). The extension refuses them in every
127
+ phase-worker pane before they run — a `bash` command in any position of a pipeline or `&&`
128
+ chain, with or without `-R`, judged on the whole argument list; `eval` code; and a `hub`
129
+ process start — from your own tool calls and from any `task` subagent you spawn (it runs in
130
+ your pane, against the same log), and a `bash` command whose quoted text merely mentions `jj`
131
+ with one of those words (a heredoc, an echo, a commit message) is refused too: write such text
132
+ with the `write` tool or say "operation-log rollback" instead. `jj restore <paths>`,
133
+ `jj op log`, and `jj op show` stay allowed. Recover forward only: a new commit
134
+ (`jj -R "$LEGION_WORKSPACE" new`) or `jj -R "$LEGION_WORKSPACE" restore <paths>` of files.
135
+ Anything else, stop and send the owning architect the `jj -R "$LEGION_WORKSPACE" log`
136
+ evidence; the architect decides, and an operator performs any operation-log restore with every
137
+ other tree paused.
127
138
 
128
139
  ## Phase work
129
140
 
@@ -152,22 +163,29 @@ that execs `legion gh -- "$@"`, so `gh …` and `legion gh -- …` are the same
152
163
  redeems a fresh token from your session's grant — identity is supplied per call, never stored.
153
164
  Never run `gh auth login` or `gh auth setup-git`; there is no login state to create. The shim
154
165
  refuses `pr merge` (and a raw `gh api …/merge`): no worker role merges a pull request — the merge
155
- queue does, under its own authority. The credential reaches `legion` through the file
156
- `$LEGION_GRANT_FILE` names, written before each of your bash commands by the extension; never
157
- `cat`, `echo`, copy, or `export` it — `legion credential`, `legion gh`, `jj git push`, and
158
- `legion handoff complete` read it themselves. The file is the pane's, not the command's: a `task`
159
- subagent, an `eval` subprocess, or a background job in your pane reads the grant your last bash
160
- command minted, so its `legion gh` or `jj git push` succeeds only within 60 seconds of that call
161
- and 403s afterwards — a timing artifact, not a broken credential; run credentialed commands from
162
- your own bash calls.
166
+ queue does, under its own authority. It also refuses every GitHub-issue write — the `issue`
167
+ subcommand's `comment`, `create`, `edit`, `close`, `reopen`, `delete`, `pin`, `unpin`, `transfer`,
168
+ `lock`, `unlock`, and `develop`, and any raw `gh api` call to an `/issues` path whose method is not
169
+ GET (an explicit `-X`, or the POST that `-f`/`-F`/`--input` imply; pull-request conversation
170
+ comments live on that path too, so edit them with `gh pr comment`) — printing
171
+ `Legion issues live on Dispatch; use dispatch_message or dispatch_comment on <your LEGION_ISSUE>`:
172
+ Legion never reads or writes a GitHub issue (LEGION-78). `pr comment`, `pr review`,
173
+ `api …/pulls/…`, `api graphql`, and issue reads are unaffected. The credential reaches `legion`
174
+ through the file `$LEGION_GRANT_FILE` names, written before each of your bash commands by the
175
+ extension; never `cat`, `echo`, copy, or `export` it — `legion credential`, `legion gh`,
176
+ `jj git push`, and `legion handoff complete` read it themselves. The file is the pane's, not the
177
+ command's: a `task` subagent, an `eval` subprocess, or a background job in your pane reads the
178
+ grant your last bash command minted, so its `legion gh` or `jj git push` succeeds only within 60
179
+ seconds of that call and 403s afterwards — a timing artifact, not a broken credential; run
180
+ credentialed commands from your own bash calls.
163
181
 
164
182
  ## GitHub PR comment attribution
165
183
 
166
184
  Append this exact structured footer to **every** pull-request comment and review that this
167
185
  phase posts on GitHub. It preserves session provenance on the artifact itself so work stays
168
186
  attributable to the session that produced it. Dispatch comments carry session provenance
169
- natively through their own `actor`/`origin` fields; this footer is only for GitHub PR
170
- artifacts:
187
+ natively through their own `actor`/`origin` fields; this footer is for GitHub PR artifacts and
188
+ for the retro's Dispatch message (`skills/legion-retro`):
171
189
 
172
190
  ```html
173
191
  <!-- legion: {"session":"<session-id>","phase":"<phase>"} -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [