@sjawhar/pi-legion-envoy 1.65.2 → 1.66.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.
package/dist/envoy.js CHANGED
@@ -34747,6 +34747,64 @@ function messageMetadata(input) {
34747
34747
  import { logger } from "@oh-my-pi/pi-utils";
34748
34748
  var import_nats = __toESM(require_mod4(), 1);
34749
34749
 
34750
+ // src/legion/phase-stall.ts
34751
+ var PHASE_STALL_ENTRY = "legion-phase-stall";
34752
+ var LOCAL_ENVOY_NOTICE = { localNotice: true };
34753
+ var WAITING_REPLY = /^\W*WAITING\b/m;
34754
+ var TOOL_CALL_SHAPED = /<(?:[\w-]+:)?(?:function_calls\b|invoke\s+name\s*=|parameter\s+name\s*=)/;
34755
+ var FOLLOW_UP = "Your turn ended with your Legion phase still open: the `legion` tool's `handoff_complete` has " + "not succeeded since your assignment. If your phase's work is done, call the `legion` tool now " + "with op `handoff_complete` and the fields your role's instructions give. If you are waiting on " + "something (CI, a review, a person), reply with a line starting WAITING that names what you are " + "waiting on; nothing more will be sent until a new event arrives.";
34756
+ var TEXT_TOOL_CALL = "Your last message holds a tool call written as text, so it did not run: make it a real tool call. ";
34757
+ function stepPhaseStall(state, input) {
34758
+ switch (input.kind) {
34759
+ case "assignment":
34760
+ return { state: "open" };
34761
+ case "handoff-complete":
34762
+ return { state: "closed" };
34763
+ case "inbound-event":
34764
+ return { state: state === "quiet" ? "open" : state };
34765
+ case "settle":
34766
+ if (state !== "open")
34767
+ return { state };
34768
+ if (WAITING_REPLY.test(input.lastAssistantText))
34769
+ return { state: "quiet" };
34770
+ return {
34771
+ state: "quiet",
34772
+ followUp: TOOL_CALL_SHAPED.test(input.lastAssistantText) ? TEXT_TOOL_CALL + FOLLOW_UP : FOLLOW_UP
34773
+ };
34774
+ }
34775
+ }
34776
+ function inboundKind(message) {
34777
+ if (typeof message !== "object" || message === null || !("role" in message))
34778
+ return;
34779
+ if (message.role === "user")
34780
+ return "assignment";
34781
+ if (message.role === "custom" && "customType" in message && message.customType === "envoy-message" && !(("details" in message) && typeof message.details === "object" && message.details !== null && ("localNotice" in message.details) && message.details.localNotice === LOCAL_ENVOY_NOTICE.localNotice))
34782
+ return "inbound-event";
34783
+ return;
34784
+ }
34785
+ function assistantText(message) {
34786
+ if (typeof message !== "object" || message === null)
34787
+ return "";
34788
+ if (!("role" in message) || message.role !== "assistant" || !("content" in message))
34789
+ return "";
34790
+ if (!Array.isArray(message.content))
34791
+ return "";
34792
+ return message.content.map((block) => typeof block === "object" && block !== null && ("type" in block) && block.type === "text" && ("text" in block) && typeof block.text === "string" ? block.text : "").join(`
34793
+ `);
34794
+ }
34795
+ function isPhaseStall(value) {
34796
+ return value === "closed" || value === "open" || value === "quiet";
34797
+ }
34798
+ function restorePhaseStall(branch) {
34799
+ for (let index = branch.length - 1;index >= 0; index -= 1) {
34800
+ const entry = branch[index];
34801
+ if (typeof entry === "object" && entry !== null && "type" in entry && entry.type === "custom" && "customType" in entry && entry.customType === PHASE_STALL_ENTRY && "data" in entry && typeof entry.data === "object" && entry.data !== null && "state" in entry.data && isPhaseStall(entry.data.state)) {
34802
+ return entry.data.state;
34803
+ }
34804
+ }
34805
+ return "closed";
34806
+ }
34807
+
34750
34808
  // src/legion/role-claim-bridge.ts
34751
34809
  var LEGION_ROLE_CLAIM_BRIDGE = Symbol.for("legion.pi-envoy.role-claim-bridge");
34752
34810
  function legionRoleClaimBridge() {
@@ -35391,7 +35449,8 @@ function envoyExtension(pi) {
35391
35449
  detail: "This conversation now lives under a new session id. Any identity you shared earlier (envoy_whoami output, session ids named in messages) is stale, and peers using the old id cannot reach you. Re-run envoy_whoami before identifying yourself and re-announce the new id to peers you already introduced yourself to."
35392
35450
  }
35393
35451
  }),
35394
- display: true
35452
+ display: true,
35453
+ details: LOCAL_ENVOY_NOTICE
35395
35454
  }, { deliverAs: "steer", triggerTurn: false });
35396
35455
  };
35397
35456
  pi.on("session_switch", (event, context) => rebind(event.reason, context));
@@ -35496,7 +35555,7 @@ ${formatOpenAsksSummary(open.snapshot, open.url)}`,
35496
35555
  }
35497
35556
  });
35498
35557
  const announceFollow = createFollowAnnouncer((text) => {
35499
- pi.sendMessage({ customType: "envoy-message", content: text, display: true }, { deliverAs: "steer", triggerTurn: false });
35558
+ pi.sendMessage({ customType: "envoy-message", content: text, display: true, details: LOCAL_ENVOY_NOTICE }, { deliverAs: "steer", triggerTurn: false });
35500
35559
  });
35501
35560
  pi.on("tool_result", async (event) => {
35502
35561
  if (event.isError)
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: "1.65.2",
16145
+ version: "1.66.1",
16146
16146
  type: "module",
16147
16147
  omp: {
16148
16148
  extensions: [
@@ -31326,6 +31326,13 @@ async function writeGrantFile(file2, grantId) {
31326
31326
  throw new Error(`LEGION_GRANT_FILE ${file2} could not be written: ${messageFor(error48)}`);
31327
31327
  }
31328
31328
  }
31329
+ async function writeMintedGrant(mint) {
31330
+ const file2 = process.env.LEGION_GRANT_FILE;
31331
+ if (file2 === undefined || file2.trim() === "") {
31332
+ throw new Error("LEGION_GRANT_FILE is not set on this pane: the daemon that launched it predates this plugin; restart the daemon on the matching release");
31333
+ }
31334
+ await writeGrantFile(file2, await mint());
31335
+ }
31329
31336
 
31330
31337
  // src/legion/go-bootstrap.ts
31331
31338
  import path3 from "path";
@@ -31799,6 +31806,141 @@ function toolFailure(error48) {
31799
31806
  return { content: [{ type: "text", text: messageFor(error48) }], details: {}, isError: true };
31800
31807
  }
31801
31808
 
31809
+ // src/legion/handoff-actions.ts
31810
+ import { execFile as execFile2 } from "child_process";
31811
+ var HANDOFF_OPERATION_FIELDS = {
31812
+ handoff_write: ["phase", "data"],
31813
+ handoff_read: ["phase"],
31814
+ handoff_message: ["sender", "recipient", "body"],
31815
+ handoff_complete: ["summary", "verdict", "ready", "phase"]
31816
+ };
31817
+ var HANDOFF_OPERATIONS = Object.keys(HANDOFF_OPERATION_FIELDS);
31818
+ function isHandoffOperation(operation) {
31819
+ return Object.hasOwn(HANDOFF_OPERATION_FIELDS, operation);
31820
+ }
31821
+ function handoffSchemaFields(z2) {
31822
+ return {
31823
+ phase: z2.enum(HANDOFF_PHASES).optional(),
31824
+ data: z2.unknown().optional(),
31825
+ sender: z2.enum(HANDOFF_PHASES).optional(),
31826
+ recipient: z2.enum(HANDOFF_PHASES).optional(),
31827
+ body: z2.string().optional(),
31828
+ summary: z2.string().optional(),
31829
+ verdict: z2.enum(["pass", "fail"]).optional(),
31830
+ ready: z2.boolean().optional()
31831
+ };
31832
+ }
31833
+ var HANDOFF_DESCRIPTION = "Handoff actions (phase workers and sub-architects): handoff_write writes this phase's handoff " + "(`phase`, and `data`: the phase-specific fields as a JSON object) to `.legion/<phase>.json` in " + "the issue workspace; handoff_read returns the handoffs (every phase, or `phase`); " + "handoff_message leaves a message for another phase (`sender`, `recipient`, `body`); " + "handoff_complete reports this phase complete to the daemon (`summary`: two sentences for the " + "architect; `verdict` pass|fail when your role's instructions require one; `ready: true` for " + "the merger's READY; no `phase` is needed, and one other than your own is refused). Each " + "returns the command's output; a failed action changed nothing.";
31834
+ var ROLE_HANDOFF_PHASE = {
31835
+ architect: "architect",
31836
+ planner: "plan",
31837
+ plan: "plan",
31838
+ implementer: "implement",
31839
+ implement: "implement",
31840
+ tester: "test",
31841
+ test: "test",
31842
+ reviewer: "review",
31843
+ review: "review"
31844
+ };
31845
+ var COMMAND_TIMEOUT_MS = 300000;
31846
+ function required2(parameters, operation, name) {
31847
+ const value = parameters[name];
31848
+ if (typeof value !== "string" || value.trim() === "") {
31849
+ throw new Error(`${operation} requires ${name}`);
31850
+ }
31851
+ return value;
31852
+ }
31853
+ function commandArguments(operation, parameters) {
31854
+ const accepted = HANDOFF_OPERATION_FIELDS[operation];
31855
+ for (const name of Object.keys(parameters)) {
31856
+ if (name !== "op" && !accepted.includes(name)) {
31857
+ throw new Error(`${operation} does not accept field "${name}"`);
31858
+ }
31859
+ }
31860
+ switch (operation) {
31861
+ case "handoff_write": {
31862
+ const data = parameters.data;
31863
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
31864
+ throw new Error("handoff_write requires data: the phase's handoff fields as a JSON object");
31865
+ }
31866
+ const phase = required2(parameters, operation, "phase");
31867
+ return ["handoff", "write", "--phase", phase, "--data", JSON.stringify(data)];
31868
+ }
31869
+ case "handoff_read":
31870
+ return parameters.phase === undefined ? ["handoff", "read"] : ["handoff", "read", "--phase", required2(parameters, operation, "phase")];
31871
+ case "handoff_message":
31872
+ return [
31873
+ "handoff",
31874
+ "message",
31875
+ "--from",
31876
+ required2(parameters, operation, "sender"),
31877
+ "--to",
31878
+ required2(parameters, operation, "recipient"),
31879
+ "--body",
31880
+ required2(parameters, operation, "body")
31881
+ ];
31882
+ case "handoff_complete": {
31883
+ const command = [
31884
+ "handoff",
31885
+ "complete",
31886
+ "--summary",
31887
+ required2(parameters, operation, "summary")
31888
+ ];
31889
+ const { verdict, ready, phase } = parameters;
31890
+ if (phase !== undefined) {
31891
+ const role = requiredEnvironment(process.env, "LEGION_ROLE");
31892
+ const own = ROLE_HANDOFF_PHASE[role];
31893
+ if (own === undefined) {
31894
+ throw new Error(`handoff_complete takes no phase for a ${role}, which writes no handoff`);
31895
+ }
31896
+ if (phase !== own) {
31897
+ throw new Error(`handoff_complete's phase is "${own}" for a ${role}: omit phase, or pass "${own}"`);
31898
+ }
31899
+ }
31900
+ if (verdict !== undefined) {
31901
+ if (verdict !== "pass" && verdict !== "fail") {
31902
+ throw new Error("handoff_complete's verdict is pass or fail");
31903
+ }
31904
+ command.push("--verdict", verdict);
31905
+ }
31906
+ if (ready !== undefined && typeof ready !== "boolean") {
31907
+ throw new Error("handoff_complete's ready is true or false");
31908
+ }
31909
+ if (ready === true)
31910
+ command.push("--ready");
31911
+ return command;
31912
+ }
31913
+ }
31914
+ }
31915
+ function runLegion(args, cwd, signal) {
31916
+ const { promise: promise2, resolve, reject } = Promise.withResolvers();
31917
+ execFile2("legion", args, { cwd, env: process.env, signal, timeout: COMMAND_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (error48, stdout, stderr) => {
31918
+ const output = [stdout, stderr].map((text) => text.trimEnd()).filter((text) => text !== "").join(`
31919
+ `);
31920
+ if (error48 === null) {
31921
+ resolve({ exitCode: 0, output });
31922
+ } else if (typeof error48.code === "number") {
31923
+ resolve({ exitCode: error48.code, output });
31924
+ } else {
31925
+ reject(new Error(`legion ${args.slice(0, 2).join(" ")} did not run: ${messageFor(error48)}`));
31926
+ }
31927
+ });
31928
+ return promise2;
31929
+ }
31930
+ async function runHandoffAction(input) {
31931
+ const args = commandArguments(input.operation, input.parameters);
31932
+ if (input.operation === "handoff_complete")
31933
+ await writeMintedGrant(input.mintGrant);
31934
+ const { exitCode, output } = await runLegion(args, requiredEnvironment(process.env, "LEGION_WORKSPACE"), input.signal);
31935
+ const text = output === "" ? `legion ${args.slice(0, 2).join(" ")} exited ${exitCode}` : output;
31936
+ const details = { operation: input.operation, exitCode };
31937
+ if (exitCode !== 0)
31938
+ return { content: [{ type: "text", text }], details, isError: true };
31939
+ if (input.operation === "handoff_complete")
31940
+ input.onPhaseCompleted();
31941
+ return { content: [{ type: "text", text }], details };
31942
+ }
31943
+
31802
31944
  // src/legion/go-tools.ts
31803
31945
  var OPERATIONS = {
31804
31946
  architect: [
@@ -31829,7 +31971,8 @@ function toolSchema(pi) {
31829
31971
  "request_backward_move",
31830
31972
  "retry_or_escalate",
31831
31973
  "sign_off",
31832
- "read_record"
31974
+ "read_record",
31975
+ ...HANDOFF_OPERATIONS
31833
31976
  ]),
31834
31977
  issue: z2.string().optional(),
31835
31978
  artifactId: z2.string().optional(),
@@ -31837,7 +31980,8 @@ function toolSchema(pi) {
31837
31980
  issues: z2.array(z2.string()).optional(),
31838
31981
  to: z2.enum(LEGION_GO_PHASES).optional(),
31839
31982
  reason: z2.string().optional(),
31840
- decision: z2.enum(["retry", "escalate"]).optional()
31983
+ decision: z2.enum(["retry", "escalate"]).optional(),
31984
+ ...handoffSchemaFields(z2)
31841
31985
  });
31842
31986
  }
31843
31987
  function requiredString(parameters, operation, name) {
@@ -31874,17 +32018,29 @@ function recordFrom(state, issue2) {
31874
32018
  return record2;
31875
32019
  }
31876
32020
  function createGoLegionTool(deps) {
31877
- const { pi, daemon, session } = deps;
32021
+ const { pi, daemon, session, onPhaseCompleted } = deps;
31878
32022
  return {
31879
32023
  name: "legion",
31880
32024
  label: "legion",
31881
- description: "Perform the workflow operation the Go Legion daemon assigned this role. The daemon advances phases; this tool cannot spawn workers.",
32025
+ description: "Perform the workflow operation the Go Legion daemon assigned this role. The daemon advances phases; this tool cannot spawn workers. " + HANDOFF_DESCRIPTION,
31882
32026
  defaultInactive: true,
31883
32027
  parameters: toolSchema(pi),
31884
- execute: async (_id, parameters, _signal, _onUpdate, context) => {
32028
+ execute: async (_id, parameters, signal, _onUpdate, context) => {
31885
32029
  try {
31886
32030
  const active = session(context);
31887
32031
  const operation = requiredString(parameters, "legion", "op");
32032
+ if (isHandoffOperation(operation)) {
32033
+ if (active.kind === "architect" && active.issue === active.tree) {
32034
+ throw new Error(`${operation} is not available to a root architect session`);
32035
+ }
32036
+ return await runHandoffAction({
32037
+ operation,
32038
+ parameters,
32039
+ signal,
32040
+ mintGrant: () => grantFor(daemon(), active),
32041
+ onPhaseCompleted: () => onPhaseCompleted(context)
32042
+ });
32043
+ }
31888
32044
  if (!OPERATIONS[active.kind].includes(operation)) {
31889
32045
  throw new Error(`${operation} is not available to a ${active.kind} session`);
31890
32046
  }
@@ -31957,13 +32113,71 @@ function createGoLegionTool(deps) {
31957
32113
  };
31958
32114
  }
31959
32115
 
32116
+ // src/legion/phase-stall.ts
32117
+ var PHASE_STALL_ENTRY = "legion-phase-stall";
32118
+ var LOCAL_ENVOY_NOTICE = { localNotice: true };
32119
+ var WAITING_REPLY = /^\W*WAITING\b/m;
32120
+ var TOOL_CALL_SHAPED = /<(?:[\w-]+:)?(?:function_calls\b|invoke\s+name\s*=|parameter\s+name\s*=)/;
32121
+ var FOLLOW_UP = "Your turn ended with your Legion phase still open: the `legion` tool's `handoff_complete` has " + "not succeeded since your assignment. If your phase's work is done, call the `legion` tool now " + "with op `handoff_complete` and the fields your role's instructions give. If you are waiting on " + "something (CI, a review, a person), reply with a line starting WAITING that names what you are " + "waiting on; nothing more will be sent until a new event arrives.";
32122
+ var TEXT_TOOL_CALL = "Your last message holds a tool call written as text, so it did not run: make it a real tool call. ";
32123
+ function stepPhaseStall(state, input) {
32124
+ switch (input.kind) {
32125
+ case "assignment":
32126
+ return { state: "open" };
32127
+ case "handoff-complete":
32128
+ return { state: "closed" };
32129
+ case "inbound-event":
32130
+ return { state: state === "quiet" ? "open" : state };
32131
+ case "settle":
32132
+ if (state !== "open")
32133
+ return { state };
32134
+ if (WAITING_REPLY.test(input.lastAssistantText))
32135
+ return { state: "quiet" };
32136
+ return {
32137
+ state: "quiet",
32138
+ followUp: TOOL_CALL_SHAPED.test(input.lastAssistantText) ? TEXT_TOOL_CALL + FOLLOW_UP : FOLLOW_UP
32139
+ };
32140
+ }
32141
+ }
32142
+ function inboundKind(message) {
32143
+ if (typeof message !== "object" || message === null || !("role" in message))
32144
+ return;
32145
+ if (message.role === "user")
32146
+ return "assignment";
32147
+ if (message.role === "custom" && "customType" in message && message.customType === "envoy-message" && !(("details" in message) && typeof message.details === "object" && message.details !== null && ("localNotice" in message.details) && message.details.localNotice === LOCAL_ENVOY_NOTICE.localNotice))
32148
+ return "inbound-event";
32149
+ return;
32150
+ }
32151
+ function assistantText(message) {
32152
+ if (typeof message !== "object" || message === null)
32153
+ return "";
32154
+ if (!("role" in message) || message.role !== "assistant" || !("content" in message))
32155
+ return "";
32156
+ if (!Array.isArray(message.content))
32157
+ return "";
32158
+ return message.content.map((block) => typeof block === "object" && block !== null && ("type" in block) && block.type === "text" && ("text" in block) && typeof block.text === "string" ? block.text : "").join(`
32159
+ `);
32160
+ }
32161
+ function isPhaseStall(value) {
32162
+ return value === "closed" || value === "open" || value === "quiet";
32163
+ }
32164
+ function restorePhaseStall(branch) {
32165
+ for (let index = branch.length - 1;index >= 0; index -= 1) {
32166
+ const entry = branch[index];
32167
+ if (typeof entry === "object" && entry !== null && "type" in entry && entry.type === "custom" && "customType" in entry && entry.customType === PHASE_STALL_ENTRY && "data" in entry && typeof entry.data === "object" && entry.data !== null && "state" in entry.data && isPhaseStall(entry.data.state)) {
32168
+ return entry.data.state;
32169
+ }
32170
+ }
32171
+ return "closed";
32172
+ }
32173
+
31960
32174
  // src/legion/tools.ts
31961
32175
  import { randomUUID as randomUUID2 } from "crypto";
31962
32176
  var jsonSuccess2 = (details) => toolSuccess(JSON.stringify(details), details);
31963
32177
  function isDispatchArtifactId(value) {
31964
32178
  return LegionDaemonApi.GatesRegister.request.shape.artifactId.safeParse(value).success;
31965
32179
  }
31966
- var LEGION_OP_FIELDS = {
32180
+ var ARCHITECT_OP_FIELDS = {
31967
32181
  set_status: ["issue", "status"],
31968
32182
  register_gate: ["issue", "artifactId", "version"],
31969
32183
  release_wave: ["issues"],
@@ -31973,7 +32187,7 @@ var LEGION_OP_FIELDS = {
31973
32187
  function legionToolSchema(pi) {
31974
32188
  const z2 = pi.zod;
31975
32189
  return z2.object({
31976
- op: z2.enum(["set_status", "register_gate", "release_wave", "escalate", "spawn_worker"]),
32190
+ op: z2.enum([...Object.keys(ARCHITECT_OP_FIELDS), ...HANDOFF_OPERATIONS]),
31977
32191
  issue: z2.string().optional(),
31978
32192
  status: z2.enum(ISSUE_STATUSES).optional(),
31979
32193
  artifactId: z2.string().optional(),
@@ -31983,30 +32197,51 @@ function legionToolSchema(pi) {
31983
32197
  issues: z2.array(z2.string()).optional(),
31984
32198
  rationale: z2.string().optional(),
31985
32199
  role: z2.enum(LEGION_ROLES).optional(),
31986
- task: z2.string().optional()
32200
+ task: z2.string().optional(),
32201
+ ...handoffSchemaFields(z2)
31987
32202
  });
31988
32203
  }
31989
32204
  function createLegionTool(deps) {
31990
- const { pi, roleDaemon, architectSession } = deps;
32205
+ const { pi, roleDaemon, session, onPhaseCompleted } = deps;
31991
32206
  return {
31992
32207
  name: "legion",
31993
32208
  label: "legion",
31994
- description: "Perform a Legion lifecycle write through the Legion daemon. " + "register_gate records the root spec document a human must approve: `artifactId` is the " + "document id (a UUID) and `version` the version number, both copied from the `artifact` and " + "`version` fields of dispatch_request_approval's result \u2014 never the slug or file name you " + "passed to that tool. " + `spawn_worker's response "status" means: "spawned" \u2014 a fresh pane just opened and is ` + 'running now; "resumed" \u2014 an existing worker was prompted directly over its live socket ' + "and its turn started, or (if its boot has not confirmed yet) its task was recorded to " + 'deliver once that boot completes; "queued" \u2014 the task was recorded and this role will ' + "start on its own: either the running-worker cap is full, or the live worker acknowledged " + "the task without starting a turn and the daemon is retrying it. Never re-spawn a role " + 'after "resumed" or "queued" \u2014 wait for the worker-started notification instead. A call ' + 'that fails with "got no response in 3 attempts" was retried by the plugin with one ' + "request id; the daemon may still have received it \u2014 read legion state " + "(workerAdmission.queue, and the role in roles) before sending it again. A spawn_worker " + "identical to the task already queued for the role changes nothing and is not announced again.",
32209
+ description: "Perform a Legion lifecycle write through the Legion daemon. " + "Architect operations (set_status, register_gate, release_wave, escalate, spawn_worker): " + "register_gate records the root spec document a human must approve: `artifactId` is the " + "document id (a UUID) and `version` the version number, both copied from the `artifact` and " + "`version` fields of dispatch_request_approval's result \u2014 never the slug or file name you " + "passed to that tool. " + `spawn_worker's response "status" means: "spawned" \u2014 a fresh pane just opened and is ` + 'running now; "resumed" \u2014 an existing worker was prompted directly over its live socket ' + "and its turn started, or (if its boot has not confirmed yet) its task was recorded to " + 'deliver once that boot completes; "queued" \u2014 the task was recorded and this role will ' + "start on its own: either the running-worker cap is full, or the live worker acknowledged " + "the task without starting a turn and the daemon is retrying it. Never re-spawn a role " + 'after "resumed" or "queued" \u2014 wait for the worker-started notification instead. A call ' + 'that fails with "got no response in 3 attempts" was retried by the plugin with one ' + "request id; the daemon may still have received it \u2014 read legion state " + "(workerAdmission.queue, and the role in roles) before sending it again. A spawn_worker " + "identical to the task already queued for the role changes nothing and is not announced again. " + HANDOFF_DESCRIPTION,
31995
32210
  defaultInactive: true,
31996
32211
  parameters: legionToolSchema(pi),
31997
- execute: async (_id, parameters, _signal, _onUpdate, context) => {
32212
+ execute: async (_id, parameters, signal, _onUpdate, context) => {
31998
32213
  try {
31999
- const architect = architectSession(context);
32000
- const daemon = roleDaemon();
32214
+ const active = session(context);
32001
32215
  const sessionId = context.sessionManager.getSessionId();
32216
+ const op = String(parameters.op);
32217
+ if (isHandoffOperation(op)) {
32218
+ if (active.kind !== "phase-worker") {
32219
+ throw new Error(`${op} is not available to a root architect session`);
32220
+ }
32221
+ return await runHandoffAction({
32222
+ operation: op,
32223
+ parameters,
32224
+ signal,
32225
+ mintGrant: async () => (await roleDaemon().grant({
32226
+ tree: active.tree,
32227
+ issue: active.issue,
32228
+ sessionId,
32229
+ secret: active.secret
32230
+ })).grantId,
32231
+ onPhaseCompleted: () => onPhaseCompleted(context)
32232
+ });
32233
+ }
32234
+ if (active.role !== "architect") {
32235
+ throw new Error(`${op} is not available to a ${active.role} session`);
32236
+ }
32237
+ const daemon = roleDaemon();
32002
32238
  const stringInput = (name) => {
32003
32239
  const value = parameters[name];
32004
32240
  if (typeof value !== "string")
32005
32241
  throw new Error(`${String(parameters.op)} requires ${name}`);
32006
32242
  return value;
32007
32243
  };
32008
- const op = String(parameters.op);
32009
- const allowedFields = LEGION_OP_FIELDS[op];
32244
+ const allowedFields = ARCHITECT_OP_FIELDS[op];
32010
32245
  if (allowedFields) {
32011
32246
  for (const key of Object.keys(parameters)) {
32012
32247
  if (key !== "op" && !allowedFields.includes(key)) {
@@ -32021,9 +32256,9 @@ function createLegionTool(deps) {
32021
32256
  throw new Error("set_status requires a valid Legion issue status");
32022
32257
  }
32023
32258
  await daemon.issueStatus({
32024
- tree: architect.tree,
32259
+ tree: active.tree,
32025
32260
  sessionId,
32026
- secret: architect.secret,
32261
+ secret: active.secret,
32027
32262
  issue: stringInput("issue"),
32028
32263
  status
32029
32264
  });
@@ -32039,9 +32274,9 @@ function createLegionTool(deps) {
32039
32274
  throw new Error(`register_gate requires artifactId to be the document id (a UUID) from dispatch_request_approval's result, not "${artifactId}"`);
32040
32275
  }
32041
32276
  await daemon.gatesRegister({
32042
- tree: architect.tree,
32277
+ tree: active.tree,
32043
32278
  sessionId,
32044
- secret: architect.secret,
32279
+ secret: active.secret,
32045
32280
  issue: stringInput("issue"),
32046
32281
  artifactId,
32047
32282
  version: version2
@@ -32054,10 +32289,10 @@ function createLegionTool(deps) {
32054
32289
  throw new Error("release_wave requires issues");
32055
32290
  }
32056
32291
  return jsonSuccess2(await daemon.releaseWave({
32057
- tree: architect.tree,
32292
+ tree: active.tree,
32058
32293
  issues,
32059
32294
  sessionId,
32060
- secret: architect.secret
32295
+ secret: active.secret
32061
32296
  }));
32062
32297
  }
32063
32298
  case "escalate": {
@@ -32068,11 +32303,11 @@ function createLegionTool(deps) {
32068
32303
  if (!("context" in parameters) || parameters.context === undefined)
32069
32304
  throw new Error("escalate requires context");
32070
32305
  await daemon.escalate({
32071
- tree: architect.tree,
32306
+ tree: active.tree,
32072
32307
  kind,
32073
32308
  context: parameters.context,
32074
32309
  sessionId,
32075
- secret: architect.secret
32310
+ secret: active.secret
32076
32311
  });
32077
32312
  return jsonSuccess2({});
32078
32313
  }
@@ -32083,9 +32318,9 @@ function createLegionTool(deps) {
32083
32318
  }
32084
32319
  const requestId = randomUUID2();
32085
32320
  return jsonSuccess2(await daemon.spawnWorker({
32086
- tree: architect.tree,
32321
+ tree: active.tree,
32087
32322
  sessionId,
32088
- secret: architect.secret,
32323
+ secret: active.secret,
32089
32324
  issue: stringInput("issue"),
32090
32325
  role,
32091
32326
  task: stringInput("task"),
@@ -32180,16 +32415,8 @@ var callReadyWithRetry = async (label, call) => {
32180
32415
  }
32181
32416
  };
32182
32417
  async function wrapBashWithGrant(mint) {
32183
- const grantFile = process.env.LEGION_GRANT_FILE;
32184
- if (grantFile === undefined || grantFile.trim() === "") {
32185
- return {
32186
- block: true,
32187
- reason: "LEGION_GRANT_FILE is not set on this pane: the daemon that launched it predates this plugin; restart the daemon on the matching release"
32188
- };
32189
- }
32190
32418
  try {
32191
- const grant = await mint();
32192
- await writeGrantFile(grantFile, grant.grantId);
32419
+ await writeMintedGrant(async () => (await mint()).grantId);
32193
32420
  return;
32194
32421
  } catch (error48) {
32195
32422
  return { block: true, reason: messageFor(error48) };
@@ -32355,6 +32582,16 @@ function legionExtension(pi) {
32355
32582
  let bootstrap;
32356
32583
  const checkSubagentSession = subagentSessionCheck();
32357
32584
  let phaseWorkerPane;
32585
+ let phaseStall = "closed";
32586
+ const phaseWorkerSession = (context) => capability !== undefined && capability.sessionID === context.sessionManager.getSessionId() && capability.role !== "architect";
32587
+ const advancePhaseStall = (input) => {
32588
+ const step = stepPhaseStall(phaseStall, input);
32589
+ if (step.state !== phaseStall) {
32590
+ phaseStall = step.state;
32591
+ pi.appendEntry(PHASE_STALL_ENTRY, { state: phaseStall });
32592
+ }
32593
+ return step.followUp;
32594
+ };
32358
32595
  let daemonClient;
32359
32596
  const roleDaemon = () => {
32360
32597
  daemonClient ??= createLegionDaemonClient(requiredEnvironment(process.env, "LEGION_DAEMON_URL"), fetch, {
@@ -32504,7 +32741,7 @@ function legionExtension(pi) {
32504
32741
  generation: generation(process.env)
32505
32742
  }));
32506
32743
  });
32507
- registerArchitectTools();
32744
+ registerLegionTool();
32508
32745
  await activateLegionTool();
32509
32746
  } catch (error48) {
32510
32747
  if (error48 instanceof LegionDaemonApiError && (error48.status === 401 || error48.status === 403)) {
@@ -32560,10 +32797,8 @@ function legionExtension(pi) {
32560
32797
  secret: started.secret
32561
32798
  };
32562
32799
  await claimEnvoyRole(sessionID, started.roleToken, context);
32563
- if (role === "architect") {
32564
- registerArchitectTools();
32565
- await activateLegionTool();
32566
- }
32800
+ registerLegionTool();
32801
+ await activateLegionTool();
32567
32802
  await callReadyWithRetry("worker/ready", () => roleDaemon().workerReady({
32568
32803
  tree,
32569
32804
  issue: issue2,
@@ -32591,6 +32826,7 @@ function legionExtension(pi) {
32591
32826
  pi.on("session_start", async (_event, context) => {
32592
32827
  if (await checkSubagentSession(context))
32593
32828
  return;
32829
+ phaseStall = restorePhaseStall(context.sessionManager.getBranch?.() ?? []);
32594
32830
  if (process.env.LEGION_DAEMON_API === "go") {
32595
32831
  await bootstrapGoClaim(context, {
32596
32832
  capability: () => capability,
@@ -32695,6 +32931,22 @@ function legionExtension(pi) {
32695
32931
  secret: active.secret
32696
32932
  }));
32697
32933
  });
32934
+ pi.on("message_start", async (event, context) => {
32935
+ if (!phaseWorkerSession(context))
32936
+ return;
32937
+ const kind = inboundKind(event.message);
32938
+ if (kind !== undefined)
32939
+ advancePhaseStall({ kind });
32940
+ });
32941
+ pi.on("session_stop", async (event, context) => {
32942
+ if (event.signal.aborted || !phaseWorkerSession(context))
32943
+ return;
32944
+ const followUp = advancePhaseStall({
32945
+ kind: "settle",
32946
+ lastAssistantText: assistantText(event.last_assistant_message)
32947
+ });
32948
+ return followUp === undefined ? undefined : { continue: true, additionalContext: followUp };
32949
+ });
32698
32950
  pi.on("session_shutdown", async (_event, context) => {
32699
32951
  const sessionID = context.sessionManager.getSessionId();
32700
32952
  if (capability === undefined || capability.kind !== "root-architect" || capability.sessionID !== sessionID) {
@@ -32714,17 +32966,22 @@ function legionExtension(pi) {
32714
32966
  controlConnection = undefined;
32715
32967
  }
32716
32968
  });
32717
- const architectSession = (context) => {
32969
+ const toolSession = (context) => {
32718
32970
  const sessionID = context.sessionManager.getSessionId();
32719
- if (capability !== undefined && capability.sessionID === sessionID && capability.role === "architect") {
32720
- return {
32721
- tree: capability.tree,
32722
- issue: capability.issue,
32723
- role: capability.role,
32724
- secret: capability.secret
32725
- };
32971
+ if (capability === undefined || capability.sessionID !== sessionID) {
32972
+ throw new Error("legion is available only to this session's registered Legion role");
32726
32973
  }
32727
- throw new Error("legion is available only to root and sub-architect sessions");
32974
+ return {
32975
+ kind: capability.kind,
32976
+ tree: capability.tree,
32977
+ issue: capability.issue,
32978
+ role: capability.role,
32979
+ secret: capability.secret
32980
+ };
32981
+ };
32982
+ const onPhaseCompleted = (context) => {
32983
+ if (phaseWorkerSession(context))
32984
+ advancePhaseStall({ kind: "handoff-complete" });
32728
32985
  };
32729
32986
  const activateLegionTool = async () => {
32730
32987
  const activeTools = pi.getActiveTools();
@@ -32732,12 +32989,12 @@ function legionExtension(pi) {
32732
32989
  return;
32733
32990
  await pi.setActiveTools([...activeTools, "legion"]);
32734
32991
  };
32735
- let architectToolsRegistered = false;
32736
- const registerArchitectTools = () => {
32737
- if (architectToolsRegistered)
32992
+ let legionToolRegistered = false;
32993
+ const registerLegionTool = () => {
32994
+ if (legionToolRegistered)
32738
32995
  return;
32739
- architectToolsRegistered = true;
32740
- pi.registerTool(createLegionTool({ pi, roleDaemon, architectSession }));
32996
+ legionToolRegistered = true;
32997
+ pi.registerTool(createLegionTool({ pi, roleDaemon, session: toolSession, onPhaseCompleted }));
32741
32998
  };
32742
32999
  let goToolsRegistered = false;
32743
33000
  const registerGoTools = () => {
@@ -32760,7 +33017,8 @@ function legionExtension(pi) {
32760
33017
  issue: active.issue,
32761
33018
  secret: active.secret
32762
33019
  };
32763
- }
33020
+ },
33021
+ onPhaseCompleted
32764
33022
  }));
32765
33023
  };
32766
33024
  pi.registerCommand("legion-claim-controller", {
@@ -21,7 +21,7 @@ separate coordinator to finish necessary work.
21
21
  instead of starting a fresh one. Phases on one issue are strictly sequential -- one role
22
22
  is the issue's active phase at a time, and calling `spawn_worker` for a different role
23
23
  while a phase is active supersedes that phase: the superseded worker's
24
- `legion handoff complete` is then refused, so finish (or deliberately abandon) one role
24
+ `handoff_complete` is then refused, so finish (or deliberately abandon) one role
25
25
  before assigning the next. Phase workers escalate lifecycle, scope, and
26
26
  cross-phase matters the same way: `envoy_publish` to your own encoded token. Any role
27
27
  may use `dispatch_ask` directly for a standalone human question; replies return to the
@@ -108,8 +108,8 @@ dispatch_message({
108
108
 
109
109
  The Dispatch message and the `docs/solutions/` commit are the only retro outputs. Never write a
110
110
  handoff, phase artifact, local feedback log, or completion label; `.legion/` was deleted before
111
- retro and nothing recreates it. Report completion with `legion handoff complete` alone (its
112
- summary: two sentences for the architect) — no `legion handoff write`.
111
+ retro and nothing recreates it. Report completion with the `legion` tool's `handoff_complete` alone (its
112
+ summary: two sentences for the architect) — no `handoff_write`.
113
113
 
114
114
  ## Completion check
115
115
 
@@ -105,7 +105,7 @@ Read only files that precede the assigned phase. Every handoff is validated when
105
105
  `validatePhaseHandoff` (`packages/contracts/src/handoff-schema.ts`) checks the file, and the
106
106
  ledger (`packages/daemon/src/handoff/ledger.ts`) treats a file that fails validation as missing.
107
107
  Undeclared fields pass validation untouched and reach the next worker; a declared field of the
108
- wrong type fails the whole file, so `legion handoff read` returns null for that phase.
108
+ wrong type fails the whole file, so the `legion` tool's `handoff_read` returns null for that phase.
109
109
  Write the phase-specific fields the next phase and the architect need, consistent with what
110
110
  predecessor phases already wrote. The durable copy lives in
111
111
  `$LEGION_WORKSPACE/.legion/<phase>.json`. If a committed handoff conflicts with memory or a prior
@@ -190,8 +190,9 @@ comments live on that path too, so edit them with `gh pr comment`) — printing
190
190
  Legion never reads or writes a GitHub issue (LEGION-78). `pr comment`, `pr review`,
191
191
  `api …/pulls/…`, `api graphql`, and issue reads are unaffected. The credential reaches `legion`
192
192
  through the file `$LEGION_GRANT_FILE` names, written before each of your bash commands by the
193
- extension; never `cat`, `echo`, copy, or `export` it — `legion credential`, `legion gh`,
194
- `jj git push`, and `legion handoff complete` read it themselves. The file is the pane's, not the
193
+ extension (and by the `legion` tool before its `handoff_complete`); never `cat`, `echo`, copy, or
194
+ `export` it — `legion credential`, `legion gh`, `jj git push`, and `handoff_complete` read it
195
+ themselves. The file is the pane's, not the
195
196
  command's: a `task` subagent, an `eval` subprocess, or a background job in your pane reads the
196
197
  grant your last bash command minted, so its `legion gh` or `jj git push` succeeds only within 60
197
198
  seconds of that call and 403s afterwards — a timing artifact, not a broken credential; run
@@ -343,7 +344,7 @@ this proof.
343
344
  hides an error, or breaks a gate lands in this PR.
344
345
  - **The implementer proves the change before its phase completes, and writes the `E2E (implementer)` line when the pull request opens.**
345
346
  The proof is the one defined above. It goes into `.legion/implement.json` as the required `proof`
346
- array (`legion handoff write --phase implement` refuses a payload without one, or with a blank or
347
+ array (`handoff_write` for phase `implement` refuses a payload without one, or with a blank or
347
348
  whitespace-only field, and names the field), and into the PR body, because the reviewer and the
348
349
  merger verify facts on GitHub and never from a handoff.
349
350
  - **The tester verifies the implementer's proof and adds its own `E2E (tester)` line.** It re-runs
@@ -352,10 +353,10 @@ this proof.
352
353
  A test handoff whose predecessor carried no proof is a test failure, not a gap for the tester to fill:
353
354
  record it in `failures` with `implementerProof.verdict: "rejected"`, complete the phase, and let
354
355
  the architect return the issue to the implementer — the agent that developed the change owns
355
- proving it (`legion handoff write --phase test` refuses a rejected verdict, or `failed > 0`,
356
+ proving it (`handoff_write` for phase `test` refuses a rejected verdict, or `failed > 0`,
356
357
  with no recorded failure). Otherwise, add your own proof before completing — a proof as defined
357
- above — as the `E2E (tester)` line and the `proof` array `legion handoff write --phase test`
358
- requires whenever you report no failure. A code path whose first execution is after merge — a
358
+ above — as the `E2E (tester)` line and the `proof` array `handoff_write` for
359
+ phase `test` requires whenever you report no failure. A code path whose first execution is after merge — a
359
360
  deploy workflow's inline step, a post-merge helper, a production-only resource — is untested
360
361
  until the implementer has executed it against a devN stack; if no surface can reach it, the
361
362
  tester names that missing surface as the blocker instead of passing the phase. Environment or
@@ -502,14 +503,11 @@ above.
502
503
 
503
504
  ## Completion gate: handoff write, verification, and persistence
504
505
 
505
- Write the phase-specific handoff:
506
+ Write the phase-specific handoff: call the `legion` tool with `op: "handoff_write"`, `phase: "<p>"`,
507
+ and `data`: a JSON object of the phase-specific fields only. It runs `legion handoff write` in
508
+ `$LEGION_WORKSPACE` and returns its output.
506
509
 
507
- ```bash
508
- cd -- "$LEGION_WORKSPACE" && \
509
- legion handoff write --phase <p> --data '<JSON object of phase-specific fields only>'
510
- ```
511
-
512
- `legion handoff write` validates the payload against the phase's schema before writing: an
510
+ `handoff_write` validates the payload against the phase's schema before writing: an
513
511
  implement handoff without a well-formed `proof`, or a test handoff that reports no failure and
514
512
  carries no `proof` of its own, exits 1 naming the field and writes nothing.
515
513
 
@@ -554,17 +552,15 @@ that deletion at the reviewer's direction. No other phase removes it — and onc
554
552
  (`jj -R "$LEGION_WORKSPACE" file list -r @- .legion` prints nothing on stdout; jj warns on
555
553
  stderr), this gate no longer applies: a later rebase, bare-gate re-check, confirmation, retro, or
556
554
  the post-merge production check writes no `.legion/<phase>.json`, commits no handoff, and reports
557
- with `legion handoff complete` alone (below). Recreating `.legion/` after its deletion changes the
555
+ with `handoff_complete` alone (below). Recreating `.legion/` after its deletion changes the
558
556
  approved head and restarts the review loop this rule exists to end.
559
557
 
560
558
  ## Completion: report to the architect, then stay
561
559
 
562
- Report completion to the architect with:
563
-
564
- ```bash
565
- cd -- "$LEGION_WORKSPACE" && \
566
- legion handoff complete --summary '<two sentences for the architect>'
567
- ```
560
+ Report completion to the architect: call the `legion` tool with `op: "handoff_complete"` and
561
+ `summary`: two sentences for the architect. A worker never runs `legion handoff` from bash: the
562
+ tool call is what the extension records, and a turn that ends with the phase still open gets one
563
+ reminder.
568
564
 
569
565
  This publishes your phase's completion to the architect's role and clears the daemon's
570
566
  record of this issue's active phase. Do not add pipeline labels, run a controller loop, or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "1.65.2",
3
+ "version": "1.66.1",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [