@sjawhar/opencode-legion-envoy 1.24.0 → 1.26.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.
@@ -13806,7 +13806,7 @@ var dispatchToolSpecs = [
13806
13806
  options: z.array(z.object({
13807
13807
  label: z.string().describe("Selectable option label."),
13808
13808
  description: z.string().describe("Optional option context.").optional()
13809
- }), { max: 8 }).describe("Optional choices, at most 8.").optional(),
13809
+ }), { max: 8 }).describe("Up to 8 choices, each an object { label, description? } (never a bare string).").optional(),
13810
13810
  multiple: z.boolean().describe("Whether multiple choices may be selected.").optional(),
13811
13811
  urgency: z.enum(ASK_URGENCIES).describe("Optional decision urgency.").optional(),
13812
13812
  anchor: z.object({
@@ -13858,7 +13858,7 @@ var dispatchToolSpecs = [
13858
13858
  quote: z.string().describe("Optional exact quoted document text.").optional(),
13859
13859
  occurrence: z.number({ int: true, min: 0 }).describe("Optional zero-based occurrence of quote.").optional(),
13860
13860
  body: z.string({ max: 2000 }).describe("Review comment, at most 2,000 characters."),
13861
- reply_to: z.string().describe("Full id of a comment to reply to; replying to any comment in a thread continues that " + "thread (an ask's clarification thread included).").optional(),
13861
+ reply_to: z.string().describe("A comment id (uuid); replying to any comment in a thread continues that thread (an " + "ask's clarification thread included). To reply to an ask, use reply_to_ask with the " + "ask id instead.").optional(),
13862
13862
  reply_to_ask: z.string().describe("Optional ask id to reply to, threading this comment under that question. Mutually " + "exclusive with reply_to.").optional(),
13863
13863
  turn: z.enum(["agent", "human"]).describe("Only with reply_to_ask: who holds the turn after this reply. agent: a progress note - " + "you keep the turn and the ask stays 'Waiting on agents' for the human; human (default): " + "you need the human to act - the ask returns to 'Waiting on you'.").optional()
13864
13864
  }),
@@ -13890,7 +13890,7 @@ var dispatchToolSpecs = [
13890
13890
  },
13891
13891
  {
13892
13892
  name: "dispatch_doc_edit",
13893
- description: "Apply deterministic document edits, including retyping an identified paragraph into a schema-declared typed block. " + "Do not use it for review feedback or for reading; use dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. " + "For replace, delete, and quote insert anchors, find text exactly as rendered: omit Markdown markers such as backticks or asterisks. " + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_GUIDANCE}`,
13893
+ description: "Apply deterministic document edits, including retyping an identified paragraph into a schema-declared typed block. " + "Do not use it for review feedback or for reading; use dispatch_comment, dispatch_suggest, or dispatch_doc_read instead. " + 'For replace, delete, and quote insert anchors, find text as rendered: inline Markdown (**bold**, `code`) is tolerated; a leading \'# \' matches a heading. Insert anchors also accept "start", "end", and "heading:<exact heading text>". ' + `The spec (or any document) holds requirements, design, and decisions - never progress, status, or timestamps. ${OWNER_REFERENCE} ${SPEC_WRITING_GUIDANCE}`,
13894
13894
  arguments: (z) => ({
13895
13895
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
13896
13896
  project: z.string().describe("Project key owning the document.").optional(),
@@ -13898,12 +13898,12 @@ var dispatchToolSpecs = [
13898
13898
  ref: z.string().describe("Optional dispatch:// issue or document reference.").optional(),
13899
13899
  ops: z.array(z.object({
13900
13900
  op: z.enum(DOC_EDIT_OPS).describe("Edit operation."),
13901
- find: z.string().describe("Text to find for replace or delete.").optional(),
13901
+ find: z.string().describe("Text of the target block as rendered, for replace or delete; inline markdown (**bold**, `code`) is tolerated; a leading '# ' matches a heading.").optional(),
13902
13902
  with: z.string().describe("Replacement text for replace.").optional(),
13903
13903
  occurrence: z.number({ int: true, min: 0 }).describe("Optional zero-based match occurrence.").optional(),
13904
13904
  markdown: z.string().describe("Markdown to insert.").optional(),
13905
- after: z.string().describe("Anchor after which to insert.").optional(),
13906
- before: z.string().describe("Anchor before which to insert.").optional(),
13905
+ after: z.string().describe(`Insert after this anchor: a quote of the neighbouring block's text, or one of "start", "end", "heading:<exact heading text>".`).optional(),
13906
+ before: z.string().describe(`Insert before this anchor: a quote of the neighbouring block's text, or one of "start", "end", "heading:<exact heading text>".`).optional(),
13907
13907
  block: z.string().describe("Block id to retype.").optional(),
13908
13908
  type: z.string().describe("Typed block name for retype.").optional(),
13909
13909
  attributes: z.unknown().describe("Typed block attributes for retype.").optional()
@@ -14234,7 +14234,7 @@ var LegionDaemonApi = {
14234
14234
  queue: array(nonEmptyString)
14235
14235
  }),
14236
14236
  gates: record(string2(), stateGate),
14237
- controllerLocator: stateLocator.optional(),
14237
+ controllerLocator: stateTreeLocator.optional(),
14238
14238
  roles: record(string2(), stateRole),
14239
14239
  controllerPendingNotices: number2().int().nonnegative(),
14240
14240
  pendingStatusWrites: array(nonEmptyString),
@@ -14242,7 +14242,11 @@ var LegionDaemonApi = {
14242
14242
  })
14243
14243
  },
14244
14244
  ControllerReady: {
14245
- request: strictObject({ secret: nonEmptyString, sessionId: nonEmptyString }),
14245
+ request: strictObject({
14246
+ secret: nonEmptyString,
14247
+ sessionId: nonEmptyString,
14248
+ ompSessionFile: nonEmptyString.optional()
14249
+ }),
14246
14250
  response: object({})
14247
14251
  },
14248
14252
  ProcessStarted: {
@@ -14362,17 +14366,23 @@ var LegionDaemonApi = {
14362
14366
  response: object({})
14363
14367
  },
14364
14368
  Grant: {
14365
- request: strictObject({
14366
- tree: nonEmptyString,
14367
- issue: nonEmptyString,
14368
- sessionId: nonEmptyString,
14369
- secret: nonEmptyString
14370
- }),
14369
+ request: union([
14370
+ strictObject({
14371
+ sessionId: nonEmptyString,
14372
+ secret: nonEmptyString,
14373
+ tree: nonEmptyString,
14374
+ issue: nonEmptyString
14375
+ }),
14376
+ strictObject({ sessionId: nonEmptyString, secret: nonEmptyString })
14377
+ ]),
14371
14378
  response: object({ grantId: nonEmptyString, expiresAt: nonEmptyString })
14372
14379
  },
14373
14380
  GitHubToken: {
14374
- request: strictObject({ grantId: nonEmptyString }),
14381
+ request: strictObject({ grantId: nonEmptyString, merge: literal(true).optional() }),
14375
14382
  response: object({ token: nonEmptyString, appLogin: string2().endsWith("[bot]") })
14383
+ },
14384
+ GitCredential: {
14385
+ request: strictObject({ grantId: nonEmptyString })
14376
14386
  }
14377
14387
  };
14378
14388
  // ../contracts/src/repo.ts
@@ -14380,6 +14390,9 @@ function canonicalRepo(owner, repo) {
14380
14390
  return `${owner.trim().toLowerCase()}/${repo.trim().toLowerCase().replace(/\.git$/, "")}`;
14381
14391
  }
14382
14392
  // ../contracts/src/tool-schema.ts
14393
+ function overCapMessage(length, max) {
14394
+ return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
14395
+ }
14383
14396
  function zodSchemaApi(zod) {
14384
14397
  const api = zod;
14385
14398
  return {
@@ -14387,8 +14400,12 @@ function zodSchemaApi(zod) {
14387
14400
  let schema = api.string();
14388
14401
  if (opts.min !== undefined)
14389
14402
  schema = schema.min(opts.min);
14390
- if (opts.max !== undefined)
14391
- schema = schema.max(opts.max);
14403
+ if (opts.max !== undefined) {
14404
+ const max = opts.max;
14405
+ schema = schema.max(max, {
14406
+ error: (issue) => overCapMessage(typeof issue.input === "string" ? issue.input.length : max + 1, max)
14407
+ });
14408
+ }
14392
14409
  return schema;
14393
14410
  },
14394
14411
  number: (opts = {}) => {
@@ -14587,6 +14604,24 @@ function resolveDispatchConfig(env, options = {}) {
14587
14604
  // ../envoy-client/src/dispatch-execute.ts
14588
14605
  import { resolve as resolvePath } from "path";
14589
14606
 
14607
+ // ../envoy-client/src/ask-answer.ts
14608
+ var HEAD_LENGTH = 120;
14609
+ function textHead(text, limit = HEAD_LENGTH) {
14610
+ const flat = text.replace(/\s+/g, " ").trim();
14611
+ return flat.length > limit ? `${flat.slice(0, limit)}\u2026` : flat;
14612
+ }
14613
+ function askAnswerText(answer) {
14614
+ if (answer === null || answer === undefined)
14615
+ return "";
14616
+ const selected = (answer.selected ?? []).join(", ");
14617
+ const text = answer.text ?? "";
14618
+ if (text === "")
14619
+ return selected;
14620
+ if (selected === "")
14621
+ return text;
14622
+ return `${selected} - ${text}`;
14623
+ }
14624
+
14590
14625
  // ../envoy-client/src/dispatch-cwd.ts
14591
14626
  import { execFile } from "child_process";
14592
14627
  import { promisify } from "util";
@@ -14768,6 +14803,9 @@ class DispatchClient {
14768
14803
  async comment(issue, input) {
14769
14804
  return this.#json("POST", ["api", "v1", "issues", await this.#resolveIssue(issue), "comments"], input);
14770
14805
  }
14806
+ async listIssueAsks(issue, state) {
14807
+ return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue), "asks"], undefined, state === undefined ? undefined : { state });
14808
+ }
14771
14809
  async getArtifactAsks(id, state) {
14772
14810
  return this.#json("GET", ["api", "v1", "artifacts", id, "asks"], undefined, state === undefined ? undefined : { state });
14773
14811
  }
@@ -14966,6 +15004,116 @@ class DispatchClient {
14966
15004
  }
14967
15005
  }
14968
15006
 
15007
+ // ../envoy-client/src/tool-input-errors.ts
15008
+ class ToolInputError extends Error {
15009
+ tool;
15010
+ problems;
15011
+ constructor(tool, problems) {
15012
+ const count = problems.length;
15013
+ super([
15014
+ `${tool} was not called: ${count} problem${count === 1 ? "" : "s"}`,
15015
+ ...problems.map((problem) => `- ${problem}`)
15016
+ ].join(`
15017
+ `));
15018
+ this.name = "ToolInputError";
15019
+ this.tool = tool;
15020
+ this.problems = problems;
15021
+ }
15022
+ }
15023
+ function unwrap(schema) {
15024
+ let current = schema;
15025
+ while (current !== undefined) {
15026
+ const def = current.def;
15027
+ if (!("innerType" in def) || !(def.type === "optional" || def.type === "nullable" || def.type === "default"))
15028
+ break;
15029
+ current = def.innerType;
15030
+ }
15031
+ return current;
15032
+ }
15033
+ function shapeOf(schema) {
15034
+ const unwrapped = unwrap(schema);
15035
+ if (unwrapped === undefined)
15036
+ return;
15037
+ const def = unwrapped.def;
15038
+ return def.type === "object" && "shape" in def ? def.shape : undefined;
15039
+ }
15040
+ function schemaAt(schema, path) {
15041
+ let current = schema;
15042
+ for (const key of path) {
15043
+ const unwrapped = unwrap(current);
15044
+ if (unwrapped === undefined)
15045
+ return;
15046
+ const def = unwrapped.def;
15047
+ if (def.type === "object" && "shape" in def) {
15048
+ current = shapeOf(unwrapped)?.[String(key)];
15049
+ } else if (def.type === "array" && "element" in def) {
15050
+ current = def.element;
15051
+ } else {
15052
+ return;
15053
+ }
15054
+ }
15055
+ return unwrap(current);
15056
+ }
15057
+ function describeInput(value) {
15058
+ if (value === null)
15059
+ return "null";
15060
+ if (Array.isArray(value))
15061
+ return "an array";
15062
+ switch (typeof value) {
15063
+ case "number":
15064
+ case "boolean":
15065
+ return String(value);
15066
+ case "string":
15067
+ return "a string";
15068
+ case "object":
15069
+ return "an object";
15070
+ default:
15071
+ return typeof value;
15072
+ }
15073
+ }
15074
+ function describeExpected(expected, schema) {
15075
+ switch (expected) {
15076
+ case "int":
15077
+ return "an integer";
15078
+ case "object": {
15079
+ const shape = shapeOf(schema);
15080
+ const keys = shape === undefined ? [] : Object.entries(shape).map(([key, field]) => `${key}${field.def.type === "optional" ? "?" : ""}`);
15081
+ return keys.length === 0 ? "an object" : `an object {${keys.join(", ")}}`;
15082
+ }
15083
+ case "array":
15084
+ return "an array";
15085
+ default:
15086
+ return `a ${expected}`;
15087
+ }
15088
+ }
15089
+ function formatZodIssues(issues, schema) {
15090
+ const allowed = Object.keys(shapeOf(schema) ?? {}).join(", ");
15091
+ return issues.flatMap((issue) => {
15092
+ const path = issue.path.map(String).join(".");
15093
+ switch (issue.code) {
15094
+ case "invalid_type":
15095
+ return issue.input === undefined ? [`${path} is required (${issue.expected})`] : [
15096
+ `${path} must be ${describeExpected(issue.expected, schemaAt(schema, issue.path))}, not ${describeInput(issue.input)}`
15097
+ ];
15098
+ case "unrecognized_keys":
15099
+ return issue.keys.map((key) => `unknown field "${key}"; allowed: ${allowed}`);
15100
+ case "invalid_value":
15101
+ return [
15102
+ `${path} must be one of ${issue.values.map(String).join("|")}; got ${JSON.stringify(issue.input)}`
15103
+ ];
15104
+ case "too_big":
15105
+ if (issue.origin === "array" && Array.isArray(issue.input)) {
15106
+ return [`${path} has ${issue.input.length} items; the limit is ${issue.maximum}`];
15107
+ }
15108
+ return [`${path} ${issue.message}`];
15109
+ case "custom":
15110
+ return [issue.message];
15111
+ default:
15112
+ return [path === "" ? issue.message : `${path}: ${issue.message}`];
15113
+ }
15114
+ });
15115
+ }
15116
+
14969
15117
  // ../envoy-client/src/dispatch-execute.ts
14970
15118
  function documentResultDetails(artifact) {
14971
15119
  return {
@@ -15060,23 +15208,19 @@ function askUrgency(args) {
15060
15208
  return ASK_URGENCIES.find((urgency) => urgency === value);
15061
15209
  }
15062
15210
  function askKind(args) {
15063
- const value = optionalString(args, "kind");
15064
- if (value === undefined || value === "action")
15065
- return value;
15066
- throw new Error("kind must be action");
15211
+ return optionalString(args, "kind") === "action" ? "action" : undefined;
15067
15212
  }
15068
- function askQuestionWithRef(args) {
15069
- const question = stringArg(args, "question");
15070
- const ref = optionalString(args, "ref");
15071
- if (ref === undefined || question.includes(ref))
15072
- return question;
15073
- const withRef = `${question}
15213
+ var maxAskQuestion16 = 800;
15214
+ function questionWithRef(question, ref) {
15215
+ return ref === undefined || question.includes(ref) ? question : `${question}
15074
15216
 
15075
15217
  Ref: ${ref}`;
15076
- if (withRef.length > 800) {
15077
- throw new Error("question plus ref must be at most 800 characters");
15078
- }
15079
- return withRef;
15218
+ }
15219
+ function askQuestionWithRef(args) {
15220
+ return questionWithRef(stringArg(args, "question"), optionalString(args, "ref"));
15221
+ }
15222
+ function askQuestionProblem(withRef) {
15223
+ return withRef.length > maxAskQuestion16 ? `question plus ref ${overCapMessage(withRef.length, maxAskQuestion16)}; shorten the question or drop the ref` : undefined;
15080
15224
  }
15081
15225
  function parseDispatchRef(ref) {
15082
15226
  const projectDocument = ref.match(/^dispatch:\/\/([A-Z][A-Z0-9]{1,9})\/artifact\/([^/@]+)(?:@v(\d+))?(?:\/(ask|comment)\/([^/]+))?$/);
@@ -15098,7 +15242,8 @@ function parseDispatchRef(ref) {
15098
15242
  return {
15099
15243
  owner: { kind: "project", project },
15100
15244
  kind: targetKind,
15101
- id: targetID
15245
+ id: targetID,
15246
+ artifact
15102
15247
  };
15103
15248
  }
15104
15249
  const issueReference = ref.match(/^dispatch:\/\/([A-Z][A-Z0-9]{1,9}-[1-9][0-9]*)(?:\/(spec)|\/(log)|\/(children)|\/artifact\/([^/@]+)(?:@v(\d+))?|\/ask\/([^/]+)|\/comment\/([^/]+)|\/message\/([^/]+))?$/);
@@ -15130,60 +15275,171 @@ function parseDispatchRef(ref) {
15130
15275
  return { owner, kind: "message", id: message };
15131
15276
  return { owner, kind: "issue", id: issue };
15132
15277
  }
15278
+ var uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15279
+ var askIdProblem = "ask must be a bare ask id or a dispatch://.../ask/<id> reference";
15280
+ var messageIdProblem = "in_reply_to must be a full message id (uuid) or a dispatch://KEY/message/<id> reference";
15133
15281
  function askId(args) {
15134
15282
  const ask = stringArg(args, "ask");
15135
- if (!ask.startsWith("dispatch://"))
15136
- return ask;
15137
- const reference = parseDispatchRef(ask);
15138
- if (reference?.kind !== "ask") {
15139
- throw new Error("ask must be a bare ask id or a dispatch://.../ask/<id> reference");
15283
+ return ask.startsWith("dispatch://") ? parseDispatchRef(ask)?.id ?? ask : ask;
15284
+ }
15285
+ function messageIdOf(value) {
15286
+ const id = value.startsWith("dispatch://") ? (() => {
15287
+ const reference = parseDispatchRef(value);
15288
+ return reference?.kind === "message" ? reference.id : undefined;
15289
+ })() : value;
15290
+ return id !== undefined && uuidPattern.test(id) ? id : undefined;
15291
+ }
15292
+ var idPrefixPattern = /^[0-9a-f][0-9a-f-]{7,}$/i;
15293
+ async function resolveIdPrefix(tool, kind, ref, list) {
15294
+ if (uuidPattern.test(ref.id))
15295
+ return ref.id;
15296
+ const ownerName = ref.owner.kind === "issue" ? ref.owner.issue : `${ref.owner.project}/${ref.artifact}`;
15297
+ if (!idPrefixPattern.test(ref.id)) {
15298
+ throw new ToolInputError(tool, [
15299
+ `${kind} id ${ref.id} must be a full uuid or a prefix of at least 8 hex characters`
15300
+ ]);
15140
15301
  }
15141
- return reference.id;
15302
+ const prefix = ref.id.toLowerCase();
15303
+ const matches = (await list()).filter((item) => item.id.toLowerCase().startsWith(prefix));
15304
+ if (matches.length === 1 && matches[0] !== undefined)
15305
+ return matches[0].id;
15306
+ throw new ToolInputError(tool, [
15307
+ matches.length === 0 ? `${kind} id ${ref.id} matches none of the ${kind}s on ${ownerName}; use the full id` : `${kind} id ${ref.id} matches ${matches.length} ${kind}s on ${ownerName}; use the full id`
15308
+ ]);
15142
15309
  }
15143
15310
  function messageInReplyTo(args) {
15144
15311
  const inReplyTo = optionalString(args, "in_reply_to");
15145
- if (inReplyTo === undefined || !inReplyTo.startsWith("dispatch://"))
15146
- return inReplyTo;
15147
- const reference = parseDispatchRef(inReplyTo);
15148
- if (reference?.kind !== "message") {
15149
- throw new Error("in_reply_to must be a bare message id or a dispatch://.../message/<id> reference");
15312
+ return inReplyTo === undefined ? undefined : messageIdOf(inReplyTo);
15313
+ }
15314
+ function argumentProblems(tool, args) {
15315
+ const problems = [];
15316
+ switch (tool) {
15317
+ case "dispatch_ask": {
15318
+ const question = optionalString(args, "question");
15319
+ const ref = optionalString(args, "ref");
15320
+ if (question !== undefined && ref !== undefined && question.length <= maxAskQuestion16) {
15321
+ const problem = askQuestionProblem(questionWithRef(question, ref));
15322
+ if (problem !== undefined)
15323
+ problems.push(problem);
15324
+ }
15325
+ break;
15326
+ }
15327
+ case "dispatch_edit_ask":
15328
+ case "dispatch_resolve_ask": {
15329
+ const ask = optionalString(args, "ask");
15330
+ if (ask?.startsWith("dispatch://") && parseDispatchRef(ask)?.kind !== "ask") {
15331
+ problems.push(askIdProblem);
15332
+ }
15333
+ break;
15334
+ }
15335
+ case "dispatch_comment": {
15336
+ if (optionalString(args, "quote") !== undefined && optionalString(args, "artifact") === undefined) {
15337
+ problems.push("artifact is required when quote is supplied");
15338
+ }
15339
+ if (optionalString(args, "reply_to") !== undefined && optionalString(args, "reply_to_ask") !== undefined) {
15340
+ problems.push("reply_to and reply_to_ask cannot both be set");
15341
+ }
15342
+ break;
15343
+ }
15344
+ case "dispatch_message": {
15345
+ const inReplyTo = optionalString(args, "in_reply_to");
15346
+ if (inReplyTo !== undefined && messageIdOf(inReplyTo) === undefined) {
15347
+ problems.push(messageIdProblem);
15348
+ }
15349
+ break;
15350
+ }
15150
15351
  }
15151
- return reference.id;
15352
+ return problems;
15152
15353
  }
15354
+ function dispatchRefFromUrl(value, serverUrl) {
15355
+ let url;
15356
+ let origin;
15357
+ try {
15358
+ url = new URL(value);
15359
+ origin = new URL(serverUrl).origin;
15360
+ } catch {
15361
+ return;
15362
+ }
15363
+ if (url.origin !== origin)
15364
+ return;
15365
+ const versionOf = (name) => {
15366
+ const value = url.searchParams.get(name);
15367
+ return value !== null && /^[1-9][0-9]*$/.test(value) ? `@v${value}` : "";
15368
+ };
15369
+ const issuePage = url.pathname.match(/^\/issues\/([A-Z][A-Z0-9]{1,9}-[1-9][0-9]*)(?:\/(spec|log|conversation|children)|\/artifacts\/([^/]+)|\/asks\/([^/]+)|\/comments\/([^/]+)|\/messages\/([^/]+))?\/?$/);
15370
+ if (issuePage) {
15371
+ const [, key, page, artifact, ask, comment, message] = issuePage;
15372
+ const version = versionOf("v");
15373
+ if (page === "spec" && version !== "")
15374
+ return `dispatch://${key}/artifact/spec${version}`;
15375
+ if (page !== undefined)
15376
+ return `dispatch://${key}/${page === "conversation" ? "log" : page}`;
15377
+ if (artifact !== undefined) {
15378
+ return `dispatch://${key}/artifact/${decodeURIComponent(artifact)}${version}`;
15379
+ }
15380
+ if (ask !== undefined)
15381
+ return `dispatch://${key}/ask/${ask}`;
15382
+ if (comment !== undefined)
15383
+ return `dispatch://${key}/comment/${comment}`;
15384
+ if (message !== undefined)
15385
+ return `dispatch://${key}/message/${message}`;
15386
+ return `dispatch://${key}`;
15387
+ }
15388
+ const documentPage = url.pathname.match(/^\/projects\/([A-Z][A-Z0-9]{1,9})\/documents\/([^/]+)\/?$/);
15389
+ if (!documentPage)
15390
+ return;
15391
+ const [, project, slug] = documentPage;
15392
+ const document = `dispatch://${project}/artifact/${decodeURIComponent(slug ?? "")}${versionOf("version")}`;
15393
+ const ask = url.searchParams.get("ask");
15394
+ if (ask !== null)
15395
+ return `${document}/ask/${ask}`;
15396
+ const comment = url.searchParams.get("comment");
15397
+ if (comment !== null)
15398
+ return `${document}/comment/${comment}`;
15399
+ return document;
15400
+ }
15401
+ var refGrammarProblem = "ref must be a valid dispatch:// reference such as dispatch://KEY-1, " + "dispatch://KEY-1/ask/<uuid>, dispatch://KEY-1/comment/<uuid>, " + "dispatch://KEY-1/message/<uuid>, dispatch://KEY-1/artifact/<slug>, or " + "dispatch://PROJECT/artifact/<document-ref> (an artifact id, slug, or filename); " + "a dashboard URL on this Dispatch server is accepted too";
15402
+ var ownerRequiredProblem = "issue is required; supply issue or set LEGION_ISSUE";
15153
15403
  function toolSchema(tool) {
15154
15404
  const spec = dispatchToolSpecs.find((candidate) => candidate.name === tool);
15155
15405
  if (!spec)
15156
15406
  throw new Error(`Unknown Dispatch tool: ${tool}`);
15157
15407
  return dispatchToolSchema(spec, zodSchemaApi(exports_external), { strict: true });
15158
15408
  }
15159
- async function resolveOwnerArguments(tool, args, cwd, env, exec) {
15409
+ async function resolveOwnerArguments(tool, input, cwd, env, exec, serverUrl, problems) {
15160
15410
  if (issueFreeTools[tool] === true)
15161
- return { args, ref: null, owner: null };
15162
- const refArgument = args.ref;
15163
- if (tool === "dispatch_ask" && typeof refArgument === "string" && !refArgument.startsWith("dispatch://")) {
15164
- throw new Error("ref must be a dispatch:// reference");
15165
- }
15166
- const ref = typeof refArgument === "string" ? parseDispatchRef(refArgument) ?? (() => {
15167
- throw new Error("ref must be a valid dispatch:// reference such as dispatch://KEY-1, " + "dispatch://KEY-1/ask/<uuid>, dispatch://KEY-1/comment/<uuid>, " + "dispatch://KEY-1/message/<uuid>, dispatch://KEY-1/artifact/<slug>, or " + "dispatch://PROJECT/artifact/<document-ref> (an artifact id, slug, or filename)");
15168
- })() : null;
15411
+ return { args: input, ref: null, owner: null };
15412
+ const refArgument = input.ref;
15413
+ let ref = null;
15414
+ let args = input;
15415
+ if (typeof refArgument === "string") {
15416
+ const refText = dispatchRefFromUrl(refArgument, serverUrl) ?? refArgument;
15417
+ if (refText !== refArgument)
15418
+ args = { ...input, ref: refText };
15419
+ ref = parseDispatchRef(refText);
15420
+ if (ref === null) {
15421
+ problems.push(tool === "dispatch_ask" && !refText.startsWith("dispatch://") ? "ref must be a dispatch:// reference" : refGrammarProblem);
15422
+ }
15423
+ }
15169
15424
  const issueArgument = args.issue;
15170
15425
  const projectArgument = args.project;
15171
15426
  const artifactArgument = args.artifact;
15172
15427
  const versionArgument = args.version;
15173
15428
  if (issueArgument !== undefined && projectArgument !== undefined) {
15174
- throw new Error("exactly one of issue and project is required");
15429
+ problems.push("exactly one of issue and project is required");
15175
15430
  }
15176
15431
  if (typeof projectArgument === "string") {
15177
15432
  if (!/^[A-Z][A-Z0-9]{1,9}$/.test(projectArgument)) {
15178
- throw new Error("project must be a project key such as CORE");
15433
+ problems.push("project must be a project key such as CORE");
15179
15434
  }
15180
- if (ref?.owner.kind === "project" && (ref.owner.project !== projectArgument || artifactArgument !== undefined && artifactArgument !== ref.id)) {
15181
- throw new Error("project and ref must name the same document");
15435
+ const refDocument = ref?.owner.kind === "project" ? ref.artifact ?? ref.id : undefined;
15436
+ if (ref?.owner.kind === "project" && (ref.owner.project !== projectArgument || artifactArgument !== undefined && artifactArgument !== refDocument)) {
15437
+ problems.push("project and ref must name the same document");
15182
15438
  }
15183
15439
  return {
15184
15440
  args: {
15185
15441
  ...args,
15186
- ...artifactArgument === undefined && ref?.owner.kind === "project" ? { artifact: ref.id } : {},
15442
+ ...artifactArgument === undefined && refDocument !== undefined ? { artifact: refDocument } : {},
15187
15443
  ...versionArgument === undefined && ref?.version !== undefined ? { version: ref.version } : {}
15188
15444
  },
15189
15445
  ref,
@@ -15195,7 +15451,7 @@ async function resolveOwnerArguments(tool, args, cwd, env, exec) {
15195
15451
  args: {
15196
15452
  ...args,
15197
15453
  project: ref.owner.project,
15198
- ...artifactArgument === undefined ? { artifact: ref.id } : {},
15454
+ ...artifactArgument === undefined ? { artifact: ref.artifact ?? ref.id } : {},
15199
15455
  ...versionArgument === undefined && ref.version !== undefined ? { version: ref.version } : {}
15200
15456
  },
15201
15457
  ref,
@@ -15216,20 +15472,25 @@ async function resolveOwnerArguments(tool, args, cwd, env, exec) {
15216
15472
  };
15217
15473
  }
15218
15474
  const legionIssue = env.LEGION_ISSUE;
15219
- if (!legionIssue)
15220
- throw new Error("issue is required; supply issue or set LEGION_ISSUE");
15475
+ if (!legionIssue) {
15476
+ problems.push(ownerRequiredProblem);
15477
+ return { args, ref, owner: null };
15478
+ }
15221
15479
  if (nativeIssueKeyPattern.test(legionIssue) || externalIssueRefPattern.test(legionIssue)) {
15222
15480
  const issue = canonicalExternalIssueRef(legionIssue);
15223
- return { args: { ...args, issue }, ref: null, owner: { kind: "issue", issue } };
15481
+ return { args: { ...args, issue }, ref, owner: { kind: "issue", issue } };
15224
15482
  }
15225
15483
  if (!bareIssueNumberPattern.test(legionIssue)) {
15226
- throw new Error("LEGION_ISSUE must be a native issue key (e.g. LEGION-3), an external owner/repo#n reference, or a bare positive issue number");
15484
+ problems.push("LEGION_ISSUE must be a native issue key (e.g. LEGION-3), an external owner/repo#n reference, or a bare positive issue number");
15485
+ return { args, ref, owner: null };
15227
15486
  }
15228
15487
  const repo = await resolveCwdRepo(cwd, exec);
15229
- if (!repo)
15230
- throw new Error("issue is required; LEGION_ISSUE needs a GitHub repository in cwd");
15488
+ if (!repo) {
15489
+ problems.push("issue is required; LEGION_ISSUE needs a GitHub repository in cwd");
15490
+ return { args, ref, owner: null };
15491
+ }
15231
15492
  const issue = `${repo}#${legionIssue}`;
15232
- return { args: { ...args, issue }, ref: null, owner: { kind: "issue", issue } };
15493
+ return { args: { ...args, issue }, ref, owner: { kind: "issue", issue } };
15233
15494
  }
15234
15495
  async function resolveArtifact(client, owner, artifactReference) {
15235
15496
  if (owner.kind === "project") {
@@ -15260,7 +15521,8 @@ async function resolveArtifact(client, owner, artifactReference) {
15260
15521
  const issue = await client.getIssue(owner.issue);
15261
15522
  const artifact = artifactReference === undefined || artifactReference === "spec" ? issue.artifacts.find((candidate) => candidate.primary || candidate.id === issue.primary_artifact_id) : issue.artifacts.find((candidate) => candidate.id === artifactReference || candidate.slug === artifactReference || candidate.name === artifactReference);
15262
15523
  if (!artifact) {
15263
- throw new Error(`artifact ${artifactReference ?? "spec"} was not found on issue ${issue.key}`);
15524
+ const slugs = issue.artifacts.map((candidate) => `${candidate.slug}${candidate.primary || candidate.id === issue.primary_artifact_id ? " (primary)" : ""}`);
15525
+ throw new Error(`artifact "${artifactReference ?? "spec"}" was not found on issue ${issue.key}; artifacts: ${slugs.length === 0 ? "none" : slugs.join(", ")}`);
15264
15526
  }
15265
15527
  return { owner, issue, artifact };
15266
15528
  }
@@ -15317,7 +15579,7 @@ function issueSummary(issue, events, references) {
15317
15579
  ...typeof references === "string" ? [`- ${references}`] : references.members.length === 0 ? ["- none"] : references.members.map(({ artifact, depth, via }) => `- ${artifact.project}/${artifact.slug} \xB7 depth ${depth} via ${via.kind} ${via.id}`),
15318
15580
  ...typeof references === "string" || !references.truncated ? [] : ["- more references beyond 8 hops"],
15319
15581
  "Events:",
15320
- ...events.length === 0 ? ["- none"] : events.map((event) => `- #${event.seq} ${event.type} \xB7 ${event.actor.kind} ${event.actor.id} \xB7 ${event.created_at}`)
15582
+ ...events.length === 0 ? ["- none"] : events.map(eventLine)
15321
15583
  ].join(`
15322
15584
  `);
15323
15585
  }
@@ -15325,10 +15587,41 @@ function logSummary(issue, events) {
15325
15587
  return [
15326
15588
  `Key: ${issue.key}`,
15327
15589
  "Events:",
15328
- ...events.length === 0 ? ["- none"] : events.map((event) => `- #${event.seq} ${event.type} \xB7 ${event.actor.kind} ${event.actor.id} \xB7 ${event.created_at}`)
15590
+ ...events.length === 0 ? ["- none"] : events.map(eventLine)
15329
15591
  ].join(`
15330
15592
  `);
15331
15593
  }
15594
+ function eventHead(event) {
15595
+ switch (event.type) {
15596
+ case "ask.opened":
15597
+ case "ask.edited":
15598
+ case "ask.resolved":
15599
+ return textHead(event.payload.question);
15600
+ case "ask.answered":
15601
+ return `${textHead(event.payload.question)} -> ${textHead(askAnswerText(event.payload.answer))}`;
15602
+ case "comment.created":
15603
+ case "comment.edited":
15604
+ case "comment.resolved":
15605
+ case "comment.reopened":
15606
+ case "suggestion.accepted":
15607
+ case "suggestion.rejected":
15608
+ case "message.created":
15609
+ case "message.answered":
15610
+ return textHead(event.payload.body);
15611
+ case "artifact.version":
15612
+ return textHead(`${event.payload.name} v${event.payload.version.number}${event.payload.version.summary ? `: ${event.payload.version.summary}` : ""}`);
15613
+ case "issue.created":
15614
+ case "issue.updated":
15615
+ case "issue.closed":
15616
+ return `status ${event.payload.status}`;
15617
+ default:
15618
+ return;
15619
+ }
15620
+ }
15621
+ function eventLine(event) {
15622
+ const head = eventHead(event);
15623
+ return `- #${event.seq} ${event.type} \xB7 ${event.actor.kind} ${event.actor.id} \xB7 ${event.created_at}${head === undefined || head === "" ? "" : ` \xB7 ${head}`}`;
15624
+ }
15332
15625
  function childrenSummary(issue) {
15333
15626
  return [
15334
15627
  `Key: ${issue.key}`,
@@ -15463,19 +15756,29 @@ async function executeDispatchTool(input) {
15463
15756
  throw error;
15464
15757
  }
15465
15758
  }, { preconnect: baseFetch.preconnect });
15759
+ const env = input.env ?? process.env;
15760
+ const exec = input.exec ?? defaultExec;
15761
+ const problems = [];
15762
+ const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec, configUrl, problems);
15763
+ const ownerMissing = issueFreeTools[input.tool] !== true && ownerArguments.owner === null;
15764
+ const schema = toolSchema(input.tool);
15765
+ const parsed = schema.safeParse(ownerArguments.args, { reportInput: true });
15766
+ if (!parsed.success) {
15767
+ const issues = parsed.error.issues.filter((issue) => !ownerMissing || !(issue.code === "invalid_type" && issue.path.length === 1 && issue.path[0] === "issue" && issue.input === undefined || issue.code === "custom" && issue.path.length === 0 && issue.message.startsWith("Exactly one of issue and project is required")));
15768
+ problems.push(...formatZodIssues(issues, schema));
15769
+ }
15770
+ problems.push(...argumentProblems(input.tool, ownerArguments.args));
15771
+ if (problems.length > 0)
15772
+ throw new ToolInputError(input.tool, problems);
15466
15773
  if (input.tool === "dispatch_open_asks") {
15467
15774
  const sessionId = input.sessionId?.trim();
15468
15775
  if (!sessionId)
15469
15776
  throw new Error("host session id is required for dispatch_open_asks");
15470
- toolSchema(input.tool).parse(input.args);
15471
15777
  const client = new DispatchClient(configUrl, configToken, fetchImpl, input.signal);
15472
15778
  const response = await client.openAsks(sessionId);
15473
15779
  return { text: formatOpenAsksSummary(response, configUrl), details: { ...response } };
15474
15780
  }
15475
- const env = input.env ?? process.env;
15476
- const exec = input.exec ?? defaultExec;
15477
- const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec);
15478
- const args = toolSchema(input.tool).parse(ownerArguments.args);
15781
+ const args = parsed.success ? parsed.data : ownerArguments.args;
15479
15782
  const actor = toolActor(await resolveOrigin(env, exec, input.cwd), input);
15480
15783
  const client = new DispatchClient(configUrl, configToken, fetchImpl, input.signal);
15481
15784
  const owner = ownerArguments.owner?.kind === "issue" ? {
@@ -15558,8 +15861,6 @@ async function executeDispatchTool(input) {
15558
15861
  }
15559
15862
  case "dispatch_resolve_ask": {
15560
15863
  const kind = stringArg(args, "kind");
15561
- if (kind !== "retracted" && kind !== "resolved")
15562
- throw new Error("kind must be retracted or resolved");
15563
15864
  const ask = await client.resolveAsk(stringArg(args, "ask"), {
15564
15865
  kind,
15565
15866
  reason: stringArg(args, "reason"),
@@ -15616,24 +15917,12 @@ async function executeDispatchTool(input) {
15616
15917
  }
15617
15918
  case "dispatch_comment": {
15618
15919
  const artifactReference = optionalString(args, "artifact");
15619
- if (optionalString(args, "quote") !== undefined && artifactReference === undefined) {
15620
- throw new Error("artifact is required when quote is supplied");
15621
- }
15622
15920
  const owner = documentOwner();
15623
15921
  const resolved = owner.kind === "project" || artifactReference === undefined ? owner.kind === "project" ? await resolveArtifact(client, owner, artifactReference) : undefined : await resolveArtifact(client, owner, artifactReference);
15624
15922
  const anchored = resolved ? anchor(resolved.artifact, args) : undefined;
15625
15923
  const replyTo = optionalString(args, "reply_to");
15626
15924
  const replyToAsk = optionalString(args, "reply_to_ask");
15627
- if (replyTo !== undefined && replyToAsk !== undefined) {
15628
- throw new Error("reply_to and reply_to_ask cannot both be set");
15629
- }
15630
15925
  const requestedTurn = optionalString(args, "turn");
15631
- if (requestedTurn !== undefined && replyToAsk === undefined) {
15632
- throw new Error("turn requires reply_to_ask");
15633
- }
15634
- if (requestedTurn !== undefined && requestedTurn !== "agent" && requestedTurn !== "human") {
15635
- throw new Error("turn must be agent or human");
15636
- }
15637
15926
  const commentInput = {
15638
15927
  body: stringArg(args, "body"),
15639
15928
  ...anchored === undefined ? {} : { anchor: anchored },
@@ -15787,17 +16076,21 @@ ${trailer.join(`
15787
16076
  }
15788
16077
  case "dispatch_read": {
15789
16078
  if (ownerArguments.ref?.kind === "ask") {
15790
- const askRead = await client.getAsk(ownerArguments.ref.id);
16079
+ const ref = ownerArguments.ref;
16080
+ const id = await resolveIdPrefix(input.tool, "ask", ref, async () => ref.owner.kind === "issue" ? client.listIssueAsks(ref.owner.issue) : client.getArtifactAsks((await resolveArtifact(client, ref.owner, ref.artifact)).artifact.id, "all"));
16081
+ const askRead = await client.getAsk(id);
15791
16082
  return {
15792
16083
  text: askSummary(askRead),
15793
- details: ownerArguments.ref.owner.kind === "project" ? { project: ownerArguments.ref.owner.project } : { issue: ownerArguments.ref.owner.issue }
16084
+ details: ref.owner.kind === "project" ? { project: ref.owner.project } : { issue: ref.owner.issue }
15794
16085
  };
15795
16086
  }
15796
16087
  if (ownerArguments.ref?.kind === "comment") {
15797
- const comment = await client.getComment(ownerArguments.ref.id);
16088
+ const ref = ownerArguments.ref;
16089
+ const id = await resolveIdPrefix(input.tool, "comment", ref, async () => ref.owner.kind === "issue" ? client.getComments(ref.owner.issue) : client.getArtifactComments((await resolveArtifact(client, ref.owner, ref.artifact)).artifact.id));
16090
+ const comment = await client.getComment(id);
15798
16091
  return {
15799
16092
  text: commentSummary(comment),
15800
- details: ownerArguments.ref.owner.kind === "project" ? { project: ownerArguments.ref.owner.project } : { issue: comment.comment.issue_key }
16093
+ details: ref.owner.kind === "project" ? { project: ref.owner.project } : { issue: comment.comment.issue_key }
15801
16094
  };
15802
16095
  }
15803
16096
  if (ownerArguments.ref?.kind === "message") {
@@ -15886,8 +16179,8 @@ function messageMetadataShape(schema) {
15886
16179
  return {
15887
16180
  in_reply_to: schema.string().optional(),
15888
16181
  supersedes: schema.string().optional(),
15889
- urgency: schema.enum(URGENCY_VALUES).optional(),
15890
- expects_reply: schema.enum(EXPECTS_REPLY_VALUES).optional(),
16182
+ urgency: schema.enum(URGENCY_VALUES).describe("low | med | high | blocking").optional(),
16183
+ expects_reply: schema.enum(EXPECTS_REPLY_VALUES).describe("none | optional | required").optional(),
15891
16184
  expires_at: schema.number({ int: true }).optional()
15892
16185
  };
15893
16186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "1.24.0",
3
+ "version": "1.26.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -10,7 +10,9 @@ decide, never a log of your work. The transcript is your scratch pad; progress a
10
10
  goes through a `dispatch_*` tool.
11
11
 
12
12
  The server enforces high signal: an ask question is at most 800 characters with at most eight options; comment and message bodies are at
13
- most 2,000 characters; an artifact is at most 25 MiB. It refuses over-limit input; it never truncates it. GitHub threads and markers no
13
+ most 2,000 characters; an artifact is at most 25 MiB. It refuses over-limit input with the number to trim (`question is 50 characters over
14
+ the 800-character limit (850/800)`); it never truncates it. A tool call with several problems is refused once, every problem listed
15
+ (`<tool> was not called: N problems`), so one corrected call lands. GitHub threads and markers no
14
16
  longer exist.
15
17
 
16
18
  ## Writing for the human
@@ -273,7 +275,8 @@ within one textblock; split changes that span separate blocks into separate oper
273
275
  insert anchor is a quote, `"start"`, `"end"`, or `"heading:Title"`. Ordinary inserts create a sibling block before or after the quote or
274
276
  heading's enclosing document block; `"start"` and `"end"` select the document edges. At a table-cell quote, a body-row fragment (no
275
277
  header or delimiter rows) extends that table before or after the matched row instead; short rows are padded, wider rows are rejected,
276
- and deleting a cell's quoted text removes only that text.
278
+ and deleting a cell's quoted text removes only that text. A `find` or quote anchor tolerates inline Markdown (`**bold**`, `` `code` ``)
279
+ and a leading `# ` selects a heading by its text; a miss names the three nearest blocks so the next quote lands.
277
280
 
278
281
  Use `replace` for inline continuation. Use zero-based `occurrence` for a repeated target; re-read a missing or ambiguous target before
279
282
  retrying. Pass `summary` to name the version when recording a decision.
@@ -400,7 +403,7 @@ It returns `details` `{ issue, topic, message }`. `body` is capped at 2,000 char
400
403
 
401
404
  A human — or any bearer caller over HTTP, such as a test rig — can target the issue message at a
402
405
  live Envoy session or role as **BTW**, **Aside**, or **Steer**. The incoming Dispatch frame names
403
- the issue and includes a `reply_with` instruction; reply on the same open issue with the existing
406
+ the issue and includes a `reply_with` hint (`{ tool, args }`, ready to issue on any host); reply on the same open issue with the existing
404
407
  tool, never a new targeted send:
405
408
 
406
409
  ```ts
@@ -458,7 +461,11 @@ dispatch://PROJECT/artifact/<document-ref>/ask/<id>
458
461
  dispatch://PROJECT/artifact/<document-ref>/comment/<id>
459
462
  ```
460
463
 
461
- A bare UUID or `KEY#seq` is not a reference; the `dispatch://` form is what Dispatch links and records.
464
+ A bare UUID or `KEY#seq` is not a reference; the `dispatch://` form is what Dispatch links and records. `dispatch_read` also
465
+ accepts the dashboard URL of an issue, spec, artifact, ask, comment, or project document on the configured server (it maps to the
466
+ `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
467
+ full uuid. A non-uuid id on `GET /asks/{id}`, `/comments/{id}`, or `/issues/{key}/messages/{id}` is a 400 `ASK_ID_INPUT` /
468
+ `COMMENT_ID_INPUT` / `MESSAGE_ID_INPUT`, never a 500.
462
469
 
463
470
  ## Before / after
464
471
 
@@ -47,8 +47,17 @@ envoy:
47
47
  expects_reply: required
48
48
  re: agent-message-1
49
49
  supersedes: agent-message-0
50
- reply_with: "envoy_send(session_id=\"01a0bbbb-cccc-7ddd-eeee-0123456789ab\", message=\"...\")"
51
- reply_role: "envoy_publish(topic=\"notifications.role.legion-reviewer\", message=\"...\")"
50
+ reply_with:
51
+ tool: envoy_send
52
+ args:
53
+ session_id: 01a0bbbb-cccc-7ddd-eeee-0123456789ab
54
+ in_reply_to: agent-message-2
55
+ message: ...
56
+ reply_role:
57
+ tool: envoy_publish
58
+ args:
59
+ topic: notifications.role.legion-reviewer
60
+ message: ...
52
61
  summary: Deployment needs confirmation.
53
62
  message: "Confirm the listener health check passed.\n\nThen publish the release."
54
63
  note: body names session 01a0cccc-dddd-7eee-ffff-0123456789ab; the sender is 01a0bbbb-cccc-7ddd-eeee-0123456789ab
@@ -62,7 +71,10 @@ envoy:
62
71
  - `by` is the expiry deadline, when the sender supplied one.
63
72
  - `urgency` is the sender's priority classification.
64
73
  - `expects_reply` states whether a reply is `none`, `optional`, or `required`.
65
- - `re` names the delivery this message replies to.
74
+ - `re` names the delivery this message replies to. A Dispatch frame names it as a `dispatch://`
75
+ ref — the ask (`dispatch://KEY/ask/<id>`, or `dispatch://PROJECT/artifact/<slug>/ask/<id>` for a
76
+ document ask) or the message (`dispatch://KEY/message/<id>`) — never the quoted text; the
77
+ question head, when there is one, is `dispatch.question`.
66
78
  - `supersedes` names an earlier delivery this one replaces.
67
79
  - `reply_with` is the direct-reply call for the sender.
68
80
  - `reply_role` is the role-publish reply call when the sender has a role.
@@ -28,9 +28,9 @@ separate coordinator to finish necessary work.
28
28
  asking session.
29
29
  - The daemon spawns each role as its own process with the issue's context already in its
30
30
  environment. Never hand-format a role token: the daemon encodes one as
31
- `legion-<project>-<KEY>-<role>`; for example, project `acme`, issue `LEGION-41`, role
32
- `architect` encodes to `legion-acme-LEGION-41-architect`. Reuse a token you already
33
- hold (your own, or one `spawn_worker` returned) or compute another with the
31
+ `legion-<project>-<key>-<role>` with the issue key lower-cased; for example, project `acme`,
32
+ issue `LEGION-41`, role `architect` encodes to `legion-acme-legion-41-architect`. Reuse a
33
+ token you already hold (your own, or one `spawn_worker` returned) or compute another with the
34
34
  `roleToken` helper from `@legion/contracts` exactly the way the daemon does.
35
35
  - There is no label vocabulary. Dispatch status replaces the board, and the design gate
36
36
  is a human approving the root spec document at a version in Dispatch, requested with
@@ -240,11 +240,18 @@ Preserve this order exactly:
240
240
  commit does not void the approval and never returns the tree to the tester or reviewer;
241
241
  4. the merger verifies the current head is the reviewer-approved head plus only commits that
242
242
  change `docs/solutions/` (`jj diff --from <approved-sha> --to <tip-sha> --summary`, quoted in READY)
243
- and publishes `READY #<n> at <sha>` to `notifications.role.pr-queue`; it never
244
- merges. The merge queue merges under its own authority and the repository's own rules
245
- (branch protection, CODEOWNERS); whether a human must approve first is that repository's
246
- setting, not Legion's, and you never ask for or wait on such an approval.
247
- 5. the merge queue merges; you then `spawn_worker` the **implementer** once more with the
243
+ and publishes `READY #<n> at <current sha> (approved at <approved sha>) for <KEY> (<pr url>)`
244
+ to the project's controller topic (the merge queue; named in its `Legion addressing` line);
245
+ it never merges. The controller verifies the gates against live GitHub — the current head,
246
+ required checks, review threads, mergeability, that only `.legion/` deletions lie between the
247
+ head the `## Verification` block names and the approved sha, that only `docs/solutions/`
248
+ changed between the approved and current shas, and that the block is complete at the head it
249
+ names — and merges the current sha, pinned, under the implement App's identity and the
250
+ repository's own rules (branch protection, CODEOWNERS); it does not check the approval itself,
251
+ and whether a human must approve first is that repository's setting, not Legion's, so you never
252
+ ask for or wait on such an approval. If the controller reports a failed gate to you, treat it
253
+ like `pr-blocked`: fix through the phases, never bypass.
254
+ 5. the controller merges; you then `spawn_worker` the **implementer** once more with the
248
255
  production-check task. It drives the changed path in production through the user's own access
249
256
  path and records what it saw on the pull request and on this issue. Close only after the implementer's production report exists.
250
257
  A defect it finds is a corrective child issue of this tree, not a note on a closed one; a deploy
@@ -306,7 +313,7 @@ active phase worker.
306
313
  | `pr-ready` | Verify the live PR head, green status, and review state. Continue the review/retro/merger order only for that current head. |
307
314
  | `pr-review` | Payload `{type:"pr-review", state, author, body}`. Delivered to whichever role is currently active for the issue, falling back to you when no worker phase is active. Follows the same verdict rule as a reviewer's `phase-complete`: `state: "changes_requested"` sends the implementer back in with the review findings, then tester, then reviewer — never the reviewer again and never retro; that `spawn_worker` returns the issue to `in_progress` on its own (the daemon writes it for a corrective implementer whenever the PR's latest recorded review is changes requested, a human's after approval included), so you set nothing by hand; `state: "approved"` proceeds toward retro (step 5) once the step 6 integration/merge-gate conditions are met. `state: "approved"` on a rebased head whose body names an unchanged fingerprint is that confirmation: proceed to retro if it has not run, otherwise to the merger — never to a second retro or test round. |
308
315
  | `pr-blocked` | Payload `{type:"pr-blocked", pr, attempts}`. `attempts` counts heads pushed onto a red verdict that changed something outside `.legion/` — handoff-only pushes (`.legion/` paths only) never count; a push the daemon cannot classify (a listener without `changed_paths`, a list capped at 100, 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. Assign a focused implementer or corrective child, then return it through testing and review; do not treat the blocked PR as final. |
309
- | `pr-merged` | Payload `{type:"pr-merged", pr, mergeCommitSha}`. The merge queue landed the PR. `spawn_worker` the **implementer** with the production-check task naming that merge commit (it resumes the same agent; a retired role has no live holder, so never `envoy_publish` for this). Its `phase-complete` is what brings you to step 7: verify the record on the pull request and this issue first, then sign off naming it and set the issue `done`. A merge is not the close. |
316
+ | `pr-merged` | Payload `{type:"pr-merged", pr, mergeCommitSha}`. The controller merged the PR. `spawn_worker` the **implementer** with the production-check task naming that merge commit (it resumes the same agent; a retired role has no live holder, so never `envoy_publish` for this). Its `phase-complete` is what brings you to step 7: verify the record on the pull request and this issue first, then sign off naming it and set the issue `done`. A merge is not the close. |
310
317
  | `pr-closed-unmerged` | Decide from current scope whether to reopen the work, send a fresh implementer, or cancel it with a reason. Delegate the repository action to the responsible phase worker and keep ownership. |
311
318
  | `issue-comment` | Interpret the comment in the issue's design context. Answer it, adjust the plan, or relay it via `envoy_publish` to the responsible worker's role token; scope and product decisions remain with you. |
312
319
  | `catchup-overseer` | Verify its child counts and PR verdicts against current artifacts, then resume the applicable lifecycle step. This is a current-state snapshot, not a raw-event replay. A root architect uses `gates[LEGION_TREE].open`: `true` means the root spec is approved and section 2 may continue; `false`, or no `open` key, means section 1 still applies. A resumed sub-architect receives `overseerCatchup(state, LEGION_ISSUE)` for its own subtree: its `gates` intentionally omits the root gate because a child spec is never gated. Do not request or register a gate; resume at section 2. Handle each `phaseCompletions` entry exactly as a `phase-complete` wake, then compare `childCounts[LEGION_ISSUE].open` with `legion state` and Dispatch before deciding the next action. |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: legion-controller
3
- description: Use when handling Legion controller wakes for root-issue triage, backlog admission, architect escalation, resync healing, or human interaction.
3
+ description: Use when handling Legion controller wakes for root-issue triage, backlog admission, architect escalation, resync healing, merge-queue READY handling, or human interaction.
4
4
  ---
5
5
 
6
6
  # Legion Controller
@@ -14,21 +14,52 @@ routes raw events into an architect.
14
14
  The Legion extension claims `legion-<project>-controller` and registers controller readiness
15
15
  with the daemon during session startup. Do not handle a wake unless that startup succeeded.
16
16
 
17
- For an interactive takeover, start OMP with `LEGION_CONTROLLER_SECRET` (or
18
- `LEGION_CONTROLLER_SECRET_FILE`, a path to a file holding it) and `LEGION_DAEMON_URL` in its
19
- environment, then run:
17
+ The daemon runs the controller as an interactive OMP terminal session in its private tmux
18
+ server (the pane runs plain `omp`, not `--mode rpc`, and no `legion worker-shim`; Sami reaches
19
+ it with `tmux -L legion-<project> select-window -t <window id> \; attach -t legion-<project>`,
20
+ the window id being `controllerLocator.tmuxWindowId` in `legion state --json` — every window
21
+ opens detached, so a bare `attach` lands on whichever window is current). Sami may attach and
22
+ type into this session at any time. The pane carries the same credential environment as a
23
+ worker's — `LEGION_GRANT_FILE`, `GH_CONFIG_DIR`, the GitHub token variables emptied; the
24
+ `<state_dir>/worker-bin`-first `PATH` the daemon renders reaches no pane today (tmux drops the
25
+ `-e PATH=` pair at pane creation, LEGION-91), so a bare `gh` is whatever the box has — so
26
+ `legion gh -- <args>` works here exactly as it does for a phase worker (`legion` resolves through
27
+ `<state_dir>/bin`, which the pane inherits from the daemon's own `PATH`):
28
+ before every `bash` call the extension mints a short-lived controller grant and writes it to
29
+ the file `LEGION_GRANT_FILE` names (never into the command text or the tool's `env`), `legion`
30
+ reads it from there, and that grant is the only one the daemon lets merge a pull request.
31
+
32
+ For an interactive takeover from a hand-started OMP session, start OMP with
33
+ `LEGION_CONTROLLER_SECRET` (or `LEGION_CONTROLLER_SECRET_FILE`, a path to a file holding it),
34
+ `LEGION_DAEMON_URL`, `LEGION_STATE_DIR` (the daemon's state directory), and `LEGION_GRANT_FILE`
35
+ (an absolute path to a file only you can read, under a 0700 directory; the extension writes
36
+ each command's grant there and every `bash` call is blocked without it) in its environment. Do
37
+ not set `LEGION_CONTROLLER=1` — that marker is the daemon pane's own, and a session carrying it
38
+ claims at startup and reports its transcript as the pane's. Then run:
20
39
 
21
40
  ```text
22
41
  /legion-claim-controller
23
42
  ```
24
43
 
25
44
  The command resolves the project from daemon state, claims the Envoy role for the current
26
- session, and posts readiness before controller commands can act. It retains the environment
27
- capability for `legion({ op: "set_status", issue, status })`. Never pass a secret as a command argument
28
- or copy it into a transcript. The claim is kept alive automatically afterwards: the Envoy
29
- registration heartbeat re-asserts it and re-posts readiness whenever the listener loses sight of
30
- this session, so `/legion-claim-controller` is the manual override, not a routine step after a
31
- listener restart.
45
+ session, and posts readiness before controller commands can act. From then on this session's
46
+ shell commands are wrapped with a controller grant exactly like the daemon pane's, so
47
+ `legion gh -- <args>` works here; `legion status <KEY> <status>` works too, but through the
48
+ controller secret in this session's environment (`LEGION_CONTROLLER_SECRET` or its `_FILE`),
49
+ not the grant — if it fails, that is the variable to check. The takeover moves the role
50
+ and the daemon's recorded session id to this session; it never replaces the transcript the
51
+ daemon recorded for its own pane, so a later respawn of that pane resumes the pane's own
52
+ conversation, not yours. Never pass a secret as a command argument or copy it into a transcript.
53
+ The claim is kept alive automatically afterwards: the Envoy registration heartbeat re-asserts it
54
+ and re-posts readiness whenever the listener loses sight of this session, so
55
+ `/legion-claim-controller` is the manual override, not a routine step after a listener restart.
56
+
57
+ Two limits of a takeover session. It caches the controller secret it started with: after the
58
+ daemon respawns its own pane the secret rotates, every `bash` call in the takeover session then
59
+ fails with a 403 from the grant mint, and the fix is to start a fresh OMP with the new secret,
60
+ not to retry. And the role does not follow `/new` or `/fork` in a takeover session — without
61
+ `LEGION_CONTROLLER=1` the new session is not a Legion session to the extension — so after either
62
+ command run `/legion-claim-controller` again.
32
63
 
33
64
  This handshake lets the daemon redeliver held controller work. It does not turn the controller
34
65
  into a state holder: daemon state and the Dispatch project remain authoritative.
@@ -59,14 +90,16 @@ override a Sami ruling quoted here.
59
90
 
60
91
  | Wake | Content | Controller action |
61
92
  |---|---|---|
62
- | New issue created in the Dispatch project (`issue.created`, status `triage`; resync heals misses) | issue key + triage context (incl. pre-existing children) | Triage: `legion({ op: "set_status", issue, status: "todo" })` to admit, or set `backlog`/`icebox` to park |
93
+ | New issue created in the Dispatch project (`issue.created`, status `triage`; resync heals misses) | issue key + triage context (incl. pre-existing children) | Triage: `legion status <KEY> todo` to admit, or set `backlog`/`icebox` to park |
63
94
  | Backlog eligibility | slot freed / priority change | Reconsider parked items and move the eligible root to `todo` |
64
95
  | Architect escalation (controller-actionable only: re-file a child as a root issue, capacity, cross-tree conflicts) | request + context | Judge and act; issue-scoped human Q&A goes through `dispatch_ask` from the owning architect, not here |
65
96
  | Resync report | artifact-driven anomaly list (zero-owner trees, untriaged-open, launch-failed, admission-drift) | Verify against fresh state, then heal |
66
97
  | Resync report: `admission-drift` entry | issue key + whether the daemon added it to, or removed it from, its admission list (the detail says which) | No action: the daemon already repaired it in the same run. An issue that reappears in consecutive reports is a live leak — file a LEGION issue on Dispatch with both reports pasted as evidence (never a GitHub issue) |
67
98
  | `child-status` | child key + status transition | Not controller-actionable by default; if the daemon could not route it to the parent's architect role, verify the transition and forward it with `envoy_publish` |
68
99
  | Mention | Slack/GitHub PR @mention text | Answer, or route to the owning issue's architect role |
69
- | Closed-tree activity (comment, review, CI on a closed tree) | issue, root, event summary | Read the artifact; if work should resume, `legion({ op: "set_status", issue: root, status: "todo" })`; otherwise no action — the event is not held or redelivered |
100
+ | READY from a merger (`notifications.role.<controller token>`) | `READY #<n> at <current sha> (approved at <approved sha>) for <KEY> (<pr url>)` + gate facts | Run the Merge queue gates against live GitHub; merge, or report the failed gate to the tree's architect |
101
+ | `pr.<n>.checks` settled on a PR with a pending READY | check rollup for the head | Re-run the Merge queue gates for that READY; merge, report, or keep waiting only if still pending |
102
+ | Closed-tree activity (comment, review, CI on a closed tree) | issue, root, event summary | Read the artifact; if work should resume, `legion status <root> todo`; otherwise no action — the event is not held or redelivered |
70
103
  | Direct user message | — | Always first |
71
104
 
72
105
  ## New issue triage
@@ -77,17 +110,17 @@ override a Sami ruling quoted here.
77
110
  2. If it should run now, admit the root issue:
78
111
 
79
112
  ```text
80
- legion({ op: "set_status", issue: "<issue>", status: "todo" })
113
+ legion status <issue> todo
81
114
  ```
82
115
 
83
116
  3. If it should deliberately wait, move it to a parked status instead of leaving it in
84
117
  `triage`:
85
118
 
86
119
  ```text
87
- legion({ op: "set_status", issue: "<issue>", status: "backlog" })
120
+ legion status <issue> backlog
88
121
  ```
89
122
 
90
- (or `status: "icebox"` for longer-term deferral). Dispatch status is the durable record;
123
+ (or `icebox` for longer-term deferral). Dispatch status is the durable record;
91
124
  there is no separate marker to maintain. Do not triage a system-created child as a root
92
125
  issue.
93
126
 
@@ -95,7 +128,7 @@ override a Sami ruling quoted here.
95
128
 
96
129
  When a slot frees or priority changes, use `legion state --json` and the current Dispatch
97
130
  issue to reconsider parked roots. Admit the selected root with
98
- `legion({ op: "set_status", issue, status: "todo" })`. Moving an item to or from `backlog`/
131
+ `legion status <KEY> todo`. Moving an item to or from `backlog`/
99
132
  `icebox` is a deliberate controller decision, not a no-op.
100
133
 
101
134
  ## Architect escalation
@@ -110,7 +143,7 @@ and the Dispatch issue. If the work belongs in an independent root:
110
143
  1. File a **fresh root issue** with `dispatch_issue({ project, title, spec })` (no `parent`).
111
144
  `project` is the issue key's prefix before `-<n>` (e.g. `LEGSMOKE-3` → `LEGSMOKE`) — not
112
145
  the role-token `<project>` (the daemon's own project, e.g. `acme`), a different string.
113
- 2. Park the child (`legion({ op: "set_status", issue: child, status: "icebox" })`) and leave
146
+ 2. Park the child (`legion status <child> icebox`) and leave
114
147
  a pointer to the new root issue. The controller's capability is `todo`/`backlog`/`icebox`
115
148
  only — only the owning architect or the daemon closes an issue as `done`.
116
149
  3. Admit or deliberately backlog the new root through the normal triage procedure.
@@ -141,3 +174,188 @@ gate at all runs `gates.design: off` in its `legion.yaml`. Otherwise resolve the
141
174
  owning architect role and route the verified context with `envoy_publish`. Do not route raw
142
175
  event traffic or invent a role token from a partial issue reference.
143
176
 
177
+ ## Merge queue
178
+
179
+ The controller is the project's merge queue. A merger reports a pull request ready by
180
+ publishing to the controller topic; the controller re-reads every gate from live GitHub and
181
+ merges, or tells the tree's architect exactly which gate failed. The merger's report is a
182
+ claim, never evidence.
183
+
184
+ **READY message shape.** Defined once in `packages/pi-envoy/roles/merger.md` and mirrored here
185
+ verbatim. The first line is
186
+ `READY #<n> at <current sha> (approved at <approved sha>) for <KEY> (<pr url>)`: the pull
187
+ request number, the sha of the pull request's current head, the sha the reviewer's head-pinned
188
+ approval names, the issue key, and the pull request URL. The rest of the message is the PR
189
+ body's gate facts (the `## Verification` block). You need every field: the URL addresses the
190
+ pull request from this pane's working directory (which is not a checkout), the key finds the
191
+ tree's architect (below), the current sha is the only head you may merge, and the approved sha
192
+ anchors the two path-only compares in gates 5 and 6. The controller never verifies the
193
+ approval itself: whether a review must exist before merge is the repository's own
194
+ branch-protection or CODEOWNERS rule, which GitHub enforces at `pr merge` time and Legion
195
+ neither reads nor writes.
196
+
197
+ **Three shas.** This repository's flow leaves three commits that matter, and they are normally
198
+ all different. The *verified* sha is the head the tester and the reviewer worked at: the
199
+ `## Verification` block's own `CI`, `Thermo`, and `E2E` lines name it, and they must agree. After
200
+ that head is found clean the implementer pushes the `.legion/` handoff deletion and the reviewer
201
+ approves *that* head by name — the *approved* sha, one commit later. Retro then commits its
202
+ `docs/solutions/` learning on top — the *current* sha. READY carries the current and approved
203
+ shas; the verified sha you read from the block. The gates check the block at the verified sha
204
+ and prove, with two compares, that nothing but the `.legion/` deletion lies between verified and
205
+ approved, and nothing but `docs/solutions/` between approved and current.
206
+
207
+ **Gates.** Read them from live GitHub, never from the message or the PR body alone. Every `gh`
208
+ command takes the pull request URL, or `--repo <owner>/<repo>` taken from it, because this
209
+ session's working directory has no git remote to resolve a bare number against:
210
+
211
+ ```text
212
+ legion gh -- pr view <pr url> --json headRefOid,baseRefName,mergeable,body,files
213
+ legion gh -- pr checks <pr url> --required --json name,state,bucket,link
214
+ legion gh -- api repos/<owner>/<repo>/rules/branches/<baseRefName from pr view> --jq '[.[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context]'
215
+ legion gh -- api repos/<owner>/<repo>/branches/<baseRefName from pr view> --jq '.protection.required_status_checks.contexts'
216
+ legion gh -- api graphql -f query='query($owner:String!,$repo:String!,$n:Int!,$after:String){repository(owner:$owner,name:$repo){pullRequest(number:$n){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{isResolved}}}}}' -F owner=<owner> -F repo=<repo> -F n=<n> -F after=<null for the first page>
217
+ legion gh -- api repos/<owner>/<repo>/compare/<verified sha>...<approved sha> --jq '{status, files: [.files[].filename]}'
218
+ legion gh -- api repos/<owner>/<repo>/compare/<approved sha>...<current sha> --jq '{status, files: [.files[].filename]}'
219
+ ```
220
+
221
+ 1. **head**: `headRefOid` equals the `<current sha>` in the READY. Any other head is a different
222
+ pull request as far as this READY is concerned.
223
+ 2. **checks**: `pr checks --required --json …` exits 0 with at least one row, and every row's
224
+ `bucket` is `pass` or `skipping`. That is the only green. The five buckets the CLI emits
225
+ (`gh pr checks --help`): `pass` and `skipping` are green — a job skipped by its `if:` (a
226
+ path-filtered workflow skips the jobs whose paths a pull request does not touch, and GitHub
227
+ treats a skipped job as satisfying a required check);
228
+ `pending` is pending; `fail` and `cancel` never merge (the Flake rule applies to both). With
229
+ `--json` the command exits 0 whenever rows exist, whatever their buckets, so the buckets
230
+ decide, never the exit code. Exit 1 comes only with no rows: `no checks reported on the
231
+ '<branch>' branch` when nothing has reported at the head yet (a freshly pushed head has no
232
+ check runs for a few seconds; a head that conflicts with the base never gets any, but that
233
+ head fails gate 4 — report it, do not subscribe), or `no required checks reported on the
234
+ '<branch>' branch` when checks exist but none is required. Either message is **pending**,
235
+ never green: subscribe to the pull request's `pr.<n>` and `pr.<n>.checks` topics exactly as
236
+ the Pending READY paragraph below says, and re-run the gates on that wake. (Without `--json`
237
+ the CLI exits 8 for pending rows and 1 for a failing row or no rows; you never run it that
238
+ way — the rows are what you read.) Two exceptions, both read from the repository, never from
239
+ the absence of rows:
240
+ - A repository that genuinely requires no checks. Required checks live in two places, and
241
+ both must be empty: the `rules/branches/<baseRefName>` query above (rulesets) returns `[]`
242
+ **and** the `branches/<baseRefName>` query above (the classic branch-protection summary,
243
+ which the implement App can read; the admin endpoint
244
+ `branches/<baseRefName>/protection/required_status_checks` is not readable under your credentials
245
+ and is not used) returns `[]`. Only then does the `no required checks reported` exit let
246
+ this gate hold with no check rows. A repository whose required checks are classic
247
+ protection answers `[]` for rulesets and the check names in the classic summary; `null`
248
+ from the classic query (no `protection` object in the answer) is not `[]` and leaves this
249
+ gate pending.
250
+ - A private repository on GitHub's free plan cannot define required checks at all: the
251
+ rulesets query answers HTTP 403 with a JSON body whose `message` **contains** the phrase
252
+ `make this repository public to enable this feature` (`gh` prints the whole message with
253
+ `(HTTP 403)` appended). Match that phrase as a substring — it is the stable tail; the head
254
+ names the plan (`Upgrade to GitHub Pro` for a user-owned repository, `Upgrade to GitHub
255
+ Team` for an organization-owned one) and the sentence ends with a period inside a JSON
256
+ wrapper, so literal equality never matches. Any other 403 — `Resource not accessible by
257
+ integration` included — is a permission error and stays an error, never "no required
258
+ checks". Under this exception gate 2 requires every check reported on the head to be green
259
+ instead: `legion gh -- pr checks <pr url> --json name,state,bucket,link` (without
260
+ `--required`) exits 0 with at least one row and every row's `bucket` is `pass` or
261
+ `skipping`. A `pending` row is pending, a `fail` or `cancel` row never merges, and no rows
262
+ (the exit-1 `no checks reported`) stays pending exactly as above. This is stricter than
263
+ "no required checks, merge", and GitHub still enforces whatever protection the repository
264
+ does have at `pr merge` time, so a wrong read costs a refused merge reported to the
265
+ architect, never an unprotected one.
266
+ Where each of these reads was observed — the CLI version, the two repositories, the exact
267
+ answers — is recorded in
268
+ `docs/solutions/legion/controller-gate-2-required-checks-live-reads.md`. The rule above is
269
+ what you execute; the live answers are what you read.
270
+ 3. **threads**: zero unresolved review threads across every page. Start with `after: null`, then
271
+ repeat the query with the prior page's `pageInfo.endCursor` until `hasNextPage` is false; the
272
+ count of `isResolved: false` across all pages must be 0. A missing `pageInfo`, a missing cursor
273
+ while `hasNextPage` is true, or any failed page is a failed gate: do not merge. This follows the
274
+ pagination `legion threads resolve` uses, but the controller reads only and never resolves a
275
+ review thread.
276
+ 4. **mergeable**: `mergeable` is not `CONFLICTING` and not `UNKNOWN`.
277
+ 5. **cleanup only**: `compare/<verified sha>...<approved sha>` reports `status` `identical` or
278
+ `ahead`, and every path in `files` starts with `.legion/` — the handoff deletion the reviewer
279
+ directed, and nothing else. Anything else between the two is the failed gate
280
+ `cleanup changed more than .legion`.
281
+ 6. **retro only**: `compare/<approved sha>...<current sha>` reports `status` `identical` or
282
+ `ahead`, and every path in `files` starts with `docs/solutions/`. Anything else between the
283
+ two is the failed gate `head moved beyond retro`: the approval no longer covers the head.
284
+ 7. **verification block**: the PR body's `## Verification` block (the template in
285
+ `skills/legion-worker/SKILL.md`) is complete and current at the verified sha. The tester
286
+ fills the `E2E` line before review; the reviewer writes the `Thermo` line at the head it
287
+ audited; approval lands one commit later on the cleanup head; so the block names the verified
288
+ sha, never the approved or the current one. Line by line: the `CI` line names a run and
289
+ reports success at one sha; the `Thermo` line names the same sha and a verdict, unless the
290
+ pull request is docs-only, in which case the template omits that line entirely — docs-only
291
+ is a fact you read, never one you take from the omission itself: every `path` in the `files`
292
+ list of the `pr view` command above starts with `docs/` or ends with `.md`
293
+ (`--jq '[.files[].path | select((startswith("docs/") or endswith(".md")) | not)]'` is `[]`);
294
+ a missing `Thermo` line on any other pull request fails this gate; the `E2E`
295
+ line names the same sha and has a `Negative control` line — those lines agreeing on one sha
296
+ is what defines the verified sha; the `Threads` line reports `0 unresolved` (its per-thread
297
+ lines name fixing commits, never the head — do not look for a sha there); the `Fast-follow`
298
+ and `Chain` lines are filled in. No `<placeholder>` text remains anywhere in the block.
299
+
300
+ When all seven hold, merge:
301
+ `legion gh -- pr merge <pr url> --squash --match-head-commit <current sha>`. The head pin makes
302
+ GitHub refuse the merge if a push landed after gate 1 read the head; that refusal is a failed
303
+ `head` gate, reported like any other. The grant your `bash` call carries is the controller's
304
+ own, the only grant the daemon honours for a merge; the merge runs under the implement App's
305
+ identity and the repository's own rules (branch protection, CODEOWNERS). Whether a human must
306
+ approve first is that repository's setting — you neither read nor bypass it, and you never
307
+ admin-merge without an explicit deployment grant from Sami for that specific merge.
308
+
309
+ **Failed gate.** Reply to the tree's architect naming the gate (`head`, `checks`, `threads`,
310
+ `mergeable`, `cleanup changed more than .legion`, `head moved beyond retro`, or
311
+ `verification block`) and the evidence you read (the shas, the check name and run link, the
312
+ thread count, the `mergeable` value, the offending paths from the compare). Do not merge, do not
313
+ retry on a timer. The architect fixes through the phases.
314
+
315
+ **Flake.** A required check that failed or was cancelled (`bucket` `fail` or `cancel`) for a
316
+ reason unrelated to the change (a runner outage, a rate limit, a known-flaky job) may be rerun
317
+ once: `legion gh -- run rerun <run-id> --failed --repo <owner>/<repo>`, the run id taken from
318
+ the failing row's `link` (`https://github.com/<owner>/<repo>/actions/runs/<run-id>/job/<job-id>`).
319
+ Then stop. The rerun's result reaches you as a `pr.<n>.checks` wake; re-run the gates then.
320
+ A second failure is a failed gate, reported as above.
321
+
322
+ **Conflicts and unknown mergeability.** `mergeable == CONFLICTING` is the only reason to ask
323
+ for a rebase: reply to the tree's architect asking for one. Never request a rebase for any other
324
+ reason — the CI queue is long and slow, and an unnecessary rebase clogs it for every other pull
325
+ request. `mergeable == UNKNOWN` means GitHub has not finished computing it: do not merge, do
326
+ not poll; re-read on the next `pr.<n>.checks` wake.
327
+
328
+ **Pending READY.** A READY that cannot merge yet only because checks are still running (a
329
+ `pending` row), none has reported at the head yet (gate 2's `no checks reported` exit), a flake
330
+ rerun was issued, or `mergeable` is `UNKNOWN` is pending. Subscribe to that pull request's
331
+ events so its settlement wakes you:
332
+
333
+ ```text
334
+ envoy_subscribe({ topics: ["notifications.github.<owner>.<repo>.pr.<n>", "notifications.github.<owner>.<repo>.pr.<n>.checks"] })
335
+ ```
336
+
337
+ On that wake, re-run the gates against the shas from the READY in your conversation, then
338
+ `envoy_unsubscribe` those topics once you have merged or reported a failed gate. The controller
339
+ never polls; READY and `pr.<n>.checks` are the only wakes. If you were resumed and no longer
340
+ have the READY in your conversation, ask that issue's merger (its token is the `roles` key in
341
+ `legion state --json` whose `issue` is `<KEY>` and whose `role` is `merger`; publish to
342
+ `notifications.role.` followed by that key) to republish it; never guess a sha.
343
+
344
+ **Finding the tree's architect.** Never hand-format a role token: the daemon lower-cases the
345
+ issue key inside it (`LEGION-16` becomes `legion-16`) and rejects any other shape, so a token
346
+ you assemble from `<KEY>` never matches a live role. Read it instead: `legion state --json`
347
+ gives `issues[<KEY>].parent`; follow `parent` until it is absent — that key is the root (the
348
+ `trees` map lists the same roots). Then take the `roles` key whose `issue` equals that root and
349
+ whose `role` is `architect`, and publish to `notifications.role.` followed by that exact key.
350
+ Every registered root architect and phase worker appears in `roles`, so the lookup is
351
+ unambiguous. (Phase workers get an addressing line in their system prompt; the controller does
352
+ not, so state is your only source.)
353
+
354
+ **After a successful merge, publish nothing to the architect.** The daemon derives
355
+ `{type:"pr-merged", pr, mergeCommitSha}` from GitHub's own merged webhook and routes it to the
356
+ tree's architect itself. A second copy from you would make the architect run its sign-off twice.
357
+
358
+ **Policy questions go to Sami.** Whether a pull request should merge at all, whether an admin
359
+ merge is warranted, or a gate that looks wrong for this repository is not a controller judgment:
360
+ ask with `dispatch_ask` on the issue, in plain sentences, and leave the READY pending until the
361
+ answer arrives.
@@ -404,11 +404,15 @@ Verified the implementer's proof by <re-running its command | driving the same s
404
404
  `cd -- "$LEGION_WORKSPACE" && jj -R "$LEGION_WORKSPACE" git fetch && jj -R "$LEGION_WORKSPACE" diff --from <approved-sha> --to <tip-sha> --summary`,
405
405
  whose output is quoted in READY (an empty output is quoted as
406
406
  `no file changes above the approved head`); then the same with `'~docs/solutions'` appended,
407
- which must print nothing. Then it publishes `READY #<n> at <tip-sha>` naming the approved
408
- head, the tip, and that summary, plus the PR body's gate facts, to the merge queue's role
409
- (`notifications.role.pr-queue`) with `envoy_publish`. The READY packet names both the
410
- implementer's and the tester's `E2E` lines; a missing one is reported to the architect instead
411
- of published. The merger never merges; the queue merges under its own authority.
407
+ which must print nothing. Then it publishes
408
+ `READY #<n> at <current sha> (approved at <approved sha>) for <KEY> (<pr url>)` (the shape
409
+ `packages/pi-envoy/roles/merger.md` defines) with that summary and the PR body's gate facts to
410
+ the project's controller topic (the merge queue, named in the `Legion addressing` line at the
411
+ end of the system prompt) with `envoy_publish`; on a 404 no-holder it publishes the same `READY`
412
+ to the architect's topic and stays idle. The READY packet names both the implementer's and the
413
+ tester's `E2E` lines; a missing one is reported to the architect instead of published. The
414
+ merger never merges; the controller verifies the
415
+ gates against live GitHub and merges under its own authority.
412
416
  - **After the queue merges, the implementer verifies in production.** Sami, 2026-09-13,
413
417
  verbatim: "the agent that developed it should be responsible for testing in production."
414
418
  The architect sends the implementer back once the merge lands; the implementer watches the