@sjawhar/pi-legion 10.4.1 → 10.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/legion.js CHANGED
@@ -14090,7 +14090,17 @@ function dispatchSettings(env, config2) {
14090
14090
  }
14091
14091
  const url2 = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
14092
14092
  const token = source.kind === "variable" ? source.token : fileBearer(source, url2, "DISPATCH", env);
14093
- return { enabled: true, url: url2, token };
14093
+ const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
14094
+ const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
14095
+ if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
14096
+ return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
14097
+ }
14098
+ return {
14099
+ enabled: true,
14100
+ url: url2,
14101
+ token,
14102
+ ...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
14103
+ };
14094
14104
  } catch (error48) {
14095
14105
  return offWithError(messageFor(error48));
14096
14106
  }
@@ -14902,6 +14912,11 @@ function controllerToken(project) {
14902
14912
  assertLegionProjectToken(project);
14903
14913
  return `legion-${project}-controller`;
14904
14914
  }
14915
+ // ../contracts/src/slack.ts
14916
+ var SLACK_CHANNEL_ID_PATTERN = /^[CG][A-Z0-9]+$/;
14917
+ function slackMentionTopic(channel) {
14918
+ return `notifications.slack.*.${channel}.mention`;
14919
+ }
14905
14920
  // ../envoy-client/src/ask-answer.ts
14906
14921
  var HEAD_LENGTH = 120;
14907
14922
  function textHead(text) {
@@ -15540,7 +15555,7 @@ function sessionDispatchSettings(file2, env, options) {
15540
15555
 
15541
15556
  // ../pi-shared/src/interface.ts
15542
15557
  import { logger } from "@oh-my-pi/pi-utils";
15543
- var ENVOY_PLUGIN_INTERFACE_VERSION = 2;
15558
+ var ENVOY_PLUGIN_INTERFACE_VERSION = 3;
15544
15559
  var ENVOY_PLUGIN_INTERFACE_KEY = Symbol.for("legion.pi-shared.envoy-plugin-interface");
15545
15560
  var LEGION_PLUGIN_LOADED_KEY = Symbol.for("legion.pi-legion.loaded");
15546
15561
  var LEGACY_LEGION_LOADED_KEY = Symbol.for("legion.pi-envoy.legion-loaded");
@@ -15553,7 +15568,12 @@ function envoyPluginInterface() {
15553
15568
  const created = {
15554
15569
  version: ENVOY_PLUGIN_INTERFACE_VERSION,
15555
15570
  publishers: [],
15556
- roleClaim: { instances: [], managedSessions: new Set, regained: undefined },
15571
+ roleClaim: {
15572
+ instances: [],
15573
+ managedSessions: new Set,
15574
+ regained: undefined,
15575
+ slackRoleOwned: false
15576
+ },
15557
15577
  injectedUserTurns: new Map,
15558
15578
  bootstrappedSession: { file: undefined }
15559
15579
  };
@@ -15627,6 +15647,29 @@ function messageTag(message) {
15627
15647
  return typeof message.tag === "string" ? message.tag : undefined;
15628
15648
  }
15629
15649
 
15650
+ // ../pi-shared/src/role-claim-bridge.ts
15651
+ function legionRoleClaimBridge() {
15652
+ return envoyPluginInterface().roleClaim;
15653
+ }
15654
+ async function claimEnvoyRole(sessionID, role, context) {
15655
+ const bridge = legionRoleClaimBridge();
15656
+ bridge.managedSessions.add(sessionID);
15657
+ const instance = bridge.instances.findLast((candidate) => candidate.sessionID() === sessionID) ?? bridge.instances.at(-1);
15658
+ if (instance === undefined)
15659
+ throw new Error("Envoy has no bound instance for a role claim");
15660
+ await instance.claim(sessionID, role, context);
15661
+ }
15662
+ async function subscribeLegionNotice(sessionID, topic, context, whileHolding) {
15663
+ const bridge = legionRoleClaimBridge();
15664
+ const instance = bridge.instances.findLast((candidate) => candidate.sessionID() === sessionID) ?? bridge.instances.at(-1);
15665
+ if (instance === undefined)
15666
+ throw new Error("Envoy has no bound instance for a notice subscription");
15667
+ await instance.subscribe(sessionID, topic, context, whileHolding);
15668
+ }
15669
+ function onEnvoyRoleRegained(listener) {
15670
+ legionRoleClaimBridge().regained = listener;
15671
+ }
15672
+
15630
15673
  // ../pi-shared/src/subagent-session.ts
15631
15674
  import fs from "fs";
15632
15675
  import path2 from "path";
@@ -15701,7 +15744,8 @@ var ENVOY_TOOL_NAMES = [
15701
15744
  "envoy_role_set",
15702
15745
  "envoy_role_get",
15703
15746
  "envoy_whoami",
15704
- "envoy_sessions"
15747
+ "envoy_sessions",
15748
+ "slack_post"
15705
15749
  ];
15706
15750
  var LIVE_CAPABILITIES = [
15707
15751
  "subagents",
@@ -15907,7 +15951,7 @@ var checkDispatchEnvoyTools = async (host2, input, signal) => {
15907
15951
  });
15908
15952
  if (result.code !== 0)
15909
15953
  return failed(`dispatch read failed: ${commandFailure(result)}`);
15910
- return ok(`ten Envoy tools registered; dispatch read ${input.issue} answered from ${whichOnPath("dispatch")}`);
15954
+ return ok(`${ENVOY_TOOL_NAMES.length} Envoy tools registered; dispatch read ${input.issue} answered from ${whichOnPath("dispatch")}`);
15911
15955
  };
15912
15956
  var checkGitHub = async (host2, input, signal) => {
15913
15957
  const result = await host2.run("gh", ["api", "graphql", "-f", "query={viewer{login}}", "--jq", ".data.viewer.login"], { cwd: input.cwd, signal });
@@ -16122,33 +16166,10 @@ function ompCapabilityHost() {
16122
16166
  run: runCommand2
16123
16167
  };
16124
16168
  }
16125
-
16126
- // ../pi-shared/src/role-claim-bridge.ts
16127
- function legionRoleClaimBridge() {
16128
- return envoyPluginInterface().roleClaim;
16129
- }
16130
- async function claimEnvoyRole(sessionID, role, context) {
16131
- const bridge = legionRoleClaimBridge();
16132
- bridge.managedSessions.add(sessionID);
16133
- const instance = bridge.instances.findLast((candidate) => candidate.sessionID() === sessionID) ?? bridge.instances.at(-1);
16134
- if (instance === undefined)
16135
- throw new Error("Envoy has no bound instance for a role claim");
16136
- await instance.claim(sessionID, role, context);
16137
- }
16138
- async function subscribeLegionNotice(sessionID, topic, context, whileHolding) {
16139
- const bridge = legionRoleClaimBridge();
16140
- const instance = bridge.instances.findLast((candidate) => candidate.sessionID() === sessionID) ?? bridge.instances.at(-1);
16141
- if (instance === undefined)
16142
- throw new Error("Envoy has no bound instance for a notice subscription");
16143
- await instance.subscribe(sessionID, topic, context, whileHolding);
16144
- }
16145
- function onEnvoyRoleRegained(listener) {
16146
- legionRoleClaimBridge().regained = listener;
16147
- }
16148
16169
  // package.json
16149
16170
  var package_default = {
16150
16171
  name: "@sjawhar/pi-legion",
16151
- version: "10.4.1",
16172
+ version: "10.5.1",
16152
16173
  license: "Apache-2.0",
16153
16174
  type: "module",
16154
16175
  omp: {
@@ -16160,7 +16181,7 @@ var package_default = {
16160
16181
  ]
16161
16182
  },
16162
16183
  legion: {
16163
- daemonApiVersion: 28
16184
+ daemonApiVersion: 29
16164
16185
  },
16165
16186
  repository: {
16166
16187
  type: "git",
@@ -16841,6 +16862,26 @@ function createClaimSession(deps) {
16841
16862
  };
16842
16863
  }
16843
16864
 
16865
+ // src/slack-channels.ts
16866
+ function daemonLaunchedController(env) {
16867
+ return env.LEGION_CONTROLLER === "1" && env.LEGION_TREE === undefined && (env.LEGION_BOOT_TOKEN_FILE !== undefined || env.LEGION_BOOT_TOKEN !== undefined);
16868
+ }
16869
+ function daemonControllerSlackChannels(env) {
16870
+ const { LEGION_SLACK_CHANNELS: named } = env;
16871
+ if (named === undefined) {
16872
+ throw new Error("LEGION_SLACK_CHANNELS is required for the controller the daemon launches: the project's slack_channels, comma-separated, empty for none");
16873
+ }
16874
+ if (named.trim() === "")
16875
+ return [];
16876
+ return named.split(",").map((entry) => {
16877
+ const channel = entry.trim();
16878
+ if (!SLACK_CHANNEL_ID_PATTERN.test(channel)) {
16879
+ throw new Error(`LEGION_SLACK_CHANNELS entry ${JSON.stringify(channel)} is not a Slack channel id (C or G, then capital letters and digits)`);
16880
+ }
16881
+ return channel;
16882
+ });
16883
+ }
16884
+
16844
16885
  // src/controller-session.ts
16845
16886
  function createControllerSession(deps) {
16846
16887
  const { daemon, persistedTranscript, pi } = deps;
@@ -16850,7 +16891,7 @@ function createControllerSession(deps) {
16850
16891
  let startMessageSent = false;
16851
16892
  let controllerTranscript;
16852
16893
  const claim = async (context) => {
16853
- const supervised = classifySession(process.env).kind === "controller" && (process.env.LEGION_BOOT_TOKEN_FILE !== undefined || process.env.LEGION_BOOT_TOKEN !== undefined);
16894
+ const supervised = daemonLaunchedController(process.env);
16854
16895
  if (!supervised)
16855
16896
  return claimWith(context, false);
16856
16897
  try {
@@ -16870,6 +16911,7 @@ function createControllerSession(deps) {
16870
16911
  if (launched)
16871
16912
  recordBootstrappedSession(sessionFile);
16872
16913
  const project = requiredEnvironment(process.env, "LEGION_PROJECT");
16914
+ const slackChannels = supervised ? daemonControllerSlackChannels(process.env) : [];
16873
16915
  const client = daemon();
16874
16916
  const { daemon: served } = await client.state();
16875
16917
  const servedRole = controllerToken(legionProjectToken(served.project, "the daemon's project"));
@@ -16898,6 +16940,9 @@ function createControllerSession(deps) {
16898
16940
  startMessageSent = true;
16899
16941
  }
16900
16942
  await subscribeLegionNotice(sessionID, legionControllerNoticeSubject(project), envoyContext, registration.claimToken);
16943
+ for (const channel of slackChannels) {
16944
+ await subscribeLegionNotice(sessionID, slackMentionTopic(channel), envoyContext, registration.claimToken);
16945
+ }
16901
16946
  if (supervised) {
16902
16947
  await client.ready({
16903
16948
  claimToken: registration.claimToken,
@@ -17464,6 +17509,9 @@ function legionExtension(pi) {
17464
17509
  from: import.meta.url,
17465
17510
  envoyInterface: ENVOY_PLUGIN_INTERFACE_VERSION
17466
17511
  };
17512
+ if (daemonLaunchedController(process.env)) {
17513
+ legionRoleClaimBridge().slackRoleOwned = true;
17514
+ }
17467
17515
  const checkSubagentSession = subagentSessionCheck();
17468
17516
  let phaseStall = "closed";
17469
17517
  const phaseWorkerSession = (context) => {
@@ -309,10 +309,11 @@ The daemon keeps this order from its fixed table; you start none of its steps:
309
309
  one final commit, the removal of `.legion/<issue>/`, so no merge carries a handoff onto the
310
310
  default branch (dispatch://LEGION-605); those commits do not void the approval and never return
311
311
  the tree to the tester or reviewer;
312
- 4. the merger verifies the current head is the reviewer-approved head plus only retro's commits,
313
- those that change `docs/solutions/` and that removal (`jj diff --from <approved-sha> --to
314
- <tip-sha> --summary`, quoted in READY) and sends the READY packet with its completion, which
315
- refuses a head that still carries `.legion/<issue>/`; the daemon posts
312
+ 4. the merger quotes what sits above the reviewer-approved head (`jj diff --from <approved-sha> --to
313
+ <tip-sha> --summary`) in READY and sends the READY packet with its completion, which refuses a
314
+ head carrying anything above the approved head but retro's commits, those that change
315
+ `docs/solutions/` and that removal (`READY_UNREVIEWED_CHANGE`), and a
316
+ head that still carries `.legion/<issue>/`; the daemon posts
316
317
  `READY #<n> at <current sha> (approved at <approved sha>) for <KEY> (<pr url>)` on the Dispatch
317
318
  issue and publishes it to the project's merge queue role when one is set. The moment the daemon
318
319
  accepts READY, it submits the merge of that head itself, as the implement App and with GitHub's
@@ -324,8 +325,11 @@ The daemon keeps this order from its fixed table; you start none of its steps:
324
325
  design gate refused is submitted by the daemon once that spec version is approved; nobody is
325
326
  told to submit it. If the merger reports a failed verification,
326
327
  treat it like `pr-blocked`: the merger holds the phase, so tell it to move the issue back with
327
- `request_backward_move`, naming what failed; never bypass. A READY refused because the head
328
- still carries `.legion/<issue>/` goes back to `retro`, so the implementer's retro removes it.
328
+ `request_backward_move`, naming what failed; never bypass. A READY refused
329
+ `READY_UNREVIEWED_CHANGE`, or a merger task with no approved head (`READY_NOT_APPROVED`), goes
330
+ back to `reviewing`, the move's reason naming the code and what it found, so the reviewer runs a
331
+ new round. A READY refused because the head still carries `.legion/<issue>/` goes back to
332
+ `retro`, so the implementer's retro removes it.
329
333
  5. the merge lands under the repository's rules; the daemon then starts the **implementer** once
330
334
  more, on the production check.
331
335
  It drives the changed path in production through the user's own access path and records
@@ -403,7 +407,7 @@ active phase worker.
403
407
  | `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. One whose `version` is the version a human's own answer to a decision block wrote, after an earlier approval (Dispatch carried that approval to it, section 1), opens nothing new: carry on, and release no wave because of it. |
404
408
  | `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. |
405
409
  | `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. |
406
- | `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 a conflict (`the head conflicts with <base>`), the required checks that failed (`CI is red at <head>`), or the merge queue's failed merge and its failing run (`the merge queue took pull request #<n> out at READY's 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, or when the merge queue fails the merge) 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. |
410
+ | `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 a conflict (`the head conflicts with <base>`), the required checks that failed (`CI is red at <head>`), or the merge queue's failed merge and its failing run (`the merge queue took pull request #<n> out at READY's 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, or when the merge queue fails the merge) 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` for a READY refusal (`READY_UNREVIEWED_CHANGE`, or `READY_NOT_APPROVED` when its task names no approved head), and the reviewer's no-fix request for changes that follows it so the tester runs the head, are not the work going in circles either. A move that sends back code found wrong counts with the tester fails. |
407
411
  | `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. |
408
412
  | `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. |
409
413
  | `merge-refused` | Its `reason` names the pull request, READY's head, what the daemon asked GitHub to do (merge, enqueue, or enable auto-merge for it), and GitHub's refusal, which a retry would meet again (squash merges disabled, a permission the implement App lacks). The daemon has stopped submitting that merge; the issue stays in `awaiting_merge`. The cause is a repository setting or an App permission, a person's to change: ask one with `dispatch ask`, naming the refusal and the pull request at READY's head, which that person merges under the repository's rules once it is fixed. The merge moves the issue on to its production check. Never bypass a rule. |
@@ -404,7 +404,8 @@ priority first, then board rank ([Keeping the slots full](#keeping-the-slots-ful
404
404
  | `todo on <KEY>` from the Go daemon (payload `{kind: "todo"}`) | an issue not handed to Legion that changed while in `todo` and a slot stood free, sent half a minute later | Verify a free slot, then walk the whole `todo` list ([Keeping the slots full](#keeping-the-slots-full-go-daemon)) |
405
405
  | `tick on <PROJECT>` from the Go daemon (payload `{kind: "tick", openCapabilities?: [<name>, …]}`) | the project key; the daemon's periodic wake, whatever the slots; `openCapabilities` names the deployment capabilities with no decision, each with its detail and `capabilities.decided.<name>` line under `capabilities` in `read_state`, and the live capabilities a running session's check failed, each with the detail naming the session (pod or pane), role, issue and fact, and no `configLine` | Recheck the trees waiting on a claim, then walk if a slot is free — a gap never stops the walk; post the day's report if this is the day's first turn, naming each open capability in it (the operator closes or decides a deployment row; the operator or the issue's architect fixes a live row's session; you take no other action) |
406
406
  | Architect escalation (controller-actionable only: re-file a child as a root issue, capacity, cross-tree conflicts) | request + context | Judge and act; the owning architect writes an issue-design decision as a decision block and opens `dispatch ask` only for a human to-do |
407
- | Mention | Slack/GitHub PR @mention text | Answer, or route to the owning issue's architect role |
407
+ | Mention | GitHub PR @mention text | Answer, or route to the owning issue's architect role |
408
+ | Slack mention (`from: slack`) | the mention's `channel_id`, `user_id`, `text`, `ts`, `thread_ts` | Nothing when your `Slack channels:` line does not name its `channel_id`; otherwise answer in its thread with `slack_post`: a state question from `read_state`, a work request filed as a Dispatch issue, an architect's question routed to that architect ([Slack mentions](skill://legion-controller/references/slack-mentions.md)) |
408
409
  | `held on <KEY>` from the Go daemon (payload `{kind: "held", phase, role?, reason?}`) | the held issue, the phase it left, and the role whose claim failed, or `reason: "escalated"` | Verify the hold in `read_state` (the issue's phase is `held`). Without `reason`, a phase worker's launches or prompts ran out and the tree's architect decides retry or escalate: no action. With `reason: "escalated"` (on the record, `issues.<KEY>.holdReason` is `escalated`), the architect sent it to you: handle it as an architect escalation below. Parking the tree is `set_status` of the root to `backlog`; setting the root back to `todo` later re-admits it as a new generation, which starts again from its architect |
409
410
  | `worker-died on <KEY>` from the Go daemon with `role: "architect"` | the tree root whose architect's claim failed, and the phase the root was in | The tree's architect ran out of launches or prompts and the daemon relaunches nothing; every other notice of the tree goes to that architect, so nobody inside the tree can act. Verify in `read_state` (`issues.<KEY>.architect.state` is `failed`); if the root's phase is `done`, the tree is already parked: no action. Otherwise re-admit the tree (`set_status` of the root to `backlog`, then to `todo`: a new generation, whose architect starts again with fresh budgets) or leave it parked and say why on the issue |
410
411
  | Direct user message | — | Always first |
@@ -482,12 +483,13 @@ judgments.
482
483
 
483
484
  ## Mentions
484
485
 
485
- Read the mention and its artifact. Answer it when it asks the controller for triage or
486
- human-facing information. A human asking how to let a root proceed past its design gate
487
- approves the root issue's spec document in Dispatch — the `Approve` control in the document's
488
- header, or the approval question the architect's request opened in the Inbox. The controller
489
- never opens a gate and there is no operator command for it; a project that does not want the
490
- gate at all runs `gates.design: off` in its `legion.yaml`. Otherwise resolve the authoritative
491
- owning architect role and route the verified context with `envoy_publish`. Do not route raw
492
- event traffic or invent a role token from a partial issue reference.
486
+ Read the mention and its artifact; a Slack mention follows [Slack
487
+ mentions](skill://legion-controller/references/slack-mentions.md). Answer it when it asks the
488
+ controller for triage or human-facing information. A human asking how to let a root proceed past
489
+ its design gate approves the root issue's spec document in Dispatch — the `Approve` control in the
490
+ document's header, or the approval question the architect's request opened in the Inbox. The
491
+ controller never opens a gate and there is no operator command for it; a project that does not
492
+ want the gate at all runs `gates.design: off` in its `legion.yaml`. Otherwise resolve the
493
+ authoritative owning architect role and route the verified context with `envoy_publish`. Do not
494
+ route raw event traffic or invent a role token from a partial issue reference.
493
495
 
@@ -0,0 +1,94 @@
1
+ # Slack mentions
2
+
3
+ A person who mentions the Legion Slack app in one of the project's Slack channels is talking to
4
+ you, and you answer in that thread. The `Slack channels:` line of your system prompt names each
5
+ channel `legion.yaml` gives the project (`projects.<KEY>.slack_channels`). Only the controller the
6
+ daemon launches has one: the Slack bot token is the Legion daemon service's, which the secrets
7
+ broker grants to that service's own pods alone. With no such line the project has no channel, and
8
+ nothing here applies.
9
+
10
+ ## Your session subscribes for you
11
+
12
+ Your session hears each listed channel's mentions by itself, on
13
+ `notifications.slack.*.<channel>.mention`, for as long as it holds the controller role: a resumed or
14
+ relaunched session hears the channels its launch names and no others, and drops every Slack topic an
15
+ earlier session subscribed to by hand. Never `envoy_subscribe` a Slack topic yourself.
16
+
17
+ A delivery from Slack whose `channel_id` the line does not name is not yours: do nothing with it.
18
+
19
+ A mention published while no controller ran never arrives: Envoy hands a session no copy of an
20
+ event from before it subscribed. Nothing tells you about one, so there is nothing to catch up. When
21
+ the project's `slack_channels` change, the daemon relaunches you into this session with the new
22
+ line.
23
+
24
+ ## What a mention carries
25
+
26
+ The delivery's `message` holds Slack's fields: `channel_id`, `user_id` (a Slack user id such as
27
+ `U0123ABCD`), `text` (the mention itself appears in it as `<@U…>`, the app's own id), `ts` (the
28
+ mention's own timestamp), `thread_ts` (the thread's root, only when the mention was posted inside a
29
+ thread), and `bot_id` and `bot_name` when a bot posted it.
30
+
31
+ ## Answer in the thread
32
+
33
+ Every answer is one `slack_post` call:
34
+
35
+ - `channel` is the mention's `channel_id`.
36
+ - `thread_ts` is the mention's `thread_ts`, or its `ts` when it has none. Either keeps the reply in
37
+ the thread the mention opened or joined, never at the channel's top level.
38
+ - `text` is what you say, at most 4,000 characters, written as `skill://dispatch`'s "Writing for
39
+ the human" says. Address the author as `<@user_id>` (by `bot_name` when a bot posted the mention
40
+ with no `user_id`) and give every issue as its key, its title and its Dispatch link exactly as
41
+ `dispatch` printed it (the `Link:` line of `dispatch read`, the link on a `Created` line or a
42
+ search hit): it points at Dispatch's dashboard, and `slack_post` refuses a reply linking an
43
+ address inside the cluster. Never build a link yourself. Say only what Dispatch shows: an issue's
44
+ assignee is the one `dispatch` names, never one you assume.
45
+
46
+ `slack_post` is sent once and never retried. When it answers that Slack did not answer, the reply
47
+ may already be in the thread: do not post it again. When it answers with Slack's refusal, the
48
+ reply is not posted: `not_in_channel` means the app has not been added to that channel, which only
49
+ a person in the workspace can fix, so say so in your next report rather than retrying. When it
50
+ answers that the secrets broker has not granted `LEGION_SLACK_BOT_TOKEN` (its request waits for
51
+ approval, or was denied), nothing was posted and no reply can be until the operator stores the
52
+ token with owner `legion-daemon` and tier `agent`: name the error in your next report.
53
+
54
+ ## Decide what the person wants
55
+
56
+ Read the mention's `text`. When it names an issue key, read the issue with `dispatch read` before
57
+ answering. Then take the first of these that fits.
58
+
59
+ - **A question about state.** "What is Legion working on?", "Where is ACME-12?", "Why is it held?"
60
+ Answer from `read_state` and Dispatch as they stand now, never from memory: each issue's
61
+ `issues.<KEY>.phase`, its pull request, `admission.active` and `admission.waiting`. Say what you
62
+ read, not what you infer.
63
+ - **A request for work.** Someone wants a change made, a bug fixed, something investigated. Search
64
+ first (`skill://dispatch-first`): when an open issue already tracks it, reply with that issue and
65
+ file nothing. Otherwise file one issue in the project:
66
+
67
+ ```text
68
+ dispatch issue --project <PROJECT> --title '<the request, as a title>' --spec-file - <<'EOF'
69
+ ## Summary
70
+
71
+ <what the person asked for, in your words, and why if they said>
72
+
73
+ Asked in Slack by <@user_id> in channel <channel_id>, message <ts>.
74
+ EOF
75
+ ```
76
+
77
+ Add `--label legion` only when the person asks Legion to do the work ("Legion, fix…", "can
78
+ Legion take…"); a request for someone to look at something, or one that does not say who, gets
79
+ no label and waits for a person to hand it over. Never label a request outside the scope the
80
+ deployment instructions state. Reply with the new issue's key, its title, the link and assignee
81
+ the `Created` line names (Dispatch assigns the issue to your token's owner when you name nobody),
82
+ and whether Legion will take it.
83
+ - **Something an issue's architect owns.** A question about a tree's design, a change of direction,
84
+ a decision only the tree's architect can make about an issue Legion runs. Find the tree: follow
85
+ `Links:` up through each `child_of` parent with `dispatch read` until you reach the issue
86
+ `read_state` records with an `architect`. When `issues.<ROOT>.architect.locator.claim` is there,
87
+ `envoy_publish` the person's words to `notifications.role.` followed by it, with the channel id
88
+ and `thread_ts` you reply on, and reply in the thread that you passed it to the architect that
89
+ owns `<ROOT>`, which answers on the issue. When the architect is not running (no locator), say
90
+ that in the thread instead and what `read_state` shows for the tree. Never invent a role token.
91
+ - **Anything else** (a greeting, a question about Legion itself): answer it briefly in the thread.
92
+
93
+ A request to approve a design is answered as the skill's "Mentions" section says: a person
94
+ approves the spec in Dispatch, and you never open a gate.
@@ -26,9 +26,9 @@ retrospective's durable output.
26
26
  issue. Retro writes **no handoff**. The removal exists because a squash merge commits the head
27
27
  merged into the base, and nothing on the base reads a handoff; the handoffs stay on the issue
28
28
  branch below that commit.
29
- 4. The merger verifies the tip is the approved head plus retro's commits, quotes their
30
- `jj diff --from <approved-sha> --to <tip-sha> --summary` in READY, and sends the READY packet
31
- with its completion, which refuses a head that still carries `.legion/<issue>/`; the daemon
29
+ 4. The merger quotes retro's commits (`jj diff --from <approved-sha> --to <tip-sha> --summary`) in
30
+ READY, and sends the READY packet with its completion, which refuses a head with anything above
31
+ the approved head but those commits, or still carrying `.legion/<issue>/`; the daemon
32
32
  posts it on the Dispatch issue and publishes it to the project's merge-queue role when one is
33
33
  configured. On READY's acceptance the daemon submits the merge, and it lands under the
34
34
  repository's rules: its required reviews and checks, and its merge queue when it enables one.
@@ -40,12 +40,15 @@ retrospective's durable output.
40
40
  merge commit. A defect the production check finds becomes a corrective child issue of the same tree,
41
41
  owned by the architect and implemented by the same implementer; the parent stays open until it lands.
42
42
 
43
- Retro's commits sit above the reviewer's approved head and the approval stands: commits that
44
- change only `docs/solutions/`, and the one that removes `.legion/<issue>/`, do not void it, and the
45
- tree goes from retro to the merger — never back to the tester or reviewer. A conflict-forced
46
- rebase after retro moves these commits with the branch. Any round after retro (a conflict round, a
47
- round a red CI sends back, a re-review) writes `.legion/<issue>/` again, and every approval moves
48
- the issue to retro once more: each retro ends with the removal again, or READY is refused.
43
+ Retro's commits sit above the reviewer's approved head and the approval stands: commits that change
44
+ only `docs/solutions/`, and the one that removes `.legion/<issue>/`, do not void it, and the tree
45
+ goes from retro to the merger — never back to the tester or reviewer. A conflict merge pushed above
46
+ the approved head with no review round after it brings the base's paths above that head, so READY
47
+ refuses it (`READY_UNREVIEWED_CHANGE`) and the reviewer reviews the new head as a new round; one the
48
+ conflict procedure sends back through `implementing` is confirmed before READY. Any round after
49
+ retro (a conflict round, a round a red CI sends back, a re-review) writes `.legion/<issue>/` again,
50
+ and every approval moves the issue to retro once more: each retro ends with the removal again, or
51
+ READY is refused.
49
52
 
50
53
  Do not start retro before step 2 or skip it because the change seems mechanical; the merger's
51
54
  `READY` comes only after step 3. The design gate is not a substitute for review and retro.
@@ -204,7 +207,7 @@ workspace stands on, the removal commit your push made, so push before you compl
204
207
  Before returning, verify all of the following:
205
208
 
206
209
  - The reviewer-approved head remains below the retro commits, and the reviewer's approval of that
207
- head stands: the merger accepts the approved head plus the documentation commit and the removal.
210
+ head stands: READY accepts the approved head plus the documentation commit and the removal.
208
211
  - The learning documents and the Dispatch message both exist (never a GitHub issue comment).
209
212
  - The PR body carries what the repository's instructions derive from the pull request's changed
210
213
  paths at the retro head, written back before that head's push, and each line that affirms work
@@ -93,9 +93,9 @@ fingerprint=$(set -o pipefail; jj -R "$LEGION_WORKSPACE" diff --from "fork_point
93
93
 
94
94
  Where each role gets its two heads: the implementer — the tip before and after its own rebase;
95
95
  the tester — the head its `E2E` line names and the new head; the reviewer — the `commit_id` of
96
- its last submitted review (`gh api repos/{owner}/{repo}/pulls/{n}/reviews --jq '.[] | {commit_id, state, user: .user.login}'`)
97
- and the new head; the merger never computes a fingerprint — it uses the `--summary` check in
98
- `skill://legion-worker/references/merge-gate.md`.
96
+ its last submitted review (`gh api repos/{owner}/{repo}/pulls/{n}/reviews --jq '.[] | {commit_id, state, user: .user.login, body}'`, whose body tells a no-fix request that answered a READY refusal from any other)
97
+ and the new head; the merger never computes a fingerprint — it quotes the `--summary` lines
98
+ in READY, and the daemon holds the head to retro's commits.
99
99
 
100
100
  ## Rewriting pushed commits
101
101
 
@@ -12,8 +12,8 @@ the default branch and nothing there reads a handoff, and READY refuses a head t
12
12
  carries them. After the approval, only retro's commits leave it standing on their own: those that
13
13
  change only `docs/solutions/`, and that removal (*Retro*, below). A conflict-forced merge goes back
14
14
  to the reviewer for a confirmation or a new round, as the fingerprint decides (*The reviewer*,
15
- below, and `skill://legion-worker/references/conflicts-and-rewrites.md`), and any other change to
16
- the head voids it.
15
+ below, and `skill://legion-worker/references/conflicts-and-rewrites.md`), unless READY refused
16
+ it, and any other change to the head voids it.
17
17
 
18
18
  ## The reviewer
19
19
 
@@ -71,7 +71,10 @@ the head voids it.
71
71
  and the fingerprint — a confirmation, not a round; no thermo pass, no thread pass. Equal and
72
72
  that review was `COMMENT` or `REQUEST_CHANGES`: continue that round against the new head;
73
73
  nothing restarts. Different, or either run printed no fingerprint: a new round — thermo again,
74
- one review.
74
+ one review. A return whose task's `Reason:` names a READY refusal is never a confirmation, even
75
+ after a rebase, and a no-fix `REQUEST_CHANGES` that answered one is never continued: the round
76
+ after it reviews the refused change in full from the last `APPROVE` (*Retro's commits do not
77
+ void the reviewer's approval* below).
75
78
  - Answer every thread you opened, and every thread a bot opened that is none of Legion's role
76
79
  Apps, as `skill://legion-worker/references/review-threads.md` says; the same reference says
77
80
  when every thread is settled enough to approve, and resolving one never gates your approval.
@@ -95,9 +98,14 @@ reviewing until you finish.
95
98
  head, its `docs/solutions/` learnings and its last commit removing `.legion/<issue>/`
96
99
  (`skill://legion-retro` gives the steps), leave the approval standing, and the tree goes to the
97
100
  merger — never back to the tester or reviewer. Anything else above the approved head does void
98
- it, and the merger tells the architect the head must return to review instead of completing. A
99
- conflict-forced rebase after retro moves those commits with the branch. Every approval moves the
100
- issue to retro again, so each retro ends with the removal again.
101
+ it: READY is refused `READY_UNREVIEWED_CHANGE`, naming the paths, and the merger tells the
102
+ architect, who has it send the head back to review instead of completing, the move's reason
103
+ naming the refusal's code and what it found. A conflict merge pushed above the approved head
104
+ with no review round after it brings the base's paths above that head, so READY refuses it the
105
+ same way; one sent back through `implementing` by the conflict procedure gets its confirmation
106
+ before READY runs. A READY refusal's return reaches the reviewer as a new round, never a
107
+ fingerprint confirmation. Every approval moves the issue to
108
+ retro again, so each retro ends with the removal again.
101
109
  - **Retro brings the PR body's path-derived content up to date before its push.** Whatever the
102
110
  repository's instructions derive from the pull request's changed paths (a checklist named for
103
111
  each class of path, read by a required check), retro recomputes for the whole diff at its head
@@ -108,11 +116,12 @@ reviewing until you finish.
108
116
  ## The merger
109
117
 
110
118
  - The merger resolves no review thread: the reviewer's approval already judged them, and READY
111
- requires no thread state beyond it. It proves the head is the approved head plus retro's commits
112
- with two commands. First `cd -- "$LEGION_WORKSPACE" && jj -R "$LEGION_WORKSPACE" git fetch && jj -R
113
- "$LEGION_WORKSPACE" diff --from <approved-sha> --to <tip-sha> --summary`, whose output is quoted
114
- in READY; then the same with the one fileset `'~(docs/solutions | .legion/<issue>)'` appended,
115
- which must print nothing (jj unions separate path arguments, so two of them leave nothing out).
119
+ requires no thread state beyond it. It quotes what sits above the approved head with
120
+ `cd -- "$LEGION_WORKSPACE" && jj -R "$LEGION_WORKSPACE" git fetch && jj -R
121
+ "$LEGION_WORKSPACE" diff --from <approved-sha> --to <tip-sha> --summary` in READY; the daemon,
122
+ not the merger, holds those commits to retro's: READY reads GitHub's compare of the round's
123
+ approved head with READY's head and refuses any other path (`READY_UNREVIEWED_CHANGE`), and a
124
+ round with no recorded approval (`READY_NOT_APPROVED`).
116
125
  *The READY packet* is
117
126
  `READY #<n> at <current sha> (approved at <approved sha>) for <KEY> (<pr url>)`
118
127
  (the shape `packages/daemon/internal/prompts/roles/merger.md` defines), then the PR body's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion",
3
- "version": "10.4.1",
3
+ "version": "10.5.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "omp": {
@@ -12,7 +12,7 @@
12
12
  ]
13
13
  },
14
14
  "legion": {
15
- "daemonApiVersion": 28
15
+ "daemonApiVersion": 29
16
16
  },
17
17
  "repository": {
18
18
  "type": "git",