@sjawhar/opencode-legion-envoy 8.5.0 → 8.6.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/dispatch.js CHANGED
@@ -16577,14 +16577,14 @@ function toolSchema(tool) {
16577
16577
  toolSchemas.set(tool, schema);
16578
16578
  return schema;
16579
16579
  }
16580
- async function resolveOwnerArguments(tool, input, cwd, env, exec, serverUrl, problems) {
16580
+ async function resolveOwnerArguments(tool, input, cwd, env, exec, dashboardUrls, problems) {
16581
16581
  if (issueFreeTools[tool] === true)
16582
16582
  return { args: input, ref: null, owner: null };
16583
16583
  const refArgument = input.ref;
16584
16584
  let ref = null;
16585
16585
  let args = input;
16586
16586
  if (typeof refArgument === "string") {
16587
- const refText = dispatchRefFromUrl(refArgument, serverUrl) ?? refArgument;
16587
+ const refText = dashboardUrls.map((dashboard) => dispatchRefFromUrl(refArgument, dashboard)).find((ref2) => ref2 !== undefined) ?? refArgument;
16588
16588
  if (refText !== refArgument)
16589
16589
  args = { ...input, ref: refText };
16590
16590
  const agentArtifact = parseAgentArtifactRef(refText);
@@ -16846,7 +16846,7 @@ function progressText(progress, sep) {
16846
16846
  }
16847
16847
  return parts.join(sep);
16848
16848
  }
16849
- function issueSummary(issue2, events, references, graph, titles) {
16849
+ function issueSummary(issue2, events, references, graph, linkBase, titles) {
16850
16850
  const asks = issue2.open_asks;
16851
16851
  const spec = issue2.artifacts?.find((artifact) => artifact.primary);
16852
16852
  const specApproval = spec === undefined ? undefined : approvalLine(spec);
@@ -16864,6 +16864,7 @@ function issueSummary(issue2, events, references, graph, titles) {
16864
16864
  return [
16865
16865
  `Title: ${issue2.title}`,
16866
16866
  `Key: ${issue2.key}`,
16867
+ `Link: ${new URL(`/issues/${encodeURIComponent(issue2.key)}`, linkBase).toString()}`,
16867
16868
  `Status: ${issue2.status}`,
16868
16869
  `Assignee: ${issue2.assignee ?? "unassigned"}`,
16869
16870
  `Claimed by: ${issue2.claim === null ? "nobody" : claimText(issue2.claim, titles)}`,
@@ -17328,6 +17329,7 @@ function withAccount(error48, account) {
17328
17329
  }
17329
17330
  async function executeDispatchTool(input) {
17330
17331
  const { url: configUrl, token: configToken } = input.config;
17332
+ const linkBase = input.config.publicUrl ?? configUrl;
17331
17333
  const baseFetch = input.fetchImpl ?? fetch;
17332
17334
  const fetchImpl = Object.assign(async (...args2) => {
17333
17335
  try {
@@ -17342,7 +17344,7 @@ async function executeDispatchTool(input) {
17342
17344
  const env = input.env ?? process.env;
17343
17345
  const exec = input.exec ?? defaultExec;
17344
17346
  const problems = [];
17345
- const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec, configUrl, problems);
17347
+ const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec, input.config.publicUrl === undefined ? [configUrl] : [configUrl, input.config.publicUrl], problems);
17346
17348
  const ownerMissing = issueFreeTools[input.tool] !== true && ownerArguments.owner === null;
17347
17349
  const schema = toolSchema(input.tool);
17348
17350
  const parsed = schema.safeParse(ownerArguments.args, { reportInput: true });
@@ -17360,13 +17362,13 @@ async function executeDispatchTool(input) {
17360
17362
  const project = optionalString(ownerArguments.args, "project");
17361
17363
  if (project !== undefined) {
17362
17364
  const response2 = await client2.openAsksForProject(project);
17363
- return { text: formatOpenAsksSummary(response2, configUrl), details: { ...response2 } };
17365
+ return { text: formatOpenAsksSummary(response2, linkBase), details: { ...response2 } };
17364
17366
  }
17365
17367
  const sessionId = input.sessionId?.trim();
17366
17368
  if (!sessionId)
17367
17369
  throw new Error("host session id is required for dispatch open-asks");
17368
17370
  const response = await client2.openAsks(sessionId);
17369
- return { text: formatOpenAsksSummary(response, configUrl), details: { ...response } };
17371
+ return { text: formatOpenAsksSummary(response, linkBase), details: { ...response } };
17370
17372
  }
17371
17373
  if (input.tool === "dispatch_whoami") {
17372
17374
  const sessionId = input.sessionId?.trim();
@@ -17448,11 +17450,13 @@ async function executeDispatchTool(input) {
17448
17450
  isPrimarySpec: spec !== undefined
17449
17451
  }),
17450
17452
  ...componentGuidance === undefined ? [] : [componentGuidance],
17451
- ...renderSuggestions(created.advice?.suggestions, configUrl)
17453
+ ...renderSuggestions(created.advice?.suggestions, linkBase)
17452
17454
  ];
17455
+ const href = new URL(`/issues/${encodeURIComponent(created.key)}`, linkBase).toString();
17456
+ const assigned = created.assignee === undefined ? "" : created.assignee === null ? "; unassigned" : `; assigned to ${created.assignee}`;
17453
17457
  return {
17454
17458
  text: [
17455
- `Created ${created.key}: ${created.title} ${notSubscribed(issueTopic(created.key))}`,
17459
+ `Created ${created.key}: ${created.title} \u2192 ${href}${assigned} ${notSubscribed(issueTopic(created.key))}`,
17456
17460
  ...adviceLines
17457
17461
  ].join(`
17458
17462
  `),
@@ -17470,7 +17474,7 @@ async function executeDispatchTool(input) {
17470
17474
  text: [
17471
17475
  `Not created: "${title}" looks like a duplicate.`,
17472
17476
  ...candidates.map((candidate) => {
17473
- const href = new URL(candidate.href, configUrl).toString();
17477
+ const href = new URL(candidate.href, linkBase).toString();
17474
17478
  return `${candidate.key} [${candidate.status}] ${candidate.title} \u2192 ${href}`;
17475
17479
  }),
17476
17480
  "Reference the existing issue, or run dispatch issue again with --force after reading it."
@@ -17611,7 +17615,7 @@ async function executeDispatchTool(input) {
17611
17615
  ...limit === undefined ? {} : { limit },
17612
17616
  ...offset === undefined ? {} : { offset }
17613
17617
  });
17614
- return searchAnswer(search, query, offset, configUrl);
17618
+ return searchAnswer(search, query, offset, linkBase);
17615
17619
  }
17616
17620
  case "dispatch_issues": {
17617
17621
  const project = stringArg(args, "project");
@@ -17675,7 +17679,7 @@ async function executeDispatchTool(input) {
17675
17679
  } catch (error48) {
17676
17680
  throw refusalWithCode(error48);
17677
17681
  }
17678
- const href = new URL(`/projects/${encodeURIComponent(project.key)}`, configUrl).toString();
17682
+ const href = new URL(`/projects/${encodeURIComponent(project.key)}`, linkBase).toString();
17679
17683
  return {
17680
17684
  text: `Created project ${project.key} (${project.name}) at ${project.created_at}: ${href}`,
17681
17685
  details: { project: project.key, name: project.name, created_at: project.created_at }
@@ -17768,7 +17772,7 @@ async function executeDispatchTool(input) {
17768
17772
  const askOwner = ask.issue_key !== null ? issueTopic(ask.issue_key) : resolved === undefined ? issueTopic(issue2()) : documentTopic(resolved.artifact);
17769
17773
  const adviceLines = [
17770
17774
  ...renderAdvice(input.tool, askOwner.label, ask.advice, {}),
17771
- ...renderSuggestions(ask.advice?.suggestions, configUrl)
17775
+ ...renderSuggestions(ask.advice?.suggestions, linkBase)
17772
17776
  ];
17773
17777
  return {
17774
17778
  text: [
@@ -18249,7 +18253,7 @@ ${trailer.join(`
18249
18253
  ])
18250
18254
  ]);
18251
18255
  return {
18252
- text: issueSummary(read.issue, read.events, references, [...pictures2.lines, ...graph], titles),
18256
+ text: issueSummary(read.issue, read.events, references, [...pictures2.lines, ...graph], linkBase, titles),
18253
18257
  details: { issue: read.issue.key },
18254
18258
  ...showImages(pictures2)
18255
18259
  };
@@ -18987,7 +18991,17 @@ function dispatchSettings(env, config2) {
18987
18991
  }
18988
18992
  const url2 = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
18989
18993
  const token = source.kind === "variable" ? source.token : fileBearer(source, url2, "DISPATCH", env);
18990
- return { enabled: true, url: url2, token };
18994
+ const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
18995
+ const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
18996
+ if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
18997
+ return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
18998
+ }
18999
+ return {
19000
+ enabled: true,
19001
+ url: url2,
19002
+ token,
19003
+ ...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
19004
+ };
18991
19005
  } catch (error48) {
18992
19006
  return offWithError(messageFor(error48));
18993
19007
  }
@@ -14317,6 +14317,8 @@ var DISPATCH_DOCUMENT_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}document.`;
14317
14317
  function agentSubject(session) {
14318
14318
  return `${AGENT_TOPIC_PREFIX}${session}`;
14319
14319
  }
14320
+ // ../contracts/src/slack.ts
14321
+ var SLACK_POST_TEXT_MAX = 4000;
14320
14322
  // ../contracts/src/tool-schema.ts
14321
14323
  function overCapMessage(length, max) {
14322
14324
  return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
@@ -14925,7 +14927,17 @@ function dispatchSettings(env, config2) {
14925
14927
  }
14926
14928
  const url2 = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
14927
14929
  const token = source.kind === "variable" ? source.token : fileBearer(source, url2, "DISPATCH", env);
14928
- return { enabled: true, url: url2, token };
14930
+ const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
14931
+ const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
14932
+ if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
14933
+ return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
14934
+ }
14935
+ return {
14936
+ enabled: true,
14937
+ url: url2,
14938
+ token,
14939
+ ...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
14940
+ };
14929
14941
  } catch (error48) {
14930
14942
  return offWithError(messageFor(error48));
14931
14943
  }
@@ -15129,7 +15141,8 @@ var EnvoyToolOperation = {
15129
15141
  setRole: "setRole",
15130
15142
  getRole: "getRole",
15131
15143
  whoami: "whoami",
15132
- listSessions: "listSessions"
15144
+ listSessions: "listSessions",
15145
+ slackPost: "slackPost"
15133
15146
  };
15134
15147
  var envoyToolSpecs = [
15135
15148
  {
@@ -15200,6 +15213,16 @@ var envoyToolSpecs = [
15200
15213
  title: schema.string().optional()
15201
15214
  }),
15202
15215
  operation: EnvoyToolOperation.listSessions
15216
+ },
15217
+ {
15218
+ name: "slack_post",
15219
+ description: "Reply in a Slack thread as the Legion Slack app, with the bot token the secrets broker grants this session (LEGION_SLACK_BOT_TOKEN); the session never holds it. channel is the Slack channel id (a mention's channel_id); thread_ts is the thread's root ts: the mention's thread_ts, or its own ts when it has none. Answers the new message's ts. A refusal carries Slack's own error code (not_in_channel: the app is not in that channel) or the broker's state (waiting for approval, denied). Sent once and never retried: after 'Slack did not answer' the reply may already be in the thread, so do not post it again.",
15220
+ arguments: (schema) => ({
15221
+ channel: schema.string({ min: 1 }).describe("Slack channel id, such as C0123ABCD."),
15222
+ thread_ts: schema.string({ min: 1 }).describe("The thread's root ts, which keeps the reply in the mention's thread."),
15223
+ text: schema.string({ min: 1, max: SLACK_POST_TEXT_MAX }).describe(`The reply, in Slack mrkdwn, at most ${SLACK_POST_TEXT_MAX} characters.`)
15224
+ }),
15225
+ operation: EnvoyToolOperation.slackPost
15203
15226
  }
15204
15227
  ];
15205
15228
  var envoyToolSchemas = new Map;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "8.5.0",
3
+ "version": "8.6.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/src/server.js",
@@ -57,8 +57,9 @@ dispatch://PROJECT/artifact/<document-ref>/comment/<id>
57
57
  ```
58
58
 
59
59
  A bare UUID or `KEY#seq` is not a reference; the `dispatch://` form is what Dispatch links and records. `dispatch read` also
60
- accepts the dashboard URL of an issue, spec, artifact, ask, comment, or project document on the configured server (it maps to the
61
- `dispatch://` form above), and an ask or comment id may be a unique prefix of at least 8 hex characters; a message id is always the
60
+ accepts the dashboard URL of an issue, spec, artifact, ask, comment, or project document on the configured server, or on its public
61
+ dashboard when `DISPATCH_PUBLIC_URL` names one (it maps to the `dispatch://` form above), and an issue's read names its page
62
+ (`Link:`, on that public dashboard when there is one), the link to hand a person. An ask or comment id may be a unique prefix of at least 8 hex characters; a message id is always the
62
63
  full uuid. A non-uuid id on `GET /asks/{id}`, `/comments/{id}`, or `/issues/{key}/messages/{id}` is a 400 `ASK_ID_INPUT` /
63
64
  `COMMENT_ID_INPUT` / `MESSAGE_ID_INPUT`, never a 500.
64
65
 
@@ -237,6 +237,12 @@ envoy_subscribe([
237
237
  This follows every message and mention in one thread. Channel-level topics end in `.message` or
238
238
  `.mention`; thread timestamps replace dots with underscores.
239
239
 
240
+ Reply in a thread with `slack_post(channel=<channel_id>, thread_ts=<the message's thread_ts, or
241
+ its ts>, text=…)`, which posts as the Legion Slack app with the bot token the secrets broker grants
242
+ the session (`LEGION_SLACK_BOT_TOKEN`). A refusal carries Slack's own error code, or the broker's
243
+ state when it has not granted the token. The post is sent once; when the answer is that Slack did
244
+ not answer, the reply may already be there, so do not post it again.
245
+
240
246
  ## Ghost Wispr
241
247
 
242
248
  Ghost Wispr topics are `notifications.ghostwispr.<session>.<kind>`, where `kind` is
@@ -403,11 +403,12 @@ active phase worker.
403
403
  | `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
404
  | `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
405
  | `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 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. |
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. |
407
407
  | `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
408
  | `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
409
  | `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. |
410
410
  | `merge-stalled` | Its `reason` names the pull request, the state and head GitHub shows (closed, or another head), since when GitHub has shown it, and READY's head, which the record still awaits. GitHub's last change to the pull request is more than 10 minutes old and the record never heard of it, so its webhook never reached the daemon: the daemon has stopped submitting that merge, and the issue stays in `awaiting_merge`. Read the pull request on GitHub. Open at READY's head, GitHub has caught up: a person merges it under the repository's rules, and the merge moves the issue on to its production check. Closed, or at another head the daemon never recorded: ask a person with `dispatch ask`, naming the pull request, what GitHub shows, and READY's head. Never bypass a rule. |
411
+ | `merge-removed` | Its `reason` names who took the pull request out of the merge queue or turned its auto-merge off at READY's head after the daemon submitted it, or says GitHub turned it off on its own (over a repository setting such as a merge method the repository blocks) and names the App that submitted the merge, which GitHub names as the actor of its own disables; then what was removed, GitHub's reason (`manual`, `merge_conflict`, `manually_disabled`, `merge_commit_blocked`, …), when, and READY's head. It was not a failed merge, which comes as a `checks-red` instead. The daemon submits that merge no further, so the removal stands, and the issue stays in `awaiting_merge`. Read the pull request on GitHub. A `merge_conflict` conflicts with the base or with a pull request queued ahead of it: once the conflict is on the base, the daemon's own conflict check sends the issue back to `implementing` by itself. Otherwise ask a person with `dispatch ask` (the one who removed it, when the reason names a person rather than GitHub on its own), naming the pull request at READY's head and the reason; that person merges or queues it again under the repository's rules, and the merge moves the issue on to its production check. Never bypass a rule. |
411
412
  | 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. |
412
413
  | `worker-died` | Its `role` and `phase`. That role's claim failed: its launches or prompts ran out. For a phase worker the daemon holds the issue (phase `held`) and starts nothing more on it. Reassess the work, then decide with `retry_or_escalate`: `retry` when the failure looks agent-specific or transient, `escalate` to hand the held issue to the controller when it looks environmental. |
413
414
  | `held` | Its `role` and `phase` (the phase the issue left), or `phase` with `reason: "escalated"`. Without `reason`, that phase's worker ran out of launches or prompts: the issue is held (phase `held`), the `worker-died` that comes with it is yours to answer with `retry_or_escalate` as that row says, and the controller hears of the hold too. With `reason: "escalated"`, it records your own `escalate`: the controller has the issue now, and you start nothing for it. |
@@ -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.
@@ -137,23 +137,36 @@ reviewing until you finish.
137
137
  - **The daemon submits the merge the moment READY is accepted, unless the answer says a person
138
138
  merges it.** It queues a `ready_merge` outbox row for the head READY named, and the row, as the
139
139
  implement App, reads GitHub and then the record: while the issue still awaits that READY head, a
140
- base with a merge queue gets the pull request enqueued; otherwise a pull request GitHub reports
141
- mergeable now (`CLEAN`, `UNSTABLE` or `HAS_HOOKS`) is squash-merged, and one in a repository that
142
- allows auto-merge is armed. Every request carries READY's head as GitHub's expected head, so
143
- GitHub refuses it once the head has moved. In a repository that allows neither, the row waits,
144
- reading GitHub again on the outbox's backoff, until GitHub reports the pull request mergeable —
145
- a required check finishing, a required review a person gives — and then merges it. An arm or a
146
- queue entry is not the end: the row keeps reading GitHub until GitHub reports the pull request
147
- merged at READY's head, and submits again when the arm or entry is gone while the record still
148
- awaits READY (a disarm sent before READY that GitHub applied late). GitHub's
149
- rules decide each answer: the daemon bypasses none, and a person may merge it first. A refusal a
150
- retry would meet again (squash merges disabled, a permission the App lacks) ends the row and is
151
- told to the architect once (`merge-refused`), with GitHub's message. While the record still
152
- awaits READY's head, GitHub showing the pull request closed or at another head is a webhook not
153
- yet applied, so the row waits for the two to agree. Only once GitHub's last change to the pull
154
- request is more than 10 minutes old, however long the row waited before, does the row end and
155
- tell the architect once (`merge-stalled`). A reopen at READY's head reuses the row and takes it
156
- from a run that read the record before the reopen. Any other failure retries.
140
+ base with a merge queue gets the pull request enqueued once GitHub reports it mergeable now
141
+ (`CLEAN`, `UNSTABLE` or `HAS_HOOKS`); otherwise a pull request GitHub reports mergeable now is
142
+ squash-merged, and one in a repository that allows auto-merge is armed. Every request carries
143
+ READY's head as GitHub's expected head, so GitHub refuses it once the head has moved. On a
144
+ merge-queue base the row never arms auto-merge: while a required check still runs, or anything
145
+ else keeps GitHub from reporting the pull request mergeable, it waits, reading GitHub again on
146
+ the outbox's backoff, and enters the queue once GitHub does. In a repository that allows neither a
147
+ merge queue nor auto-merge, the row waits the same way until GitHub reports the pull request
148
+ mergeable — a required check finishing, a required review a person gives — and then merges it. An
149
+ arm or a queue entry is not the end: the row keeps reading GitHub until GitHub reports the pull
150
+ request merged at READY's head. Once it has seen the arm or entry, it never submits that head
151
+ again after someone else removes it: the merge queue taking the pull request out because the merge
152
+ failed (GitHub's reason `failed_checks`, or an unexplained reason with a check that failed, timed
153
+ out, failed to start, needed action or went stale on the commit the queue built for its
154
+ `merge_group` CI) sends the issue back to `implementing`, the implementer's task naming each
155
+ failing check and its run, as a red head does; any other removal (a person dequeues it,
156
+ cancelling its runs, or turns auto-merge off, GitHub turns it off, the queue finds a conflict)
157
+ ends the row and is told to the architect once
158
+ (`merge-removed`). Two removals are submitted again: the implement App's own disarm sent before
159
+ READY that GitHub applied late (auto-merge `manually_disabled`, or a `manual` dequeue, by that
160
+ App), and auto-merge a close turned off, which the reopen at READY's head undid. GitHub's
161
+ rules decide each answer: the daemon bypasses
162
+ none, and a person may merge it first. A refusal a retry would meet again (squash merges disabled,
163
+ a permission the App lacks) ends the row and is told to the architect once (`merge-refused`), with
164
+ GitHub's message. While the record still awaits READY's head, GitHub showing the pull request
165
+ closed or at another head is a webhook not yet applied, so the row waits for the two to agree.
166
+ Only once GitHub's last change to the pull request is more than 10 minutes old, however long the
167
+ row waited before, does the row end and tell the architect once (`merge-stalled`). A reopen at
168
+ READY's head reuses the row, starts it over as though nothing had been armed, and takes it from a
169
+ run that read the record before the reopen. Any other failure retries.
157
170
  No role runs `gh pr merge`. When the answer's `note` begins `MANUAL_MERGE_REQUIRED:` (the pull
158
171
  request touches a path the project's `merge.by_hand` names), READY stands but the daemon submits
159
172
  nothing: the merger tells the architect the note and the head, and a person merges under the