@sjawhar/pi-legion 9.8.2 → 10.1.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
@@ -19,9 +19,20 @@ var __require = import.meta.require;
19
19
  import { randomUUID } from "crypto";
20
20
  import path5 from "path";
21
21
 
22
- // ../envoy-client/src/dispatch-config.ts
23
- import { readFileSync as readFileSync2 } from "fs";
24
- import { homedir } from "os";
22
+ // ../envoy-client/src/envoy-config.ts
23
+ import {
24
+ chmodSync,
25
+ closeSync,
26
+ existsSync,
27
+ lstatSync,
28
+ mkdtempSync,
29
+ openSync,
30
+ readFileSync as readFileSync2,
31
+ readlinkSync,
32
+ rmSync,
33
+ statSync
34
+ } from "fs";
35
+ import { homedir, tmpdir } from "os";
25
36
  import * as path from "path";
26
37
 
27
38
  // ../../node_modules/.bun/zod@4.3.6/node_modules/zod/v4/classic/external.js
@@ -13564,8 +13575,97 @@ function hasErrnoCode(error48, code) {
13564
13575
  return error48 instanceof Error && "code" in error48 && error48.code === code;
13565
13576
  }
13566
13577
 
13578
+ // ../envoy-client/src/json-position.ts
13579
+ function jsonErrorPosition(text) {
13580
+ let at = 0;
13581
+ const fail = () => {
13582
+ throw new RangeError("not JSON");
13583
+ };
13584
+ const space = () => {
13585
+ while (at < text.length && `
13586
+ \r`.includes(text.charAt(at)))
13587
+ at += 1;
13588
+ };
13589
+ const expect = (char) => {
13590
+ if (text.charAt(at) !== char)
13591
+ fail();
13592
+ at += 1;
13593
+ };
13594
+ const string4 = () => {
13595
+ expect('"');
13596
+ while (at < text.length) {
13597
+ const char = text.charAt(at);
13598
+ if (char === '"') {
13599
+ at += 1;
13600
+ return;
13601
+ }
13602
+ if (char < " ")
13603
+ fail();
13604
+ at += char === "\\" ? 2 : 1;
13605
+ }
13606
+ fail();
13607
+ };
13608
+ const value = () => {
13609
+ space();
13610
+ const char = text.charAt(at);
13611
+ if (char === "{" || char === "[") {
13612
+ const close = char === "{" ? "}" : "]";
13613
+ at += 1;
13614
+ space();
13615
+ if (text.charAt(at) === close) {
13616
+ at += 1;
13617
+ return;
13618
+ }
13619
+ for (;; ) {
13620
+ if (close === "}") {
13621
+ space();
13622
+ string4();
13623
+ space();
13624
+ expect(":");
13625
+ }
13626
+ value();
13627
+ space();
13628
+ if (text.charAt(at) === ",") {
13629
+ at += 1;
13630
+ continue;
13631
+ }
13632
+ expect(close);
13633
+ return;
13634
+ }
13635
+ }
13636
+ if (char === '"') {
13637
+ string4();
13638
+ return;
13639
+ }
13640
+ for (const word of ["true", "false", "null"]) {
13641
+ if (text.startsWith(word, at)) {
13642
+ at += word.length;
13643
+ return;
13644
+ }
13645
+ }
13646
+ const number4 = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/.exec(text.slice(at));
13647
+ if (number4 === null)
13648
+ fail();
13649
+ at += number4?.[0].length ?? 0;
13650
+ };
13651
+ try {
13652
+ value();
13653
+ space();
13654
+ if (at < text.length)
13655
+ fail();
13656
+ return;
13657
+ } catch {
13658
+ const before = text.slice(0, at).split(`
13659
+ `);
13660
+ return `line ${before.length}, column ${(before.at(-1)?.length ?? 0) + 1}`;
13661
+ }
13662
+ }
13663
+
13567
13664
  // ../envoy-client/src/secret-file.ts
13568
13665
  import { readFileSync } from "fs";
13666
+ function sendable(value) {
13667
+ return !/[\s\p{Cc}]/u.test(value);
13668
+ }
13569
13669
  function readSecretFile(variable, filePath) {
13570
13670
  let contents;
13571
13671
  try {
@@ -13576,131 +13676,431 @@ function readSecretFile(variable, filePath) {
13576
13676
  const value = contents.trim();
13577
13677
  if (value.length === 0)
13578
13678
  throw new Error(`${variable} names ${filePath}, which is empty`);
13679
+ if (!sendable(value)) {
13680
+ throw new Error(`${variable} names ${filePath}, which holds whitespace or a control character`);
13681
+ }
13579
13682
  return value;
13580
13683
  }
13581
13684
 
13582
- // ../envoy-client/src/dispatch-config.ts
13583
- var DEFAULT_SERVER_URL = "http://localhost:8766";
13584
- function normalizeDispatchUrl(url2) {
13585
- return url2.replace(/\/+$/, "");
13685
+ // ../envoy-client/src/envoy-config.ts
13686
+ var COMMAND_TIMEOUT_MS = 1e4;
13687
+ function nonEmpty(value) {
13688
+ return value === undefined || value === "" ? undefined : value;
13586
13689
  }
13587
- function parsedDispatchUrl(value, source) {
13588
- const url2 = normalizeDispatchUrl(value);
13589
- try {
13590
- new URL(url2);
13591
- return { url: url2, error: null };
13592
- } catch {
13593
- return { url: null, error: `${source} must be a valid URL` };
13690
+ function absolute(name, value) {
13691
+ if (value !== undefined && !path.isAbsolute(value)) {
13692
+ throw new Error(`${name} is ${value}, which is not an absolute path`);
13693
+ }
13694
+ return value;
13695
+ }
13696
+ function neutralConfig(env) {
13697
+ const xdgConfigHome = nonEmpty(env.XDG_CONFIG_HOME);
13698
+ if (xdgConfigHome !== undefined && !path.isAbsolute(xdgConfigHome)) {
13699
+ return { error: `XDG_CONFIG_HOME is ${xdgConfigHome}, which is not an absolute path` };
13594
13700
  }
13701
+ const configHome = xdgConfigHome ?? path.join(nonEmpty(env.HOME) ?? homedir(), ".config");
13702
+ return { path: path.resolve(configHome, "legion", "envoy.json") };
13595
13703
  }
13704
+ var HARNESS_DIRECTORY_VARIABLE = {
13705
+ omp: "PI_CODING_AGENT_DIR",
13706
+ claude: "CLAUDE_CONFIG_DIR",
13707
+ opencode: "OPENCODE_CONFIG_DIR"
13708
+ };
13709
+ var PLUGIN_HARNESSES = Object.keys(HARNESS_DIRECTORY_VARIABLE);
13710
+ function namedDirectory(harness, env) {
13711
+ if (harness === "omp") {
13712
+ const profile = nonEmpty(env.OMP_PROFILE);
13713
+ if (profile === undefined || profile === "default")
13714
+ return;
13715
+ }
13716
+ return nonEmpty(env[HARNESS_DIRECTORY_VARIABLE[harness]]);
13717
+ }
13718
+ function harnessDirectory(harness, env) {
13719
+ for (const plugin of harness === "command" ? PLUGIN_HARNESSES : [harness]) {
13720
+ if (harness === "command" && plugin === "claude" && env.CLAUDE_CODE_CHILD_SESSION !== "1") {
13721
+ continue;
13722
+ }
13723
+ const directory = namedDirectory(plugin, env);
13724
+ if (directory !== undefined)
13725
+ return absolute(HARNESS_DIRECTORY_VARIABLE[plugin], directory);
13726
+ }
13727
+ return;
13728
+ }
13729
+ function handedConfigPath(env) {
13730
+ const handed = nonEmpty(env.ENVOY_CONFIG_FILE);
13731
+ return handed === undefined ? undefined : path.resolve(absolute("ENVOY_CONFIG_FILE", handed));
13732
+ }
13733
+ function envoyConfigPath(harness, env) {
13734
+ const handedFirst = harness === "command" ? handedConfigPath(env) : undefined;
13735
+ if (handedFirst !== undefined)
13736
+ return handedFirst;
13737
+ const directory = harnessDirectory(harness, env);
13738
+ if (directory !== undefined)
13739
+ return path.resolve(directory, "envoy.json");
13740
+ const handed = handedConfigPath(env);
13741
+ if (handed !== undefined)
13742
+ return handed;
13743
+ const neutral = neutralConfig(env);
13744
+ if ("error" in neutral)
13745
+ throw new Error(neutral.error);
13746
+ return neutral.path;
13747
+ }
13748
+ var httpUrlSchema = exports_external.url({ protocol: /^https?$/, error: "must be an http or https URL" });
13749
+ var DispatchSectionSchema = exports_external.strictObject({
13750
+ enabled: exports_external.boolean().optional(),
13751
+ serverUrl: httpUrlSchema.optional(),
13752
+ token: exports_external.string().optional()
13753
+ });
13754
+ var EnvoySectionSchema = exports_external.strictObject({
13755
+ enabled: exports_external.boolean().optional(),
13756
+ url: httpUrlSchema.optional(),
13757
+ natsUrls: exports_external.array(exports_external.string().trim().min(1, "must not be empty")).optional(),
13758
+ token: exports_external.string().optional(),
13759
+ natsSeed: exports_external.string().optional()
13760
+ });
13596
13761
  var EnvoyFileSchema = exports_external.looseObject({
13597
13762
  $schema: exports_external.string().optional(),
13598
- natsUrls: exports_external.array(exports_external.string()).optional(),
13599
- dispatch: exports_external.strictObject({
13600
- enabled: exports_external.boolean().optional(),
13601
- serverUrl: exports_external.url().optional(),
13602
- token: exports_external.string().optional()
13603
- }).optional()
13763
+ dispatch: DispatchSectionSchema.optional(),
13764
+ envoy: EnvoySectionSchema.optional()
13765
+ });
13766
+ var CheckoutFileSchema = exports_external.looseObject({
13767
+ $schema: exports_external.string().optional(),
13768
+ dispatch: DispatchSectionSchema.optional()
13604
13769
  });
13770
+ function offWithNotice(notice) {
13771
+ return { enabled: false, notice, error: null };
13772
+ }
13773
+ function offWithError(error48) {
13774
+ return { enabled: false, notice: null, error: error48 };
13775
+ }
13776
+ function httpUrl2(value) {
13777
+ let url2;
13778
+ try {
13779
+ url2 = new URL(value);
13780
+ } catch {
13781
+ return;
13782
+ }
13783
+ return url2.protocol === "http:" || url2.protocol === "https:" ? url2 : undefined;
13784
+ }
13605
13785
  function describeSchemaIssue(filePath, error48) {
13606
13786
  const issue2 = error48.issues[0];
13607
13787
  if (!issue2)
13608
- return `${filePath}: invalid dispatch config`;
13788
+ return `${filePath}: invalid envoy.json`;
13609
13789
  if (issue2.code === "unrecognized_keys") {
13610
- const keys = issue2.keys.map((key) => `dispatch.${key}`).join(", ");
13611
- return `${filePath}: unrecognized dispatch key(s): ${keys}`;
13790
+ const section = issue2.path.join(".");
13791
+ const keys = issue2.keys.map((key) => `${section}.${key}`).join(", ");
13792
+ return `${filePath}: unrecognized ${section} key(s): ${keys}`;
13612
13793
  }
13613
13794
  return `${filePath}: ${issue2.path.join(".")}: ${issue2.message}`;
13614
13795
  }
13615
- function readEnvoyFile(filePath) {
13796
+ function danglingLink(filePath) {
13797
+ try {
13798
+ return lstatSync(filePath).isSymbolicLink() ? readlinkSync(filePath) : null;
13799
+ } catch {
13800
+ return null;
13801
+ }
13802
+ }
13803
+ function parseFile(filePath, schema, checkout) {
13616
13804
  let raw;
13617
13805
  try {
13618
13806
  raw = readFileSync2(filePath, "utf-8");
13619
- } catch {
13620
- return { kind: "absent" };
13807
+ } catch (error48) {
13808
+ if (hasErrnoCode(error48, "ENOENT") || hasErrnoCode(error48, "ENOTDIR")) {
13809
+ return { kind: "absent", link: danglingLink(filePath) };
13810
+ }
13811
+ return { kind: "invalid", reason: `${filePath}: could not be read (${messageFor(error48)})` };
13621
13812
  }
13622
13813
  let parsedJson;
13623
13814
  try {
13624
13815
  parsedJson = JSON.parse(raw);
13625
- } catch (err) {
13626
- return { kind: "invalid", reason: `${filePath}: invalid JSON (${messageFor(err)})` };
13816
+ } catch {
13817
+ const position = jsonErrorPosition(raw);
13818
+ return {
13819
+ kind: "invalid",
13820
+ reason: `${filePath}: invalid JSON${position === undefined ? "" : ` at ${position}`}`
13821
+ };
13627
13822
  }
13628
- const parsed = EnvoyFileSchema.safeParse(parsedJson);
13823
+ if (typeof parsedJson === "object" && parsedJson !== null && "natsUrls" in parsedJson) {
13824
+ return {
13825
+ kind: "invalid",
13826
+ reason: checkout ? `${filePath}: the top-level natsUrls is not read, and a checkout's envoy.json holds only a dispatch section` : `${filePath}: the top-level natsUrls is not read; move it to envoy.natsUrls`
13827
+ };
13828
+ }
13829
+ const parsed = schema.safeParse(parsedJson);
13629
13830
  if (!parsed.success) {
13630
13831
  return { kind: "invalid", reason: describeSchemaIssue(filePath, parsed.error) };
13631
13832
  }
13632
- return { kind: "valid", settings: parsed.data.dispatch ?? null };
13633
- }
13634
- function resolveDispatchConfig(env, options = {}) {
13635
- const explicitUrl = env.DISPATCH_URL;
13636
- const home = options.home ?? env.HOME ?? homedir();
13637
- const cwd = options.cwd ?? process.cwd();
13638
- const userFile = readEnvoyFile(path.join(home, ".config", "opencode", "envoy.json"));
13639
- const repoFile = readEnvoyFile(path.join(cwd, ".opencode", "envoy.json"));
13640
- for (const file2 of [userFile, repoFile]) {
13641
- if (file2.kind === "invalid") {
13642
- return { enabled: false, url: null, token: null, error: file2.reason };
13643
- }
13644
- }
13645
- const userSettings = userFile.kind === "valid" ? userFile.settings : null;
13646
- const repoSettings = repoFile.kind === "valid" ? repoFile.settings : null;
13647
- const merged = { ...userSettings, ...repoSettings };
13648
- const repoURL = repoSettings?.serverUrl;
13649
- if (explicitUrl === undefined && merged.enabled === true && repoURL !== undefined) {
13650
- const token2 = repoSettings?.token ?? null;
13651
- if (!token2) {
13833
+ return { kind: "valid", settings: parsed.data };
13834
+ }
13835
+ var FAILURE_KEPT_MS = 60000;
13836
+ var COMMAND_OUTCOMES = Symbol.for("legion.envoy-config.commands.v1");
13837
+ var processGlobal = globalThis;
13838
+ var commandOutcomes = processGlobal[COMMAND_OUTCOMES] ?? new Map;
13839
+ processGlobal[COMMAND_OUTCOMES] = commandOutcomes;
13840
+ function runCommand(command, env, cwd) {
13841
+ const childEnv = {};
13842
+ for (const [name, variable] of Object.entries(env)) {
13843
+ if (variable !== undefined)
13844
+ childEnv[name] = variable;
13845
+ }
13846
+ const directory = mkdtempSync(path.join(tmpdir(), "envoy-command-"));
13847
+ try {
13848
+ chmodSync(directory, 448);
13849
+ const output = path.join(directory, "stdout");
13850
+ const fd = openSync(output, "wx", 384);
13851
+ let result;
13852
+ try {
13853
+ result = Bun.spawnSync(["/bin/sh", "-c", command], {
13854
+ cwd,
13855
+ env: childEnv,
13856
+ stdin: "ignore",
13857
+ stdout: fd,
13858
+ stderr: "ignore",
13859
+ timeout: COMMAND_TIMEOUT_MS,
13860
+ detached: true
13861
+ });
13862
+ } catch (error48) {
13863
+ return { failure: `could not start: ${messageFor(error48)}`, timedOut: false };
13864
+ } finally {
13865
+ closeSync(fd);
13866
+ }
13867
+ try {
13868
+ process.kill(-result.pid, "SIGKILL");
13869
+ } catch {}
13870
+ if (result.exitedDueToTimeout) {
13652
13871
  return {
13653
- enabled: false,
13654
- url: null,
13655
- token: null,
13656
- error: "repository dispatch.serverUrl requires dispatch.token from the same .opencode/envoy.json or DISPATCH_URL with DISPATCH_TOKEN"
13872
+ failure: `did not finish within ${COMMAND_TIMEOUT_MS / 1000} s, and does not run again in this process`,
13873
+ timedOut: true
13657
13874
  };
13658
13875
  }
13659
- const url3 = parsedDispatchUrl(repoURL, "repository dispatch.serverUrl");
13660
- if (url3.error !== null)
13661
- return { enabled: false, url: null, token: null, error: url3.error };
13662
- return { enabled: true, url: url3.url, token: token2, error: null };
13663
- }
13664
- const rawUrl = explicitUrl !== undefined ? { value: explicitUrl, source: "DISPATCH_URL" } : merged.enabled === true ? { value: merged.serverUrl ?? DEFAULT_SERVER_URL, source: "dispatch.serverUrl" } : null;
13665
- const url2 = rawUrl ? parsedDispatchUrl(rawUrl.value, rawUrl.source) : { url: null, error: null };
13666
- let token;
13667
- let tokenSource;
13668
- if (env.DISPATCH_TOKEN_FILE !== undefined) {
13669
- tokenSource = "DISPATCH_TOKEN_FILE";
13670
- try {
13671
- token = readSecretFile(tokenSource, env.DISPATCH_TOKEN_FILE);
13672
- } catch (error48) {
13673
- return { enabled: false, url: url2.url, token: null, error: messageFor(error48) };
13876
+ if (!result.success) {
13877
+ return {
13878
+ failure: `exited with ${result.signalCode ?? `status ${result.exitCode}`}`,
13879
+ timedOut: false
13880
+ };
13674
13881
  }
13675
- } else if (env.DISPATCH_TOKEN !== undefined) {
13676
- tokenSource = "DISPATCH_TOKEN";
13677
- token = env.DISPATCH_TOKEN;
13678
- } else {
13679
- tokenSource = "dispatch.token";
13680
- token = merged.token ?? null;
13681
- }
13682
- if (url2.error !== null)
13683
- return { enabled: false, url: null, token, error: url2.error };
13684
- if (url2.url === null)
13685
- return { enabled: false, url: null, token, error: null };
13686
- if (!token) {
13687
- return {
13688
- enabled: false,
13689
- url: url2.url,
13690
- token,
13691
- error: `${tokenSource} must be a non-empty bearer token`
13882
+ const value = readFileSync2(output, "utf-8").trim();
13883
+ if (value.length === 0)
13884
+ return { failure: "printed nothing", timedOut: false };
13885
+ if (!sendable(value)) {
13886
+ return {
13887
+ failure: "printed more than one line, or whitespace inside its value",
13888
+ timedOut: false
13889
+ };
13890
+ }
13891
+ return { value };
13892
+ } finally {
13893
+ rmSync(directory, { recursive: true, force: true });
13894
+ }
13895
+ }
13896
+ function fileValue(value, field, filePath, env) {
13897
+ if (!value.startsWith("!")) {
13898
+ if (!sendable(value)) {
13899
+ throw new Error(`${filePath}: ${field} holds whitespace or a control character`);
13900
+ }
13901
+ return value;
13902
+ }
13903
+ const command = value.slice(1);
13904
+ const cwd = path.dirname(path.resolve(filePath));
13905
+ const key = JSON.stringify([command, filePath, cwd]);
13906
+ let outcome = commandOutcomes.get(key);
13907
+ if (outcome === undefined || "failure" in outcome && !outcome.timedOut && Date.now() - outcome.at >= FAILURE_KEPT_MS) {
13908
+ outcome = { ...runCommand(command, env, cwd), at: Date.now() };
13909
+ commandOutcomes.set(key, outcome);
13910
+ }
13911
+ if ("failure" in outcome)
13912
+ throw new Error(`${filePath}: ${field}'s command ${outcome.failure}`);
13913
+ return outcome.value;
13914
+ }
13915
+ function statDevice(directory) {
13916
+ try {
13917
+ return statSync(directory).dev;
13918
+ } catch {
13919
+ return;
13920
+ }
13921
+ }
13922
+ function moveNotice(oldFile, newFile, move, deviceOf) {
13923
+ const oldDevice = deviceOf(path.dirname(oldFile));
13924
+ let ancestor = path.dirname(newFile);
13925
+ let newDevice = deviceOf(ancestor);
13926
+ while (newDevice === undefined && path.dirname(ancestor) !== ancestor) {
13927
+ ancestor = path.dirname(ancestor);
13928
+ newDevice = deviceOf(ancestor);
13929
+ }
13930
+ if (oldDevice !== undefined && newDevice !== undefined && oldDevice !== newDevice) {
13931
+ return `${oldFile} is not read, and is on another filesystem than ${newFile} (a mount): create ${newFile}, and move ${oldFile} on the machine that owns it`;
13932
+ }
13933
+ return `${oldFile} is not read: ${move}`;
13934
+ }
13935
+ function oldUserFileNotice(filePath, env, deviceOf) {
13936
+ const home = nonEmpty(env.HOME) ?? homedir();
13937
+ const old = path.join(home, ".config", "opencode", "envoy.json");
13938
+ if (!existsSync(old))
13939
+ return;
13940
+ const selected = path.resolve(filePath);
13941
+ const userFile = path.resolve(home, ".config", "legion", "envoy.json");
13942
+ if (selected === userFile) {
13943
+ return moveNotice(old, userFile, "mkdir -p ~/.config/legion && mv ~/.config/opencode/envoy.json ~/.config/legion/", deviceOf);
13944
+ }
13945
+ const profileFile = PLUGIN_HARNESSES.map((harness) => namedDirectory(harness, env)).some((directory) => directory !== undefined && path.isAbsolute(directory) && selected === path.resolve(directory, "envoy.json"));
13946
+ if (profileFile) {
13947
+ return `${old} is not read for this profile; copy or link it to ${filePath}, or write that file`;
13948
+ }
13949
+ const configHome = nonEmpty(env.XDG_CONFIG_HOME);
13950
+ const neutral = neutralConfig(env);
13951
+ if (configHome !== undefined && path.isAbsolute(configHome) && "path" in neutral && selected === neutral.path) {
13952
+ return `${old} is not read under XDG_CONFIG_HOME=${configHome}`;
13953
+ }
13954
+ return;
13955
+ }
13956
+ function missingConfigNotice(filePath, missing, off) {
13957
+ const link = missing.link === null ? "" : ` (a link to ${missing.link}, which does not exist)`;
13958
+ const old = missing.oldUserFileNotice === undefined ? "" : `; ${missing.oldUserFileNotice}`;
13959
+ return `no envoy.json at ${filePath}${link}: ${off}${old}`;
13960
+ }
13961
+ function readEnvoyConfig(filePath, env, options) {
13962
+ const deviceOf = options.deviceOf ?? statDevice;
13963
+ const chosen = parseFile(filePath, EnvoyFileSchema, false);
13964
+ if (chosen.kind === "invalid")
13965
+ return { kind: "failed", path: filePath, error: chosen.reason };
13966
+ const resolvedChosen = path.resolve(filePath);
13967
+ const checkoutPath = path.resolve(options.cwd, ".envoy", "envoy.json");
13968
+ const checkoutFile = checkoutPath === resolvedChosen ? { kind: "absent", link: null } : parseFile(checkoutPath, CheckoutFileSchema, true);
13969
+ const own = chosen.kind === "valid" ? chosen.settings : {};
13970
+ let checkout = null;
13971
+ if (checkoutFile.kind === "invalid") {
13972
+ checkout = { kind: "invalid", path: checkoutPath, error: checkoutFile.reason };
13973
+ } else if (checkoutFile.kind === "valid") {
13974
+ checkout = {
13975
+ kind: "valid",
13976
+ path: checkoutPath,
13977
+ dispatch: checkoutFile.settings.dispatch ?? {},
13978
+ envoyError: Object.hasOwn(checkoutFile.settings, "envoy") ? `${checkoutPath}: envoy is not read from a checkout's envoy.json; put it in ${filePath}` : null
13692
13979
  };
13693
13980
  }
13694
- return { enabled: true, url: url2.url, token, error: null };
13981
+ const oldCheckout = path.resolve(options.cwd, ".opencode", "envoy.json");
13982
+ const warnings = oldCheckout !== resolvedChosen && !existsSync(checkoutPath) && existsSync(oldCheckout) ? [
13983
+ moveNotice(oldCheckout, checkoutPath, `mkdir -p ${path.dirname(checkoutPath)} && mv ${oldCheckout} ${checkoutPath}`, deviceOf)
13984
+ ] : [];
13985
+ const unreadOldUserFile = chosen.kind === "absent" ? oldUserFileNotice(filePath, env, deviceOf) : undefined;
13986
+ return {
13987
+ kind: "read",
13988
+ path: filePath,
13989
+ dispatch: own.dispatch ?? {},
13990
+ envoy: own.envoy ?? {},
13991
+ checkout,
13992
+ missing: chosen.kind === "absent" ? {
13993
+ link: chosen.link,
13994
+ ...unreadOldUserFile === undefined ? {} : { oldUserFileNotice: unreadOldUserFile }
13995
+ } : null,
13996
+ warnings
13997
+ };
13695
13998
  }
13696
- function activeDispatchConfig(env, options = {}) {
13697
- const config2 = resolveDispatchConfig(env, options);
13698
- if (config2.error !== null)
13699
- throw new Error(`dispatch config: ${config2.error}`);
13700
- const { url: url2, token } = config2;
13701
- if (!config2.enabled || url2 === null || token === null)
13702
- return null;
13703
- return { ...config2, url: url2, token };
13999
+
14000
+ // ../envoy-client/src/bearer.ts
14001
+ function bearerSource(env, section, written) {
14002
+ const pointer = nonEmpty(env[`${section}_TOKEN_FILE`]);
14003
+ if (pointer !== undefined) {
14004
+ return { kind: "variable", token: readSecretFile(`${section}_TOKEN_FILE`, pointer) };
14005
+ }
14006
+ const plain = nonEmpty(env[`${section}_TOKEN`]);
14007
+ if (plain !== undefined) {
14008
+ if (!sendable(plain)) {
14009
+ throw new Error(`${section}_TOKEN holds whitespace or a control character`);
14010
+ }
14011
+ return { kind: "variable", token: plain };
14012
+ }
14013
+ const bearer = written();
14014
+ return bearer === undefined ? { kind: "none" } : { kind: "file", ...bearer };
14015
+ }
14016
+ function fileBearer(bearer, url2, section, env) {
14017
+ const named = httpUrl2(url2)?.origin;
14018
+ const paired = httpUrl2(bearer.server)?.origin;
14019
+ if (named !== paired) {
14020
+ throw new Error(`${section}_URL names ${named}, but ${bearer.path}'s ${bearer.field} is for ${paired}; set ${section}_TOKEN or ${section}_TOKEN_FILE for it`);
14021
+ }
14022
+ if (bearer.checkout && bearer.token.startsWith("!")) {
14023
+ throw new Error(`${bearer.path}: ${bearer.field} is a command, which a checkout's envoy.json cannot run`);
14024
+ }
14025
+ if (bearer.token === "") {
14026
+ throw new Error(`${bearer.field} in ${bearer.path} must be a non-empty bearer token`);
14027
+ }
14028
+ return fileValue(bearer.token, bearer.field, bearer.path, env);
14029
+ }
14030
+
14031
+ // ../envoy-client/src/dispatch-config.ts
14032
+ var DEFAULT_SERVER_URL = "http://localhost:8766";
14033
+ function checkoutPair(checkout, serverUrl) {
14034
+ const { token } = checkout.dispatch;
14035
+ if (token === undefined) {
14036
+ throw new Error(`${checkout.path}: dispatch.serverUrl requires dispatch.token in the same file`);
14037
+ }
14038
+ return {
14039
+ kind: "file",
14040
+ token,
14041
+ field: "dispatch.token",
14042
+ path: checkout.path,
14043
+ server: serverUrl,
14044
+ checkout: true
14045
+ };
14046
+ }
14047
+ function writtenToken(config2, checkout) {
14048
+ if (checkout?.dispatch.token !== undefined) {
14049
+ throw new Error(`${checkout.path}: dispatch.token requires dispatch.serverUrl in the same file`);
14050
+ }
14051
+ const own = config2.dispatch;
14052
+ if (own.token === undefined)
14053
+ return;
14054
+ const server = own.serverUrl ?? DEFAULT_SERVER_URL;
14055
+ return {
14056
+ token: own.token,
14057
+ field: "dispatch.token",
14058
+ path: config2.path,
14059
+ server,
14060
+ checkout: false
14061
+ };
14062
+ }
14063
+ function dispatchSettings(env, config2) {
14064
+ if (config2.kind === "failed")
14065
+ return offWithError(config2.error);
14066
+ const explicitUrl = nonEmpty(env.DISPATCH_URL);
14067
+ const checkoutFile = explicitUrl === undefined ? config2.checkout : null;
14068
+ if (checkoutFile?.kind === "invalid")
14069
+ return offWithError(checkoutFile.error);
14070
+ const checkout = checkoutFile?.kind === "valid" ? checkoutFile : null;
14071
+ const own = config2.dispatch;
14072
+ if (own.enabled === false) {
14073
+ return offWithNotice(`${config2.path} sets dispatch.enabled to false: Dispatch is off`);
14074
+ }
14075
+ if (checkout?.dispatch.enabled === false) {
14076
+ return offWithNotice(`${checkout.path} sets dispatch.enabled to false: Dispatch is off`);
14077
+ }
14078
+ const checkoutServer = checkout?.dispatch.serverUrl;
14079
+ const addressed = explicitUrl !== undefined || checkoutServer !== undefined || own.serverUrl !== undefined || own.enabled === true;
14080
+ if (!addressed) {
14081
+ return offWithNotice(config2.missing === null ? `${config2.path} names no Dispatch server, and DISPATCH_URL is not set: Dispatch is off` : missingConfigNotice(config2.path, config2.missing, "Dispatch is off"));
14082
+ }
14083
+ if (explicitUrl !== undefined && httpUrl2(explicitUrl) === undefined) {
14084
+ return offWithError("DISPATCH_URL must be an http or https URL");
14085
+ }
14086
+ try {
14087
+ const source = checkout !== null && checkoutServer !== undefined ? checkoutPair(checkout, checkoutServer) : bearerSource(env, "DISPATCH", () => writtenToken(config2, checkout));
14088
+ if (source.kind === "none") {
14089
+ return offWithError(explicitUrl === undefined ? `${config2.path}: dispatch is enabled and no token is: set dispatch.token, or DISPATCH_TOKEN or DISPATCH_TOKEN_FILE` : `DISPATCH_URL is set and no token is: set DISPATCH_TOKEN or DISPATCH_TOKEN_FILE, or dispatch.token in ${config2.path}`);
14090
+ }
14091
+ const url2 = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
14092
+ const token = source.kind === "variable" ? source.token : fileBearer(source, url2, "DISPATCH", env);
14093
+ return { enabled: true, url: url2, token };
14094
+ } catch (error48) {
14095
+ return offWithError(messageFor(error48));
14096
+ }
14097
+ }
14098
+ function activeDispatchSettings(resolution) {
14099
+ if (resolution.enabled)
14100
+ return resolution;
14101
+ if (resolution.error !== null)
14102
+ throw new Error(`dispatch config: ${resolution.error}`);
14103
+ return null;
13704
14104
  }
13705
14105
 
13706
14106
  // ../contracts/src/dispatch-api.ts
@@ -15077,16 +15477,16 @@ function dispatchAnswered(error48, status) {
15077
15477
  // ../envoy-client/src/dispatch-session-state.ts
15078
15478
  import {
15079
15479
  appendFileSync,
15080
- closeSync,
15480
+ closeSync as closeSync2,
15081
15481
  fstatSync,
15082
15482
  mkdirSync,
15083
- openSync,
15483
+ openSync as openSync2,
15084
15484
  readdirSync,
15085
15485
  readFileSync as readFileSync3,
15086
15486
  readSync,
15087
15487
  renameSync,
15088
- rmSync,
15089
- statSync,
15488
+ rmSync as rmSync2,
15489
+ statSync as statSync2,
15090
15490
  writeFileSync
15091
15491
  } from "fs";
15092
15492
  import { homedir as homedir2 } from "os";
@@ -15119,6 +15519,25 @@ function writeSessionTitle(dir, title) {
15119
15519
  writeAtomically(join2(dir, "title"), title);
15120
15520
  }
15121
15521
 
15522
+ // ../envoy-client/src/session-settings.ts
15523
+ function sessionConfigFile(harness, env) {
15524
+ try {
15525
+ return { path: envoyConfigPath(harness, env), error: null };
15526
+ } catch (error48) {
15527
+ return { path: null, error: messageFor(error48) };
15528
+ }
15529
+ }
15530
+ function sessionDispatchSettings(file2, env, options) {
15531
+ if (file2.path === null)
15532
+ return { dispatch: offWithError(file2.error), warnings: [], missing: null };
15533
+ const config2 = readEnvoyConfig(file2.path, env, options);
15534
+ return {
15535
+ dispatch: dispatchSettings(env, config2),
15536
+ warnings: config2.kind === "read" ? config2.warnings : [],
15537
+ missing: config2.kind === "read" ? config2.missing : null
15538
+ };
15539
+ }
15540
+
15122
15541
  // ../pi-shared/src/interface.ts
15123
15542
  import { logger } from "@oh-my-pi/pi-utils";
15124
15543
  var ENVOY_PLUGIN_INTERFACE_VERSION = 2;
@@ -15650,7 +16069,7 @@ async function measureCapabilities(host2, input, budgets = DEFAULT_BUDGETS) {
15650
16069
  });
15651
16070
  return { measuredAt, elapsedMs: Date.now() - started, rows };
15652
16071
  }
15653
- async function runCommand(command, args, options) {
16072
+ async function runCommand2(command, args, options) {
15654
16073
  const child = Bun.spawn([command, ...args], {
15655
16074
  cwd: options.cwd,
15656
16075
  env: process.env,
@@ -15700,7 +16119,7 @@ function ompCapabilityHost() {
15700
16119
  disabledExtensions: cfgDisabledExtensions.get(settings)
15701
16120
  });
15702
16121
  },
15703
- run: runCommand
16122
+ run: runCommand2
15704
16123
  };
15705
16124
  }
15706
16125
 
@@ -15729,7 +16148,7 @@ function onEnvoyRoleRegained(listener) {
15729
16148
  // package.json
15730
16149
  var package_default = {
15731
16150
  name: "@sjawhar/pi-legion",
15732
- version: "9.8.2",
16151
+ version: "10.1.0",
15733
16152
  license: "Apache-2.0",
15734
16153
  type: "module",
15735
16154
  omp: {
@@ -16256,17 +16675,17 @@ async function userConfigChain(timeoutMs = JJ_CONFIG_PATH_TIMEOUT_MS, env = proc
16256
16675
  new Response(child.stdout).text(),
16257
16676
  new Response(child.stderr).text()
16258
16677
  ]);
16259
- const stdout = await new Promise((resolve) => {
16678
+ const stdout = await new Promise((resolve2) => {
16260
16679
  const timer = setTimeout(() => {
16261
16680
  killGroup();
16262
- resolve(undefined);
16681
+ resolve2(undefined);
16263
16682
  }, timeoutMs);
16264
16683
  completed.then(([code, output]) => {
16265
16684
  clearTimeout(timer);
16266
- resolve(code === 0 ? output : undefined);
16685
+ resolve2(code === 0 ? output : undefined);
16267
16686
  }, () => {
16268
16687
  clearTimeout(timer);
16269
- resolve(undefined);
16688
+ resolve2(undefined);
16270
16689
  });
16271
16690
  });
16272
16691
  if (stdout === undefined)
@@ -17199,7 +17618,9 @@ function legionExtension(pi) {
17199
17618
  onPhaseCompleted,
17200
17619
  handoffCommit: (phase, role) => findHandoffCommit({ phase, role, env: process.env }),
17201
17620
  resolveDocument: async (issue2, reference) => {
17202
- const config2 = activeDispatchConfig(process.env, { cwd: process.cwd() });
17621
+ const config2 = activeDispatchSettings(sessionDispatchSettings(sessionConfigFile("omp", process.env), process.env, {
17622
+ cwd: process.cwd()
17623
+ }).dispatch);
17203
17624
  if (config2 === null) {
17204
17625
  throw new Error(`register_gate cannot look up document "${reference}" on ${issue2}: this pane has no Dispatch configured; pass the document's id`);
17205
17626
  }
@@ -387,7 +387,7 @@ active phase worker.
387
387
  | `design-approved` | Its `version` is the approved one. A human approved the root spec document at its current version; the gate is open. Proceed to section 2 — except on a root whose catch-up said `handed_over`: its workers already run their phases, so start nothing; the approval releases any READY the gate refused. |
388
388
  | `design-changes-requested` | Its `version` and `reason`. A human asked for changes to the root spec at `version`, for `reason`. Revise the spec as the reason asks and request approval again as section 1 says; stay parked; the gate is closed. |
389
389
  | `phase-finished` | The daemon has already moved the issue to its next phase by its fixed table and started that phase's role; you start nothing. Read the committed handoff for the finishing phase; if it shows unresolved gaps, tell the role now working the issue (`envoy_publish` to its role topic). A `planner` notice that names a departure from the spec's design defers to section 1's full condition: when the approved Summary, Acceptance, scope and settled decisions still hold, the plan is the record; otherwise change the root spec and request approval again as section 1 says. A `reviewer` notice whose GitHub review is `CHANGES_REQUESTED` needs nothing from you: the daemon has returned the issue to `implementing` (Dispatch `in_progress`) and started the **implementer**, whose correction goes through the tester and the reviewer again, never straight to retro. A `reviewer` notice with an `APPROVED` review means the daemon has started retro (step 5). An `implementer` notice for `production_check` is its production report: read the record on the pull request and the issue, then run step 7. A `tester` notice with `verdict: "fail"` — its handoff carries `implementerProof.verdict: "rejected"`, or a failure naming the production-like proof — has gone back to the **implementer** by the daemon's table; never supply the proof from another role. A worker that reports no surface reaches the changed path sends that report instead of completing its phase, so the daemon starts nothing more on that issue and the worker stays idle in its session, not suspended: file a child issue in this tree to build the surface (infrastructure, tooling, or a skill), and when that child's `child-closed` arrives, tell the waiting worker to continue with `envoy_publish` to its role topic. That report is never a reason to advance the phase. |
390
- | `pr-blocked` | Its `reason` names the pull request and the `max_fix_attempts` it reached, or reads `Issue reached review_round_cap=<N>.` (the end of this row). The count is of heads pushed onto a red verdict that changed something outside `.legion/` — handoff-only pushes (`.legion/` paths only) never count, a push by the review App (a planner's, tester's, reviewer's or architect's) never counts, and the head after a red the tester's red tests earned (a review-App push that changed a path outside `.legion/`, however many handoff-only pushes follow it) does not count either — so after the tester's handoff-only push onto the implementer's red, the implementer's next push does count; a push the daemon cannot classify (a listener without `changed_paths`, a list the listener stopped at 100 paths or 32,768 runes of text, a push listing no commits) does. Published once per exhausted count, not on every later red verdict for that count. Read the failed CI evidence and recovery attempts. The notice moves nothing, and no later CI red within the attempt that ran the count out (the counted head, and every head pushed on top of it until a push lands on a head CI judged green, the tester's red tests and handoff onto the held red included) sends the work back from `testing` or `reviewing` either: the issue stays in its phase until you move it, or a worker's own decision does (a tester's `fail`, a reviewer's request for changes). A head pushed onto green CI leaves that attempt, so its red still comes as a `checks-red`. In `implementing`, give the implementer the failing checks (`envoy_publish` to its role topic). In any later phase a worker holds, tell that worker (`envoy_publish` to its role topic) to move the issue back to `implementing` with `request_backward_move`, naming the failing checks, and the daemon starts the implementer; or file a corrective child. In `awaiting_merge`, where no worker holds a phase and a backward move is refused, open a `dispatch ask` naming the failing checks for the human who merges, as section 6 does for a conflict there. Do not treat the blocked PR as final. A `review_round_cap` reason is the implementer's round count instead: every move back to an earlier phase counts one, and the notice comes again with each round past the cap. It moves nothing either, since the round that reached the cap has already started its worker. Tell the rounds apart by what announced each: a tester's fail by its `phase-finished` with `verdict: "fail"`; a request for changes by the reviewer's `phase-finished` over a `CHANGES_REQUESTED` review; the daemon's own move by a `checks-red` notice, whose reason names either a conflict (`the head conflicts with <base>`) or the required checks that failed (`CI is red at <head>`); a worker's `request_backward_move` by a `phase-finished` from the role that asked, whose reason reaches only the task of the worker the move started, so ask that worker. Judge the cap by the rounds that are not conflicts. Conflict returns need nothing from you beyond the next round. Tester fails, requests for changes and CI-red returns (a `checks-red` notice is raised for CI only when a required check fails on code the branch pushed) are the work going in circles: narrow it, re-plan it with the responsible worker, or ask a human with `dispatch ask`. Judge a worker's backward move by the reason that worker gives. A move you ordered for a conflict found before `awaiting_merge` (section 6) is a conflict return. The merger's move back to `reviewing` because its task names no approved head is not the work going in circles either. A move that sends back code found wrong counts with the tester fails. |
390
+ | `pr-blocked` | Its `reason` names the pull request and the `max_fix_attempts` it reached, or reads `Issue reached review_round_cap=<N>.` (the end of this row). The count is of heads pushed onto a red verdict that changed something outside `.legion/` — handoff-only pushes (`.legion/` paths only) never count, a push by the review App (a planner's, tester's, reviewer's or architect's) never counts, and the head after a red the tester's red tests earned (a review-App push that changed a path outside `.legion/`, however many handoff-only pushes follow it) does not count either — so after the tester's handoff-only push onto the implementer's red, the implementer's next push does count; a push the daemon cannot classify (a listener without `changed_paths`, a list the listener stopped at 100 paths or 32,768 runes of text, a push listing no commits) does. Published once per exhausted count, not on every later red verdict for that count. Read the failed CI evidence and recovery attempts. The notice moves nothing, and no later CI red within the attempt that ran the count out (the counted head, and every head pushed on top of it until a push lands on a head CI judged green, the tester's red tests and handoff onto the held red included) sends the work back from `testing`, `reviewing`, `retro` or `merging` either: the issue stays in its phase until you move it, or a worker's own decision does (a tester's `fail`, a reviewer's request for changes). A head pushed onto green CI leaves that attempt, so its red still comes as a `checks-red`. In `implementing`, give the implementer the failing checks (`envoy_publish` to its role topic). In any later phase a worker holds, tell that worker (`envoy_publish` to its role topic) to move the issue back to `implementing` with `request_backward_move`, naming the failing checks, and the daemon starts the implementer; or file a corrective child. In `awaiting_merge`, where no worker holds a phase and a backward move is refused, open a `dispatch ask` naming the failing checks for the human who merges, as section 6 does for a conflict there. Do not treat the blocked PR as final. A `review_round_cap` reason is the implementer's round count instead: every move back to an earlier phase counts one, and the notice comes again with each round past the cap. It moves nothing either, since the round that reached the cap has already started its worker. Tell the rounds apart by what announced each: a tester's fail by its `phase-finished` with `verdict: "fail"`; a request for changes by the reviewer's `phase-finished` over a `CHANGES_REQUESTED` review; the daemon's own move by a `checks-red` notice, whose reason names either a conflict (`the head conflicts with <base>`) or the required checks that failed (`CI is red at <head>`); a worker's `request_backward_move` by a `phase-finished` from the role that asked, whose reason reaches only the task of the worker the move started, so ask that worker. Judge the cap by the rounds that are not conflicts. Conflict returns need nothing from you beyond the next round. Tester fails, requests for changes and CI-red returns (a `checks-red` notice is raised for CI only when a required check fails on code the branch pushed) are the work going in circles: narrow it, re-plan it with the responsible worker, or ask a human with `dispatch ask`. Judge a worker's backward move by the reason that worker gives. A move you ordered for a conflict found before `awaiting_merge` (section 6) is a conflict return. The merger's move back to `reviewing` because its task names no approved head is not the work going in circles either. A move that sends back code found wrong counts with the tester fails. |
391
391
  | `pr-merged` | Its `reason` names the pull request. The PR merged, under the repository's rules, before the issue reached `awaiting_merge`. The workflow runs on and asks no one to merge it: the daemon starts the **implementer** on the production check once the issue gets there, and you start nothing. Its `phase-finished` for `production_check` is what brings you to step 7: verify the record on the pull request and this issue first, then sign off naming it with `sign_off`. A merge is not the close. |
392
392
  | `pr-closed-unmerged` | Decide from current scope whether the work is reopened, started over (`park_child` then `rerun_child`, for a child), or ended with a reason. Delegate the repository action to the responsible phase worker and keep ownership. |
393
393
  | A reply on an ask you follow | Interpret the reply in the issue's design context. Answer in its thread (`dispatch comment --reply-to <comment id>`; under an open ask whose next move is yours, such as the approval request you must revise or hand back, `--reply-to-ask <ask id> --turn agent`, since a default-turn reply hands that request back to the human and a corrected `--summary` is then refused), then adjust the plan or relay it via `envoy_publish` to the responsible worker's role token; scope and product decisions remain with you. |
@@ -118,9 +118,12 @@ turn, including one an Envoy question starts: you are kept alive to answer quest
118
118
  editing. Do not create new commits, run `jj -R "$LEGION_WORKSPACE" new`, or touch tracked files
119
119
  once your own handoff is committed and pushed — a code change belongs to
120
120
  whichever phase is active now. The same holds once the Go daemon has taken your phase back without
121
- your completion: CI settling red while you tested, or reviewed a round you had not completed,
122
- moves the issue to `implementing` and interrupts your turn, and the implementer starts only once
123
- that turn has ended. Do not resume the interrupted work afterward.
121
+ your completion: CI settling red while you tested, reviewed a round you had not completed, or
122
+ built READY, moves the issue to `implementing` and interrupts your turn, and the implementer starts
123
+ only once that turn has ended. Do not resume the interrupted work afterward. From retro the
124
+ issue moves the same way with nothing interrupted: you are the implementer, handed the implementing
125
+ task once your turn ends; a retro completion started after the move is refused, and one in flight
126
+ when the red lands can be taken as your implementing completion, caught by the tester's CI read.
124
127
 
125
128
  On every start, and especially after revival or re-creation, read the issue and then the
126
129
  committed predecessor handoffs in lifecycle order, with the `read` tool from
@@ -437,8 +440,8 @@ record of this issue's active phase. Do not add pipeline labels, run a controlle
437
440
  invent a different completion protocol — this is the whole contract.
438
441
 
439
442
  A reviewer's phase ends with its completion, not with its review; the order of a review round
440
- (the handoff push; for an approval, CI settled green at that head; the review of that head; then
441
- the completion) is in `skill://legion-worker/references/merge-gate.md`.
443
+ (the read of the code commit's checks on GitHub; the handoff push; the review of the head that
444
+ push made; then the completion) is in `skill://legion-worker/references/merge-gate.md`.
442
445
 
443
446
  **A refused completion is information, not a retry loop.** The daemon attributes your report to
444
447
  the run whose task you took, and answers with what it found. What each answer carries, and what to
@@ -20,7 +20,33 @@ the head voids it.
20
20
  - The reviewer verifies the `CI`, `Threads`, and `E2E` facts against GitHub directly —
21
21
  never from a handoff — then runs `task(agent="thermonuclear-deep-review")` and
22
22
  `task(agent="thermonuclear-code-quality")` once at that head — the head the round reviews —
23
- and records the verdict.
23
+ and records the verdict. The `CI` fact is the checks that stand for the code under review:
24
+ those GitHub ran for the newest push of the branch that started `pull_request` workflows, when
25
+ every commit above that push's head changes only `.legion/` (a push that changes only
26
+ `.legion/` carries `skip-checks: true`, so GitHub runs no workflow for it). With `<o>/<r>`,
27
+ `<n>`, `<base>`, `<headRefName>` and `<headRefOid>` from
28
+ `gh pr view <n> --json baseRefName,headRefOid,headRefName`, four reads. The required set, from
29
+ the base branch's rulesets
30
+ (`gh api repos/<o>/<r>/rules/branches/<base> --jq '.[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context'`)
31
+ and its branch protection, when it has any
32
+ (`gh api repos/<o>/<r>/branches/<base> --jq '.protection.required_status_checks.checks[].context'`);
33
+ a read GitHub refuses is reported to the architect, never guessed. The CI head, the head of the
34
+ branch's newest `pull_request` run
35
+ (`gh api 'repos/<o>/<r>/actions/runs?branch=<headRefName>&event=pull_request&per_page=30' --jq '.workflow_runs[0].head_sha'`,
36
+ newest first); the PR body's `CI:` line names runs at that same commit
37
+ (`gh run view <run-id> --json headSha,status,conclusion`), or the line is stale and the review
38
+ says so. That commit's check-runs
39
+ (`gh api repos/<o>/<r>/commits/<ci head>/check-runs --paginate --jq '.check_runs[] | [.name, .status, .conclusion] | @tsv'`):
40
+ every required name present, `completed` and `success` (GitHub counts `neutral` and `skipped`
41
+ as passing for a required check). And the compare proving only `.legion/` changed since
42
+ (`gh api repos/<o>/<r>/compare/<ci head>...<headRefOid> --jq '[.files[].filename] | all(startswith(".legion/"))'`
43
+ prints `true`; `false` means code was pushed since CI ran, so wait for the new push's runs and
44
+ read the CI head again). A required check `queued` or `in_progress`: wait. One `completed` as
45
+ `cancelled`, or absent: `gh run rerun <run-id>` once, then wait; when nothing will run it (a
46
+ check only a commit status reports, a workflow another repository defines), tell the architect
47
+ and do not approve. `gh pr checks <n>` is not the read: it reads only the current head, which
48
+ at a handoff head shows only the `pr-title` runs the review events start (measured on
49
+ sjawhar/legion#1944 at 0220b94fc2 on 2026-10-14).
24
50
  Approval is refused while either `E2E (implementer)` or `E2E (tester)` is missing: `REQUEST_CHANGES` naming the missing line.
25
51
  Skip the `Thermo` line entirely on a docs-only PR. Submit **one review per round** —
26
52
  `REQUEST_CHANGES` when any correctness finding stands, otherwise `APPROVE` of the head you
@@ -42,25 +68,17 @@ the head voids it.
42
68
  when every thread is settled enough to approve, and resolving one never gates your approval.
43
69
 
44
70
  A reviewer's phase ends with its completion, not with its review. Every round writes a handoff
45
- and takes this order: write, commit and push the handoff; submit the review of the head that push
46
- made, by its SHA; then complete. An approval waits for the CI verdict to settle green at that head
47
- before you submit it, since an approval stands only on green checks and GitHub can dismiss one
48
- once the head moves, and a verdict that settles red there makes the round's decision a request for
49
- changes naming the failing checks, unless only review workflows the project declares
50
- (`projects.<KEY>.review_workflows`) are red on their own findings: then you answer their threads,
51
- name the ones you accepted to the implementer, who resolves them with its own `gh` as the pull
52
- request's author, and re-run the failed run once they are resolved, as your role prompt says, and
53
- approve once it passes. Any other red required workflow is a failing check like any other. A
54
- request for changes does not wait, since it stands whatever CI says and the issue leaves
55
- reviewing with it. The verdict is of the checks and workflows the base branch requires, the set
56
- READY checks: red when one of them failed, and never red for a check the base branch does not
57
- require. A required check that was cancelled, or that the head's checks
58
- settled without, leaves no verdict until a later settlement decides it, since a run can be
59
- cancelled or not yet queued when the head settles; a required workflow's run on the head that is
60
- still going or has not happened leaves none either. A review of a head the handoff push
61
- then replaces names a head the pull request no longer has. The daemon moves the issue
62
- once both are in — the decision GitHub reports and your completion, in either order — so a
63
- review posted without a completion leaves the issue in reviewing until you finish.
71
+ and takes this order: read CI as above; write, commit and push the handoff; submit the review of
72
+ the head that push made, by its SHA; then complete. A red at the code is a `REQUEST_CHANGES`
73
+ naming the check, never an approval. A request for changes does not wait, since it stands
74
+ whatever CI says and the issue leaves reviewing with it. The approval waits on nothing at the
75
+ handoff commit either: it ends the round whatever CI says there, since the round reads no CI
76
+ verdict, and a red the daemon reads later — at that commit or any above it — is the code's red
77
+ and resumes the implementer from wherever the issue is (reviewing, retro or merging) without the
78
+ reviewer. A review of a head the handoff push then replaces names a head the pull request no
79
+ longer has. The daemon moves the issue once both are in — the decision GitHub reports and your
80
+ completion, in either order — so a review posted without a completion leaves the issue in
81
+ reviewing until you finish.
64
82
 
65
83
  ## Retro
66
84
 
@@ -67,11 +67,8 @@ opened that is none of Legion's role Apps, with one reply in your own words. A b
67
67
  cannot accept becomes your own: say what stands and request changes. Approve once each of those
68
68
  threads carries your answer and no finding stands, read in `gh api graphql` — quote those answers
69
69
  in the approval. Your approval never waits on a resolution: resolution is the pull request author's
70
- App's, except when a review workflow the project declares (`projects.<KEY>.review_workflows`) is
71
- red on its findings. Such a workflow starts again only on a push, so neither a reply nor a
72
- resolution re-runs it, and it passes on a re-run only once its threads are resolved: answer its
73
- threads, name the ones you accepted to the implementer with `envoy_publish` to its role topic, and
74
- read the listing above until each shows `isResolved` true before you push your handoff, so the run
75
- that push starts reads the threads as the implementer left them; then re-run the failed run once,
76
- as your role prompt says, before you approve. The merger resolves nothing: READY requires no thread
77
- state beyond what your approval already judged.
70
+ App's. A review bot that is red while a thread it opened still stands is CI like any other, and
71
+ you re-run nothing: when the base branch requires its check, that red sends the work back to the
72
+ implementer, who answers or fixes each of the bot's threads, resolves them with its own `gh` (the
73
+ ones you named to it, above, and its own), and pushes, and the bot runs again on that push. The
74
+ merger resolves nothing: READY requires no thread state beyond what your approval already judged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion",
3
- "version": "9.8.2",
3
+ "version": "10.1.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "omp": {