@sjawhar/pi-legion 8.10.0 → 8.11.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
@@ -15,8 +15,8 @@ var __export = (target, all) => {
15
15
  };
16
16
 
17
17
  // extensions/legion.ts
18
- import { randomUUID as randomUUID2 } from "crypto";
19
- import path5 from "path";
18
+ import { randomUUID } from "crypto";
19
+ import path4 from "path";
20
20
 
21
21
  // ../envoy-client/src/dispatch-config.ts
22
22
  import { readFileSync as readFileSync2 } from "fs";
@@ -14431,8 +14431,6 @@ var EnvelopeSchema = exports_external.object({
14431
14431
  });
14432
14432
  var MINTED_DEDUPE_KEY_PATTERN = "^(?:envoy\\.role\\.forward\\.)?(?:publish|agent\\.[^.]+)\\.(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$";
14433
14433
  var mintedDedupeKey = new RegExp(MINTED_DEDUPE_KEY_PATTERN);
14434
- // ../contracts/src/handoff-schema.ts
14435
- var HANDOFF_PHASES = ["architect", "plan", "implement", "test", "review"];
14436
14434
  // ../contracts/src/subject.ts
14437
14435
  var LEGION_NOTICE_TOPIC_PREFIX = "notifications.legion.";
14438
14436
  function legionControllerNoticeSubject(project) {
@@ -15163,70 +15161,6 @@ function matchInjectedUserTurn(sessionID, message) {
15163
15161
  return sent.messageId;
15164
15162
  }
15165
15163
 
15166
- // ../pi-shared/src/shell-command.ts
15167
- var HEREDOC_OPENING = /^(.*?)[ \t]*<<(-?)[ \t]*'([A-Za-z_][A-Za-z0-9_]*)'[ \t]*$/;
15168
- var BLANK_LINE = /^[ \t]*$/;
15169
- var REFUSED_UNQUOTED = "\n;&|$`<>()#\\";
15170
- var REFUSED_DOUBLE_QUOTED = "$`\\";
15171
- function dispatchCommandHead(command) {
15172
- if (typeof command !== "string" || hasControlCharacter(command))
15173
- return;
15174
- const all = command.split(`
15175
- `);
15176
- const first = all.findIndex((line) => !BLANK_LINE.test(line));
15177
- if (first === -1)
15178
- return;
15179
- const last = all.findLastIndex((line) => !BLANK_LINE.test(line));
15180
- const lines = all.slice(first, last + 1);
15181
- let head = lines[0] ?? "";
15182
- if (lines.length > 1) {
15183
- const opening = HEREDOC_OPENING.exec(head);
15184
- if (opening === null)
15185
- return;
15186
- const stripTabs = opening[2] === "-";
15187
- const delimiter = opening[3];
15188
- const end = lines.findIndex((line, index) => index > 0 && (stripTabs ? line.replace(/^\t+/, "") : line) === delimiter);
15189
- if (end !== lines.length - 1)
15190
- return;
15191
- head = opening[1] ?? "";
15192
- }
15193
- head = head.replace(/^[ \t]+|[ \t]+$/g, "");
15194
- return firstWord(head) === "dispatch" && headScan(head) ? head : undefined;
15195
- }
15196
- function firstWord(head) {
15197
- return head.split(/[ \t]/, 1)[0] ?? "";
15198
- }
15199
- function hasControlCharacter(text) {
15200
- for (let index = 0;index < text.length; index += 1) {
15201
- const code = text.charCodeAt(index);
15202
- if (code < 32 && code !== 10 && code !== 9 || code === 127)
15203
- return true;
15204
- }
15205
- return false;
15206
- }
15207
- function headScan(head) {
15208
- let quote;
15209
- for (const char of head) {
15210
- if (quote === "'") {
15211
- if (char === "'")
15212
- quote = undefined;
15213
- continue;
15214
- }
15215
- if (quote === '"') {
15216
- if (char === '"')
15217
- quote = undefined;
15218
- else if (REFUSED_DOUBLE_QUOTED.includes(char))
15219
- return false;
15220
- continue;
15221
- }
15222
- if (char === "'" || char === '"')
15223
- quote = char;
15224
- else if (REFUSED_UNQUOTED.includes(char))
15225
- return false;
15226
- }
15227
- return quote === undefined;
15228
- }
15229
-
15230
15164
  // ../pi-shared/src/subagent-session.ts
15231
15165
  import fs from "fs";
15232
15166
  import path2 from "path";
@@ -15310,7 +15244,7 @@ function onEnvoyRoleRegained(listener) {
15310
15244
  // package.json
15311
15245
  var package_default = {
15312
15246
  name: "@sjawhar/pi-legion",
15313
- version: "8.10.0",
15247
+ version: "8.11.0",
15314
15248
  license: "Apache-2.0",
15315
15249
  type: "module",
15316
15250
  omp: {
@@ -15322,7 +15256,7 @@ var package_default = {
15322
15256
  ]
15323
15257
  },
15324
15258
  legion: {
15325
- daemonApiVersion: 18
15259
+ daemonApiVersion: 19
15326
15260
  },
15327
15261
  repository: {
15328
15262
  type: "git",
@@ -15603,39 +15537,10 @@ var LegionGrantResponse = exports_external.strictObject({
15603
15537
  grantId: nonEmptyString,
15604
15538
  expiresAt: timestamp
15605
15539
  });
15606
- var appLogin = exports_external.string().regex(/^[^[\]]+\[bot\]$/);
15607
- var LegionGitHubTokenResponse = exports_external.strictObject({
15608
- token: nonEmptyString,
15609
- appLogin: exports_external.string().endsWith("[bot]"),
15610
- legionAppLogins: exports_external.strictObject({ implement: appLogin, review: appLogin }).optional()
15611
- });
15612
- var LegionGitCredentialResponse = exports_external.strictObject({
15613
- username: exports_external.literal("x-access-token"),
15614
- password: nonEmptyString
15615
- });
15616
- var LegionThreadsResolveRequest = exports_external.strictObject({
15617
- grantId: nonEmptyString,
15618
- repo: nonEmptyString,
15619
- number: exports_external.number().int().positive()
15620
- });
15621
- var LegionThreadOutcome = exports_external.union([
15622
- exports_external.strictObject({
15623
- url: nonEmptyString,
15624
- resolved: nonEmptyString,
15625
- newestBy: nonEmptyString.optional()
15626
- }),
15627
- exports_external.strictObject({
15628
- url: nonEmptyString,
15629
- leftOpen: nonEmptyString,
15630
- newestBy: nonEmptyString.optional()
15631
- })
15632
- ]);
15633
- var LegionThreadsResolveResponse = exports_external.strictObject({
15634
- threads: exports_external.array(LegionThreadOutcome),
15635
- withheld: exports_external.number().int().nonnegative(),
15636
- refused: exports_external.strictObject({ url: nonEmptyString, error: nonEmptyString }).optional()
15637
- });
15638
15540
  var LegionEmptyResponse = exports_external.strictObject({});
15541
+ var LegionHandoffCompleteResponse = exports_external.strictObject({
15542
+ note: nonEmptyString.optional()
15543
+ });
15639
15544
  var LegionWaveReleaseResponse = exports_external.strictObject({
15640
15545
  released: exports_external.array(nonEmptyString)
15641
15546
  });
@@ -15643,16 +15548,12 @@ var LegionGrantRequest = exports_external.strictObject({
15643
15548
  sessionId: nonEmptyString,
15644
15549
  secret: nonEmptyString,
15645
15550
  tree: nonEmptyString,
15646
- issue: nonEmptyString,
15647
- push: exports_external.boolean().optional()
15551
+ issue: nonEmptyString
15648
15552
  });
15649
15553
  var LegionControllerGrantRequest = exports_external.strictObject({
15650
15554
  sessionId: nonEmptyString,
15651
15555
  secret: nonEmptyString
15652
15556
  });
15653
- var LegionGrantCredentialRequest = exports_external.strictObject({
15654
- grantId: nonEmptyString
15655
- });
15656
15557
  var LegionHandoffCompleteRequest = exports_external.strictObject({
15657
15558
  grantId: nonEmptyString,
15658
15559
  summary: nonEmptyString,
@@ -15789,10 +15690,7 @@ function createLegionDaemonClient(baseUrl, fetchFn = fetch) {
15789
15690
  exit: (input) => acknowledge("/legion/v1/claims/exit", input),
15790
15691
  grant: (input) => post("/legion/v1/grants", LegionGrantRequest, LegionGrantResponse, input),
15791
15692
  controllerGrant: (input) => post("/legion/v1/grants", LegionControllerGrantRequest, LegionGrantResponse, input),
15792
- githubToken: (input) => post("/legion/v1/gh-token", LegionGrantCredentialRequest, LegionGitHubTokenResponse, input),
15793
- gitCredential: (input) => post("/legion/v1/git-credential", LegionGrantCredentialRequest, LegionGitCredentialResponse, input),
15794
- provisioningCredential: (input) => post("/legion/v1/provisioning-credential", LegionGrantCredentialRequest, LegionGitHubTokenResponse, input),
15795
- handoffComplete: (input) => post("/legion/v1/handoff/complete", LegionHandoffCompleteRequest, LegionEmptyResponse, input),
15693
+ handoffComplete: (input) => post("/legion/v1/handoff/complete", LegionHandoffCompleteRequest, LegionHandoffCompleteResponse, input),
15796
15694
  issueStatus: (input) => post("/legion/v1/issues/status", LegionIssueStatusRequest, LegionEmptyResponse, input),
15797
15695
  gateRegister: (input) => post("/legion/v1/gates/register", LegionGateRegisterRequest, LegionEmptyResponse, input),
15798
15696
  waveRelease: (input) => post("/legion/v1/waves/release", LegionWaveReleaseRequest, LegionWaveReleaseResponse, input),
@@ -16044,7 +15942,7 @@ function createControllerSession(deps) {
16044
15942
  try {
16045
15943
  await claim(context);
16046
15944
  } catch (error48) {
16047
- context.ui.notify(`legion: re-claiming the controller for this session failed (${messageFor(error48)}). Until a re-claim succeeds, controller wakes and merges will not reach this session and its shell commands run without a Legion grant, so legion gh is unavailable.`, "warning");
15945
+ context.ui.notify(`legion: re-claiming the controller for this session failed (${messageFor(error48)}). Until a re-claim succeeds, controller wakes and merges will not reach this session, and the \`legion\` tool's set_status and its other operations that call the daemon have no controller grant to mint and fail.`, "warning");
16048
15946
  }
16049
15947
  };
16050
15948
  const isClaimedSession = (sessionID) => controllerSessionID === sessionID && mintControllerGrant !== undefined;
@@ -16057,31 +15955,88 @@ function createControllerSession(deps) {
16057
15955
  return { claim, handleSessionStart, reclaimAfterSessionChange, isClaimedSession, mintGrant };
16058
15956
  }
16059
15957
 
16060
- // src/grant-file.ts
16061
- import { randomUUID } from "crypto";
16062
- import { chmod, mkdir as mkdir2, rename as rename2, rm, writeFile as writeFile2 } from "fs/promises";
16063
- import path4 from "path";
16064
- async function writeGrantFile(file2, grantId) {
16065
- if (!path4.isAbsolute(file2)) {
16066
- throw new Error(`LEGION_GRANT_FILE ${file2} could not be written: the path is not absolute`);
16067
- }
16068
- const temp = `${file2}.${process.pid}.${randomUUID()}`;
15958
+ // src/handoff-commit.ts
15959
+ import { execFile as execFile3 } from "child_process";
15960
+ import { promisify as promisify3 } from "util";
15961
+ var FILE_BACKED = {
15962
+ planning: { word: "plan", role: "planner" },
15963
+ implementing: { word: "implement", role: "implementer" },
15964
+ testing: { word: "test", role: "tester" },
15965
+ reviewing: { word: "review", role: "reviewer" }
15966
+ };
15967
+ var execFileAsync2 = promisify3(execFile3);
15968
+ var pathJj = async (args, cwd) => {
16069
15969
  try {
16070
- await mkdir2(path4.dirname(file2), { recursive: true, mode: 448 });
16071
- await writeFile2(temp, grantId, { encoding: "utf8", mode: 384 });
16072
- await chmod(temp, 384);
16073
- await rename2(temp, file2);
15970
+ const { stdout } = await execFileAsync2("jj", ["-R", cwd, ...args], {
15971
+ cwd,
15972
+ maxBuffer: 16 * 1024 * 1024
15973
+ });
15974
+ return stdout.trim();
16074
15975
  } catch (error48) {
16075
- await rm(temp, { force: true }).catch(() => {});
16076
- throw new Error(`LEGION_GRANT_FILE ${file2} could not be written: ${messageFor(error48)}`);
15976
+ const detail = error48 !== null && typeof error48 === "object" && "stderr" in error48 ? String(error48.stderr).trim() : error48 instanceof Error ? error48.message : String(error48);
15977
+ throw new Error(`jj ${args.join(" ")} failed: ${detail}`);
16077
15978
  }
16078
- }
16079
- async function writeMintedGrant(mint) {
16080
- const file2 = process.env.LEGION_GRANT_FILE;
16081
- if (file2 === undefined || file2.trim() === "") {
16082
- throw new Error("LEGION_GRANT_FILE is not set on this pane: the daemon that launched it predates this plugin; relaunch the pane from a daemon on the matching release (a daemon restart keeps a live pane as it was launched)");
15979
+ };
15980
+ function requiredVariable(env, name) {
15981
+ const value = env[name];
15982
+ if (value === undefined || value === "") {
15983
+ throw new Error(`${name} is not set, so handoff_complete cannot find the commit to report`);
16083
15984
  }
16084
- await writeGrantFile(file2, await mint());
15985
+ return value;
15986
+ }
15987
+ async function findHandoffCommit(input) {
15988
+ const { phase, role, env } = input;
15989
+ const jj = input.jj ?? pathJj;
15990
+ const workspace = requiredVariable(env, "LEGION_WORKSPACE");
15991
+ const handoff = FILE_BACKED[phase];
15992
+ if (handoff === undefined || handoff.role !== role) {
15993
+ return jj(["log", "-r", "@-", "--no-graph", "-T", "commit_id"], workspace);
15994
+ }
15995
+ const issue2 = requiredVariable(env, "LEGION_ISSUE");
15996
+ if (!DISPATCH_KEY_PATTERN.test(issue2)) {
15997
+ throw new Error(`LEGION_ISSUE ${JSON.stringify(issue2)} is not a Dispatch issue key like LEGION-1`);
15998
+ }
15999
+ const file2 = `.legion/${issue2}/${handoff.word}.json`;
16000
+ const fileset = `root:${JSON.stringify(file2)}`;
16001
+ if (await jj(["diff", "-r", "@", "--name-only", fileset], workspace) !== "") {
16002
+ throw new Error(`${file2} has changes in the working copy that are not committed: commit this phase's handoff before completing`);
16003
+ }
16004
+ if (await jj(["file", "list", "-r", "@", fileset], workspace) === "") {
16005
+ throw new Error(`${file2} is missing from the workspace: write this phase's handoff, then commit and push it before completing`);
16006
+ }
16007
+ const carrying = await jj([
16008
+ "log",
16009
+ "-r",
16010
+ `latest((::@- ~ ::trunk()) & files(${fileset}))`,
16011
+ "--no-graph",
16012
+ "-T",
16013
+ "commit_id"
16014
+ ], workspace);
16015
+ if (carrying === "") {
16016
+ throw new Error(`${file2} is not committed on this issue's branch (only the base branch carries it): write and commit this phase's handoff`);
16017
+ }
16018
+ const user = env.JJ_USER;
16019
+ if (user !== undefined && user !== "") {
16020
+ const author = await jj(["log", "-r", carrying, "--no-graph", "-T", 'author.name() ++ "\\n" ++ author.email()'], workspace);
16021
+ const [name = "", email3 = ""] = author.split(`
16022
+ `, 2);
16023
+ if (name !== user || email3 !== (env.JJ_EMAIL ?? "")) {
16024
+ throw new Error(`${file2} is carried by commit ${carrying}, authored by ${name} <${email3}>, not this pane's ${user}: run jj new, then write and commit this phase's handoff again`);
16025
+ }
16026
+ }
16027
+ const bookmark = `legion/${issue2}`;
16028
+ const pushed = await jj([
16029
+ "log",
16030
+ "-r",
16031
+ `${carrying} & ::remote_bookmarks(exact:${JSON.stringify(bookmark)}, exact:"origin")`,
16032
+ "--no-graph",
16033
+ "-T",
16034
+ "commit_id"
16035
+ ], workspace);
16036
+ if (pushed === "") {
16037
+ throw new Error(`${file2} is carried by commit ${carrying}, which is not on ${bookmark}@origin: push the issue branch, then complete again`);
16038
+ }
16039
+ return carrying;
16085
16040
  }
16086
16041
 
16087
16042
  // src/phase-stall.ts
@@ -16171,132 +16126,6 @@ function toolFailure(error48) {
16171
16126
  return { content: [{ type: "text", text: messageFor(error48) }], details: {}, isError: true };
16172
16127
  }
16173
16128
 
16174
- // src/handoff-actions.ts
16175
- import { execFile as execFile3 } from "child_process";
16176
- var HANDOFF_OPERATION_FIELDS = {
16177
- handoff_write: ["phase", "data"],
16178
- handoff_read: ["phase"],
16179
- handoff_complete: ["summary", "verdict", "ready", "phase"]
16180
- };
16181
- var HANDOFF_OPERATIONS = Object.keys(HANDOFF_OPERATION_FIELDS);
16182
- function isHandoffOperation(operation) {
16183
- return Object.hasOwn(HANDOFF_OPERATION_FIELDS, operation);
16184
- }
16185
- function handoffSchemaFields(z2) {
16186
- return {
16187
- phase: z2.enum(HANDOFF_PHASES).optional(),
16188
- data: z2.unknown().optional(),
16189
- summary: z2.string().optional(),
16190
- verdict: z2.enum(["pass", "fail"]).optional(),
16191
- ready: z2.boolean().optional()
16192
- };
16193
- }
16194
- var HANDOFF_DESCRIPTION = "Handoff actions (phase workers; the daemon accepts a completion only from the role working " + "the issue's current phase, never from an architect): handoff_write writes this phase's handoff " + "(`phase`, and `data`: the phase-specific fields as a JSON object, never the schemaVersion, " + "phase, completed and issue the command writes itself) to `.legion/<issue>/<phase>.json` in " + "the issue workspace; handoff_read returns the handoffs (every phase, or `phase`), a handoff " + "retro's removal of `.legion/<issue>/` took off the head read from the commit before it; " + "handoff_complete reports this phase complete to the daemon (`summary`: two sentences for the " + "architect, or the merger's READY packet; `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. What a later phase needs goes " + "in your handoff; a question for another live role goes to its role topic with envoy_publish.";
16195
- function rootArchitectHandoffRefusal(operation) {
16196
- return `${operation} is not available to a root architect session; a root architect reads handoffs with \`legion handoff read [--phase <phase>]\` from bash`;
16197
- }
16198
- var ROLE_HANDOFF_PHASE = {
16199
- architect: "architect",
16200
- planner: "plan",
16201
- implementer: "implement",
16202
- tester: "test",
16203
- reviewer: "review"
16204
- };
16205
- var COMMAND_TIMEOUT_MS = 300000;
16206
- function required2(parameters, operation, name) {
16207
- const value = parameters[name];
16208
- if (typeof value !== "string" || value.trim() === "") {
16209
- throw new Error(`${operation} requires ${name}`);
16210
- }
16211
- return value;
16212
- }
16213
- function commandArguments(operation, parameters) {
16214
- const accepted = HANDOFF_OPERATION_FIELDS[operation];
16215
- for (const name of Object.keys(parameters)) {
16216
- if (name !== "op" && !accepted.includes(name)) {
16217
- throw new Error(`${operation} does not accept field "${name}"`);
16218
- }
16219
- }
16220
- switch (operation) {
16221
- case "handoff_write": {
16222
- const data = parameters.data;
16223
- if (typeof data !== "object" || data === null || Array.isArray(data)) {
16224
- throw new Error("handoff_write requires data: the phase's handoff fields as a JSON object");
16225
- }
16226
- const phase = required2(parameters, operation, "phase");
16227
- return { args: ["handoff", "write", "--phase", phase], stdin: JSON.stringify(data) };
16228
- }
16229
- case "handoff_read":
16230
- return {
16231
- args: parameters.phase === undefined ? ["handoff", "read"] : ["handoff", "read", "--phase", required2(parameters, operation, "phase")]
16232
- };
16233
- case "handoff_complete": {
16234
- const command = [
16235
- "handoff",
16236
- "complete",
16237
- "--summary",
16238
- required2(parameters, operation, "summary")
16239
- ];
16240
- const { verdict, ready, phase } = parameters;
16241
- if (phase !== undefined) {
16242
- const role = requiredEnvironment(process.env, "LEGION_ROLE");
16243
- const own = ROLE_HANDOFF_PHASE[role];
16244
- if (own === undefined) {
16245
- throw new Error(`handoff_complete takes no phase for a ${role}, which writes no handoff`);
16246
- }
16247
- if (phase !== own) {
16248
- throw new Error(`handoff_complete's phase is "${own}" for a ${role}: omit phase, or pass "${own}"`);
16249
- }
16250
- }
16251
- if (verdict !== undefined) {
16252
- if (verdict !== "pass" && verdict !== "fail") {
16253
- throw new Error("handoff_complete's verdict is pass or fail");
16254
- }
16255
- command.push("--verdict", verdict);
16256
- }
16257
- if (ready !== undefined && typeof ready !== "boolean") {
16258
- throw new Error("handoff_complete's ready is true or false");
16259
- }
16260
- if (ready === true)
16261
- command.push("--ready");
16262
- return { args: command };
16263
- }
16264
- }
16265
- }
16266
- function runLegion(args, stdin, cwd, signal) {
16267
- const { promise: promise2, resolve, reject } = Promise.withResolvers();
16268
- const child = execFile3("legion", args, { cwd, env: process.env, signal, timeout: COMMAND_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (error48, stdout, stderr) => {
16269
- const output = [stdout, stderr].map((text) => text.trimEnd()).filter((text) => text !== "").join(`
16270
- `);
16271
- if (error48 === null) {
16272
- resolve({ exitCode: 0, output });
16273
- } else if (typeof error48.code === "number") {
16274
- resolve({ exitCode: error48.code, output });
16275
- } else {
16276
- reject(new Error(`legion ${args.slice(0, 2).join(" ")} did not run: ${messageFor(error48)}`));
16277
- }
16278
- });
16279
- child.stdin?.on("error", (error48) => {
16280
- if (error48.code !== "EPIPE")
16281
- reject(error48);
16282
- });
16283
- child.stdin?.end(stdin ?? "");
16284
- return promise2;
16285
- }
16286
- async function runHandoffAction(input) {
16287
- const { args, stdin } = commandArguments(input.operation, input.parameters);
16288
- if (input.operation === "handoff_complete")
16289
- await writeMintedGrant(input.mintGrant);
16290
- const { exitCode, output } = await runLegion(args, stdin, requiredEnvironment(process.env, "LEGION_WORKSPACE"), input.signal);
16291
- const text = output === "" ? `legion ${args.slice(0, 2).join(" ")} exited ${exitCode}` : output;
16292
- const details = { operation: input.operation, exitCode };
16293
- if (exitCode !== 0)
16294
- return { content: [{ type: "text", text }], details, isError: true };
16295
- if (input.operation === "handoff_complete")
16296
- input.onPhaseCompleted();
16297
- return { content: [{ type: "text", text }], details };
16298
- }
16299
-
16300
16129
  // src/tools.ts
16301
16130
  var OPERATIONS = {
16302
16131
  architect: [
@@ -16308,9 +16137,11 @@ var OPERATIONS = {
16308
16137
  "close_root",
16309
16138
  "park_child",
16310
16139
  "rerun_child",
16311
- "read_record"
16140
+ "read_record",
16141
+ "handoff_complete"
16312
16142
  ],
16313
- "phase-worker": ["request_backward_move", "read_record"]
16143
+ "phase-worker": ["request_backward_move", "read_record", "handoff_complete"],
16144
+ controller: ["read_state", "set_status"]
16314
16145
  };
16315
16146
  var OPERATION_FIELDS = {
16316
16147
  register_gate: ["issue", "artifactId", "version"],
@@ -16321,8 +16152,12 @@ var OPERATION_FIELDS = {
16321
16152
  close_root: ["issue", "reason"],
16322
16153
  park_child: ["issue"],
16323
16154
  rerun_child: ["issue"],
16324
- read_record: ["issue"]
16155
+ read_record: ["issue"],
16156
+ handoff_complete: ["summary", "verdict", "ready"],
16157
+ read_state: [],
16158
+ set_status: ["issue", "status"]
16325
16159
  };
16160
+ var ISSUE_STATUSES2 = ["todo", "backlog", "icebox"];
16326
16161
  var jsonSuccess = (details) => toolSuccess(JSON.stringify(details), details);
16327
16162
  function toolSchema(pi) {
16328
16163
  const z2 = pi.zod;
@@ -16337,7 +16172,9 @@ function toolSchema(pi) {
16337
16172
  "park_child",
16338
16173
  "rerun_child",
16339
16174
  "read_record",
16340
- ...HANDOFF_OPERATIONS
16175
+ "handoff_complete",
16176
+ "read_state",
16177
+ "set_status"
16341
16178
  ]),
16342
16179
  issue: z2.string().optional(),
16343
16180
  artifactId: z2.string().describe("register_gate's root spec document: its artifact id, slug, or filename, as a dispatch command's --artifact takes it").optional(),
@@ -16346,7 +16183,10 @@ function toolSchema(pi) {
16346
16183
  to: z2.enum(LEGION_PHASE_BACKWARD_TARGETS).optional(),
16347
16184
  reason: z2.string().optional(),
16348
16185
  decision: z2.enum(["retry", "escalate"]).optional(),
16349
- ...handoffSchemaFields(z2)
16186
+ summary: z2.string().optional(),
16187
+ verdict: z2.enum(["pass", "fail"]).optional(),
16188
+ ready: z2.boolean().optional(),
16189
+ status: z2.enum(ISSUE_STATUSES2).optional()
16350
16190
  });
16351
16191
  }
16352
16192
  function requiredString(parameters, operation, name) {
@@ -16365,14 +16205,6 @@ function assertOperationInput(parameters, operation) {
16365
16205
  }
16366
16206
  }
16367
16207
  }
16368
- async function grantFor(daemon, session) {
16369
- return (await daemon.grant({
16370
- sessionId: session.sessionId,
16371
- secret: session.secret,
16372
- tree: session.tree,
16373
- issue: session.issue
16374
- })).grantId;
16375
- }
16376
16208
  function recordFrom(state, issue2) {
16377
16209
  const record2 = state.issues[issue2];
16378
16210
  if (record2 === undefined)
@@ -16380,39 +16212,70 @@ function recordFrom(state, issue2) {
16380
16212
  return record2;
16381
16213
  }
16382
16214
  function createLegionTool(deps) {
16383
- const { pi, daemon, session, onPhaseCompleted, resolveDocument } = deps;
16215
+ const { pi, daemon, session, controllerGrant, onPhaseCompleted, handoffCommit, resolveDocument } = deps;
16216
+ const grantFor = async (client, active) => (await client.grant({
16217
+ sessionId: active.sessionId,
16218
+ secret: active.secret,
16219
+ tree: active.tree,
16220
+ issue: active.issue
16221
+ })).grantId;
16384
16222
  return {
16385
16223
  name: "legion",
16386
16224
  label: "legion",
16387
- description: "Perform the workflow operation the Legion daemon assigned this role. The daemon advances phases; this tool cannot spawn workers. " + HANDOFF_DESCRIPTION,
16225
+ description: "Perform the workflow operation the Legion daemon assigned this role. The daemon advances phases; this tool cannot spawn workers. " + "handoff_complete (phase workers and sub-architects; the daemon accepts a completion only from the role working the issue's current phase) " + "reports this phase complete: `summary` (two sentences for the architect, or the merger's READY packet), `verdict` pass|fail when your role's " + "instructions require one, `ready: true` for the merger's READY. A handoff is a committed file, `.legion/<issue>/<phase>.json`: the pushed commit " + "carrying it is found here, with the pane's jj, and reported, so write it, commit it and push the branch (`jj git push`) before calling; this call " + "refuses, posting nothing, a handoff still in the working copy, missing, not committed on this branch, carried by another pane's commit, or not yet " + "on `legion/<issue>@origin`, each naming the remedy. The daemon refuses HANDOFF_NOT_NEW (the commit is the one you reported for your previous " + "phase: write and commit this phase's handoff), and for READY READY_HEAD_CARRIES_HANDOFFS, READY_HEAD_CONFLICTS or READY_CHECKS_NOT_GREEN; a refused completion changed " + "nothing, so fix what it names and complete again. " + "The controller's read_state returns the daemon's whole state and set_status moves an issue to todo, backlog or icebox. " + "What a later phase needs goes in your handoff; a question for another live role goes to its role topic with envoy_publish.",
16388
16226
  defaultInactive: true,
16389
16227
  parameters: toolSchema(pi),
16390
- execute: async (_id, parameters, signal, _onUpdate, context) => {
16228
+ execute: async (_id, parameters, _signal, _onUpdate, context) => {
16391
16229
  try {
16392
16230
  const active = session(context);
16393
16231
  const operation = requiredString(parameters, "legion", "op");
16394
- if (isHandoffOperation(operation)) {
16395
- if (active.kind === "architect" && active.issue === active.tree) {
16396
- throw new Error(rootArchitectHandoffRefusal(operation));
16397
- }
16398
- return await runHandoffAction({
16399
- operation,
16400
- parameters,
16401
- signal,
16402
- mintGrant: () => grantFor(daemon(), active),
16403
- onPhaseCompleted: () => onPhaseCompleted(context)
16404
- });
16405
- }
16406
16232
  if (!OPERATIONS[active.kind].includes(operation)) {
16407
16233
  throw new Error(`${operation} is not available to a ${active.kind} session`);
16408
16234
  }
16409
16235
  assertOperationInput(parameters, operation);
16410
16236
  const client = daemon();
16237
+ if (active.kind === "controller") {
16238
+ if (operation === "read_state")
16239
+ return jsonSuccess(await client.state());
16240
+ const issue2 = requiredString(parameters, operation, "issue");
16241
+ const status = ISSUE_STATUSES2.find((candidate) => candidate === parameters.status);
16242
+ if (status === undefined) {
16243
+ throw new Error(`set_status requires status ${ISSUE_STATUSES2.join(", ")}`);
16244
+ }
16245
+ const { grantId } = await controllerGrant(active.sessionId);
16246
+ await client.issueStatus({ grantId, issue: issue2, status });
16247
+ return jsonSuccess({});
16248
+ }
16411
16249
  switch (operation) {
16412
16250
  case "read_record": {
16413
16251
  const issue2 = requiredString(parameters, operation, "issue");
16414
16252
  return jsonSuccess({ record: recordFrom(await client.state(), issue2) });
16415
16253
  }
16254
+ case "handoff_complete": {
16255
+ if (active.kind === "architect" && active.issue === active.tree) {
16256
+ throw new Error("handoff_complete is not available to a root architect session, which runs no phase");
16257
+ }
16258
+ const summary = requiredString(parameters, operation, "summary");
16259
+ const { verdict, ready } = parameters;
16260
+ if (verdict !== undefined && verdict !== "pass" && verdict !== "fail") {
16261
+ throw new Error("handoff_complete's verdict is pass or fail");
16262
+ }
16263
+ if (ready !== undefined && typeof ready !== "boolean") {
16264
+ throw new Error("handoff_complete's ready is true or false");
16265
+ }
16266
+ const { phase } = recordFrom(await client.state(), active.issue);
16267
+ const commit = await handoffCommit(phase, active.role);
16268
+ const grantId = await grantFor(client, active);
16269
+ const answer = await client.handoffComplete({
16270
+ grantId,
16271
+ summary,
16272
+ verdict: verdict ?? "",
16273
+ ready: ready ?? false,
16274
+ commit
16275
+ });
16276
+ onPhaseCompleted(context);
16277
+ return jsonSuccess(answer);
16278
+ }
16416
16279
  case "register_gate": {
16417
16280
  const version2 = parameters.version;
16418
16281
  if (typeof version2 !== "number" || !Number.isSafeInteger(version2) || version2 < 1) {
@@ -16537,204 +16400,17 @@ var exitProcess = (code) => process.exit(code);
16537
16400
  function setLegionBootstrapExitForTests(hook) {
16538
16401
  exitProcess = hook;
16539
16402
  }
16540
- var GH_RESOLVED_URL = /(?:^|[\s;,"])(?:pr|issue):\/\//i;
16541
- function needsGrant({ toolName, input }) {
16542
- if (toolName === "bash") {
16543
- return typeof input.command === "string" && dispatchCommandHead(input.command) === undefined;
16544
- }
16545
- if (toolName === "github")
16546
- return true;
16547
- const paths = Array.isArray(input.paths) ? input.paths : [input.path];
16548
- return paths.some((entry) => typeof entry === "string" && GH_RESOLVED_URL.test(entry));
16549
- }
16550
- async function wrapWithGrant(mint) {
16551
- try {
16552
- await writeMintedGrant(async () => (await mint()).grantId);
16553
- return;
16554
- } catch (error48) {
16555
- return { block: true, reason: messageFor(error48) };
16556
- }
16557
- }
16558
16403
  async function persistedTranscript(context) {
16559
16404
  await context.sessionManager.ensureOnDisk();
16560
16405
  const sessionFile = context.sessionManager.getSessionFile();
16561
16406
  if (!sessionFile?.endsWith(".jsonl")) {
16562
16407
  throw new Error("Legion session must have a persisted transcript");
16563
16408
  }
16564
- const agentId = path5.basename(sessionFile, ".jsonl");
16409
+ const agentId = path4.basename(sessionFile, ".jsonl");
16565
16410
  if (!agentId)
16566
16411
  throw new Error("Legion session transcript has no agent id");
16567
16412
  return { sessionFile, agentId };
16568
16413
  }
16569
- var JJ_LOG_REWRITE_WORDS = ["undo", "abandon"];
16570
- var JJ_OP_WORDS = ["op", "operation"];
16571
- var JJ_OP_LOG_REWRITE_WORDS = ["restore", "revert"];
16572
- var JJ_MENTION = /\bjj\b/;
16573
- var JJ_LOG_REWRITE_MENTION = new RegExp(`\\b(?:${JJ_LOG_REWRITE_WORDS.join("|")})\\b|\\b(?:${JJ_OP_WORDS.join("|")})\\b\\W+(?:${JJ_OP_LOG_REWRITE_WORDS.join("|")})\\b`);
16574
- function jjLogRewriteMention(text) {
16575
- if (!JJ_MENTION.test(text))
16576
- return;
16577
- const match = JJ_LOG_REWRITE_MENTION.exec(text);
16578
- return match === null ? undefined : `jj ${match[0].replace(/\W+/g, " ")}`;
16579
- }
16580
- function splitShellCommands(command) {
16581
- const commands = [];
16582
- let words = [];
16583
- let text = "";
16584
- let inWord = false;
16585
- let quote;
16586
- const endWord = () => {
16587
- if (inWord)
16588
- words.push(text);
16589
- text = "";
16590
- inWord = false;
16591
- };
16592
- const endCommand = () => {
16593
- endWord();
16594
- if (words.length > 0)
16595
- commands.push(words);
16596
- words = [];
16597
- };
16598
- for (let i = 0;i < command.length; i += 1) {
16599
- const char = command.charAt(i);
16600
- if (quote !== undefined) {
16601
- if (char === quote)
16602
- quote = undefined;
16603
- else if (quote === '"' && char === "\\" && command.charAt(i + 1) === `
16604
- `)
16605
- i += 1;
16606
- else if (quote === '"' && char === "\\" && i + 1 < command.length) {
16607
- i += 1;
16608
- text += command.charAt(i);
16609
- } else
16610
- text += char;
16611
- continue;
16612
- }
16613
- if (char === '"' || char === "'") {
16614
- quote = char;
16615
- inWord = true;
16616
- } else if (char === "\\") {
16617
- if (command.charAt(i + 1) === `
16618
- `)
16619
- i += 1;
16620
- else if (i + 1 < command.length) {
16621
- i += 1;
16622
- text += command.charAt(i);
16623
- inWord = true;
16624
- }
16625
- } else if (char === " " || char === "\t" || char === "<" || char === ">") {
16626
- endWord();
16627
- } else if (char === "&" && (command.charAt(i - 1) === ">" || command.charAt(i - 1) === "<" || command.charAt(i + 1) === ">")) {
16628
- endWord();
16629
- } else if (";&|\n()`".includes(char)) {
16630
- endCommand();
16631
- } else {
16632
- text += char;
16633
- inWord = true;
16634
- }
16635
- }
16636
- if (quote !== undefined)
16637
- return;
16638
- endCommand();
16639
- return commands;
16640
- }
16641
- function commandMatch(words, name, sequence) {
16642
- for (let index = 0;index < words.length; index += 1) {
16643
- const word = words[index];
16644
- if (word !== name && !word?.endsWith(`/${name}`))
16645
- continue;
16646
- if (sequence.every((expected, offset) => words[index + 1 + offset] === expected))
16647
- return index;
16648
- }
16649
- return -1;
16650
- }
16651
- function isPushInvocation(words) {
16652
- return commandMatch(words, "legion", ["push"]) !== -1;
16653
- }
16654
- function commandsRunPush(commands) {
16655
- return commands?.some(isPushInvocation) ?? false;
16656
- }
16657
- function jjLogRewriteInvocation(words) {
16658
- const jj = commandMatch(words, "jj", []);
16659
- if (jj === -1)
16660
- return;
16661
- const args = words.slice(jj + 1);
16662
- 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));
16663
- return rewritesLog ? words.slice(jj).join(" ") : undefined;
16664
- }
16665
- var JJ_LOG_REWRITE = {
16666
- mention: jjLogRewriteMention,
16667
- invocation: jjLogRewriteInvocation,
16668
- refusal: (attempt) => `refused \`${attempt}\`: 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.'
16669
- };
16670
- var TREE_PANE_RULES = [JJ_LOG_REWRITE];
16671
- var PANE_RULES = {
16672
- "phase-worker": TREE_PANE_RULES,
16673
- "root-architect": TREE_PANE_RULES
16674
- };
16675
- function refusedCommand(command, commands, rule) {
16676
- if (commands === undefined) {
16677
- return rule.mention(command) === undefined ? undefined : command.trim();
16678
- }
16679
- for (const words of commands) {
16680
- const mentioned = words.find((word) => rule.mention(word) !== undefined);
16681
- if (mentioned !== undefined)
16682
- return mentioned;
16683
- const invocation = rule.invocation(words);
16684
- if (invocation !== undefined)
16685
- return invocation;
16686
- }
16687
- return;
16688
- }
16689
- var NON_FILE_WRITE_URL = /^(xd|agent|proc):\/\//iu;
16690
- var READ_HEADER_TAG = /#[0-9A-Fa-f]{4}$/u;
16691
- var PREFIXED_CONFLICT_URL = /^(.+):(conflict:\/\/.+)$/u;
16692
- function unwrapReadHeader(path6) {
16693
- const trimmed = path6.trimEnd();
16694
- if (trimmed.length < 2 || !trimmed.startsWith("[") || !trimmed.endsWith("]"))
16695
- return path6;
16696
- const inner = trimmed.slice(1, -1);
16697
- const tag = READ_HEADER_TAG.exec(inner);
16698
- const target = tag === null ? inner : inner.slice(0, tag.index);
16699
- if (target.length === 0 || tag === null && target.includes("#"))
16700
- return path6;
16701
- return target;
16702
- }
16703
- function nonFileWriteScheme(toolCall) {
16704
- if (toolCall.toolName !== "write" || typeof toolCall.input.path !== "string")
16705
- return;
16706
- const unwrapped = unwrapReadHeader(toolCall.input.path);
16707
- const target = PREFIXED_CONFLICT_URL.exec(unwrapped)?.[2] ?? unwrapped;
16708
- return NON_FILE_WRITE_URL.exec(target)?.[1]?.toLowerCase();
16709
- }
16710
- function paneRuleRefusal(toolCall, rules, commands) {
16711
- if (rules.length === 0)
16712
- return;
16713
- const { toolName, input } = toolCall;
16714
- if (toolName === "bash") {
16715
- if (typeof input.command !== "string")
16716
- return;
16717
- for (const rule of rules) {
16718
- const attempt = refusedCommand(input.command, commands, rule);
16719
- if (attempt !== undefined)
16720
- return rule.refusal(attempt);
16721
- }
16722
- return;
16723
- }
16724
- let text;
16725
- if (toolName === "eval" && typeof input.code === "string")
16726
- text = input.code;
16727
- else if (nonFileWriteScheme(toolCall) === "proc" && typeof input.content === "string") {
16728
- text = input.content;
16729
- } else
16730
- return;
16731
- for (const rule of rules) {
16732
- const mention = rule.mention(text);
16733
- if (mention !== undefined)
16734
- return rule.refusal(`${toolName}: ${mention}`);
16735
- }
16736
- return;
16737
- }
16738
16414
  function envoyPluginRefusal() {
16739
16415
  const legacy = globalThis[LEGACY_LEGION_LOADED_KEY];
16740
16416
  if (legacy !== undefined) {
@@ -16752,14 +16428,13 @@ function envoyPluginRefusal() {
16752
16428
  }
16753
16429
  }
16754
16430
  function legionExtension(pi) {
16755
- const instance = randomUUID2().slice(0, 8);
16431
+ const instance = randomUUID().slice(0, 8);
16756
16432
  logger3.debug("extension instance loaded", { extension: import.meta.url, instance });
16757
16433
  globalThis[LEGION_PLUGIN_LOADED_KEY] = {
16758
16434
  from: import.meta.url,
16759
16435
  envoyInterface: ENVOY_PLUGIN_INTERFACE_VERSION
16760
16436
  };
16761
16437
  const checkSubagentSession = subagentSessionCheck();
16762
- let paneRules;
16763
16438
  let phaseStall = "closed";
16764
16439
  const phaseWorkerSession = (context) => {
16765
16440
  const role = claimSession.capability(context.sessionManager.getSessionId())?.role;
@@ -16815,16 +16490,16 @@ function legionExtension(pi) {
16815
16490
  }
16816
16491
  if (session.kind === "controller") {
16817
16492
  await controllerSession.handleSessionStart(context);
16818
- return;
16819
- }
16820
- recreatedNotice = workspaceRecreatedNotice(process.env, session.kind === "root-architect" ? session.tree : session.issue);
16821
- if (recreatedNotice !== undefined) {
16822
- pi.sendMessage(workspaceRecreatedMessage(recreatedNotice), {
16823
- deliverAs: "steer",
16824
- triggerTurn: false
16825
- });
16493
+ } else {
16494
+ recreatedNotice = workspaceRecreatedNotice(process.env, session.kind === "root-architect" ? session.tree : session.issue);
16495
+ if (recreatedNotice !== undefined) {
16496
+ pi.sendMessage(workspaceRecreatedMessage(recreatedNotice), {
16497
+ deliverAs: "steer",
16498
+ triggerTurn: false
16499
+ });
16500
+ }
16501
+ await claimSession.bootstrap(context);
16826
16502
  }
16827
- await claimSession.bootstrap(context);
16828
16503
  registerLegionTool();
16829
16504
  await activateLegionTool();
16830
16505
  });
@@ -16857,43 +16532,6 @@ function legionExtension(pi) {
16857
16532
  pi.on("session_switch", (_event, context) => afterSessionChange(context));
16858
16533
  pi.on("session_branch", (_event, context) => afterSessionChange(context));
16859
16534
  pi.on("session_tree", (_event, context) => afterSessionChange(context));
16860
- pi.on("tool_call", async (toolCall, context) => {
16861
- logger3.debug("legion tool_call hook", {
16862
- instance,
16863
- toolCallId: toolCall.toolCallId,
16864
- toolName: toolCall.toolName
16865
- });
16866
- paneRules ??= PANE_RULES[classifySession(process.env).kind] ?? [];
16867
- const commands = toolCall.toolName === "bash" && typeof toolCall.input.command === "string" ? splitShellCommands(dispatchCommandHead(toolCall.input.command) ?? toolCall.input.command) : undefined;
16868
- const refusal = paneRuleRefusal(toolCall, paneRules, commands);
16869
- if (refusal !== undefined)
16870
- return { block: true, reason: refusal };
16871
- if (await checkSubagentSession(context))
16872
- return;
16873
- const sessionID = context.sessionManager.getSessionId();
16874
- const active = claimSession.capability(sessionID);
16875
- if (!needsGrant(toolCall))
16876
- return;
16877
- if (active === undefined) {
16878
- if (controllerSession.isClaimedSession(sessionID)) {
16879
- return wrapWithGrant(() => controllerSession.mintGrant(sessionID));
16880
- }
16881
- if (process.env.LEGION_ROLE !== undefined && process.env.LEGION_CONTROLLER !== "1") {
16882
- return {
16883
- block: true,
16884
- reason: "Legion worker session is not registered; cannot mint its grant"
16885
- };
16886
- }
16887
- return;
16888
- }
16889
- return wrapWithGrant(() => roleDaemon().grant({
16890
- tree: active.tree,
16891
- issue: active.issue,
16892
- sessionId: sessionID,
16893
- secret: active.secret,
16894
- push: commandsRunPush(commands)
16895
- }));
16896
- });
16897
16535
  pi.on("message_start", async (event, context) => {
16898
16536
  if (!phaseWorkerSession(context))
16899
16537
  return;
@@ -16930,19 +16568,26 @@ function legionExtension(pi) {
16930
16568
  pi,
16931
16569
  daemon: roleDaemon,
16932
16570
  session: (context) => {
16933
- const active = claimSession.capability(context.sessionManager.getSessionId());
16934
- if (active === undefined) {
16935
- throw new Error("legion is available only to this session's registered claim");
16571
+ const sessionID = context.sessionManager.getSessionId();
16572
+ const active = claimSession.capability(sessionID);
16573
+ if (active !== undefined) {
16574
+ return {
16575
+ kind: active.role === "architect" ? "architect" : "phase-worker",
16576
+ role: active.role,
16577
+ sessionId: active.sessionID,
16578
+ tree: active.tree,
16579
+ issue: active.issue,
16580
+ secret: active.secret
16581
+ };
16936
16582
  }
16937
- return {
16938
- kind: active.role === "architect" ? "architect" : "phase-worker",
16939
- sessionId: active.sessionID,
16940
- tree: active.tree,
16941
- issue: active.issue,
16942
- secret: active.secret
16943
- };
16583
+ if (controllerSession.isClaimedSession(sessionID)) {
16584
+ return { kind: "controller", sessionId: sessionID };
16585
+ }
16586
+ throw new Error("legion is available only to this session's registered claim");
16944
16587
  },
16588
+ controllerGrant: (sessionId) => controllerSession.mintGrant(sessionId),
16945
16589
  onPhaseCompleted,
16590
+ handoffCommit: (phase, role) => findHandoffCommit({ phase, role, env: process.env }),
16946
16591
  resolveDocument: async (issue2, reference) => {
16947
16592
  const config2 = activeDispatchConfig(process.env, { cwd: process.cwd() });
16948
16593
  if (config2 === null) {
@@ -16963,6 +16608,8 @@ function legionExtension(pi) {
16963
16608
  if (refusal !== undefined)
16964
16609
  throw new Error(refusal);
16965
16610
  await controllerSession.claim(context);
16611
+ registerLegionTool();
16612
+ await activateLegionTool();
16966
16613
  }
16967
16614
  });
16968
16615
  }