@sjawhar/opencode-legion-envoy 1.38.0 → 1.39.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/src/server.js +93 -57
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +34 -12
- package/skills/legion-architect/SKILL.md +16 -22
- package/skills/legion-controller/SKILL.md +16 -208
- package/skills/legion-retro/SKILL.md +5 -4
- package/skills/legion-worker/SKILL.md +26 -29
package/dist/src/server.js
CHANGED
|
@@ -13552,6 +13552,7 @@ function date4(params) {
|
|
|
13552
13552
|
// ../../node_modules/.bun/zod@4.3.6/node_modules/zod/v4/classic/external.js
|
|
13553
13553
|
config(en_default());
|
|
13554
13554
|
// ../contracts/src/dispatch-api.ts
|
|
13555
|
+
var ASK_TURNS = ["human", "agent"];
|
|
13555
13556
|
var DELIVERY_CAPABILITIES = ["aside", "btw", "steer"];
|
|
13556
13557
|
var DispatchEventSchema = object({
|
|
13557
13558
|
issue_key: string2().nullable(),
|
|
@@ -13632,8 +13633,8 @@ var CommentEventPayloadSchema = object({
|
|
|
13632
13633
|
ask_id: string2().nullish(),
|
|
13633
13634
|
ask_question: string2().optional(),
|
|
13634
13635
|
ask_state: _enum2(["open", "answered", "resolved"]).optional(),
|
|
13635
|
-
ask_waiting_on: _enum2(
|
|
13636
|
-
turn: _enum2(
|
|
13636
|
+
ask_waiting_on: _enum2(ASK_TURNS).optional(),
|
|
13637
|
+
turn: _enum2(ASK_TURNS).nullish(),
|
|
13637
13638
|
anchor: object({ block_id: string2().nullable().optional(), quote: string2().optional() }).nullish(),
|
|
13638
13639
|
suggestion: object({ replace_with: string2().optional() }).nullish(),
|
|
13639
13640
|
author: object({ kind: string2(), id: string2() }).optional(),
|
|
@@ -13756,16 +13757,14 @@ function documentOwnerValidation(requireArtifact, alwaysRequireArtifact = false)
|
|
|
13756
13757
|
message: alwaysRequireArtifact ? "Exactly one of issue and project is required; artifact or ref must name the document." : "Exactly one of issue and project is required; with project, artifact names the document."
|
|
13757
13758
|
};
|
|
13758
13759
|
}
|
|
13759
|
-
var
|
|
13760
|
-
|
|
13761
|
-
|
|
13762
|
-
|
|
13763
|
-
|
|
13764
|
-
|
|
13765
|
-
|
|
13766
|
-
|
|
13767
|
-
};
|
|
13768
|
-
})();
|
|
13760
|
+
var commentOwner = documentOwnerValidation(true);
|
|
13761
|
+
var commentValidation = {
|
|
13762
|
+
check: (value) => {
|
|
13763
|
+
const input = value;
|
|
13764
|
+
return commentOwner.check(value) && (input.turn === undefined || typeof input.reply_to_ask === "string");
|
|
13765
|
+
},
|
|
13766
|
+
message: `${commentOwner.message} turn requires reply_to_ask.`
|
|
13767
|
+
};
|
|
13769
13768
|
var ISSUE_COMPONENTS_MODES = ["inherit", "explicit", "none"];
|
|
13770
13769
|
function componentsArgument(z) {
|
|
13771
13770
|
return z.object({
|
|
@@ -13787,6 +13786,7 @@ var SPEC_SECTIONS = [
|
|
|
13787
13786
|
];
|
|
13788
13787
|
var SPEC_WRITING_GUIDANCE = `When writing a spec, use these sections in order: ${SPEC_SECTIONS.join(", ")}. ` + "Write for a reader who has not seen the code: plain sentences, every identifier expanded on " + "first use, no coined shorthand; see skills/dispatch Writing for the human and Writing a spec.";
|
|
13789
13788
|
var ASK_URGENCIES = ["low", "med", "high", "blocking"];
|
|
13789
|
+
var ASK_QUESTION_MAX = 800;
|
|
13790
13790
|
var ISSUE_STATUSES = [
|
|
13791
13791
|
"triage",
|
|
13792
13792
|
"icebox",
|
|
@@ -13840,13 +13840,13 @@ var dispatchToolSpecs = [
|
|
|
13840
13840
|
},
|
|
13841
13841
|
{
|
|
13842
13842
|
name: "dispatch_ask",
|
|
13843
|
-
description: "Open a durable, answerable decision on an issue or project document. Do not use it for a status update or discussion; " + "use dispatch_message instead. A to-do a human must complete is a question phrased as that to-do, with the options you want (for example Done / Can't). " + "Anchor a document question, thread reply_to/reply_to_ask, or cite a dispatch:// " +
|
|
13843
|
+
description: "Open a durable, answerable decision on an issue or project document. Do not use it for a status update or discussion; " + "use dispatch_message instead. A to-do a human must complete is a question phrased as that to-do, with the options you want (for example Done / Can't). " + "Anchor a document question, thread reply_to/reply_to_ask, or cite a dispatch:// " + `reference \u2014 it must be answerable from its own text and anchor alone, never "see above". A quote anchor is pinned to its block. Question is at most ${ASK_QUESTION_MAX} ` + `characters and has at most 8 options. ${OWNER_REFERENCE}`,
|
|
13844
13844
|
arguments: (z) => ({
|
|
13845
13845
|
issue: z.string().describe(ISSUE_REFERENCE).optional(),
|
|
13846
13846
|
project: z.string().describe("Project key owning the document.").optional(),
|
|
13847
13847
|
artifact: z.string().describe("Project document artifact id, slug, or filename.").optional(),
|
|
13848
13848
|
ref: z.string().describe("Optional dispatch:// reference (issue, document, message, or ask); appended to the question and rendered as a link.").optional(),
|
|
13849
|
-
question: z.string({ max:
|
|
13849
|
+
question: z.string({ max: ASK_QUESTION_MAX }).describe(`Decision question, at most ${ASK_QUESTION_MAX} characters.`),
|
|
13850
13850
|
options: z.array(z.object({
|
|
13851
13851
|
label: z.string().describe("Selectable option label."),
|
|
13852
13852
|
description: z.string().describe("Optional option context.").optional()
|
|
@@ -13866,7 +13866,7 @@ var dispatchToolSpecs = [
|
|
|
13866
13866
|
description: "Edit an open question in place. Use it to correct or refine the same decision; retract the " + "old ask and open a new one when the decision itself changes. Previous text remains in the " + "event log. Only the asking session can edit it; answered or resolved asks cannot be edited.",
|
|
13867
13867
|
arguments: (z) => ({
|
|
13868
13868
|
ask: z.string().describe("Ask id to edit."),
|
|
13869
|
-
question: z.string({ max:
|
|
13869
|
+
question: z.string({ max: ASK_QUESTION_MAX }).describe(`Replacement decision question, at most ${ASK_QUESTION_MAX} characters.`).optional(),
|
|
13870
13870
|
options: z.array(z.object({
|
|
13871
13871
|
label: z.string().describe("Selectable option label."),
|
|
13872
13872
|
description: z.string().describe("Optional option context.").optional()
|
|
@@ -14472,7 +14472,7 @@ var LegionDaemonApi = {
|
|
|
14472
14472
|
response: object({ grantId: nonEmptyString, expiresAt: nonEmptyString })
|
|
14473
14473
|
},
|
|
14474
14474
|
GitHubToken: {
|
|
14475
|
-
request: strictObject({ grantId: nonEmptyString
|
|
14475
|
+
request: strictObject({ grantId: nonEmptyString }),
|
|
14476
14476
|
response: object({ token: nonEmptyString, appLogin: string2().endsWith("[bot]") })
|
|
14477
14477
|
},
|
|
14478
14478
|
GitCredential: {
|
|
@@ -14700,9 +14700,9 @@ import { resolve as resolvePath } from "path";
|
|
|
14700
14700
|
|
|
14701
14701
|
// ../envoy-client/src/ask-answer.ts
|
|
14702
14702
|
var HEAD_LENGTH = 120;
|
|
14703
|
-
function textHead(text
|
|
14703
|
+
function textHead(text) {
|
|
14704
14704
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
14705
|
-
return flat.length >
|
|
14705
|
+
return flat.length > HEAD_LENGTH ? `${flat.slice(0, HEAD_LENGTH)}\u2026` : flat;
|
|
14706
14706
|
}
|
|
14707
14707
|
function askAnswerText(answer) {
|
|
14708
14708
|
if (answer === null || answer === undefined)
|
|
@@ -15126,6 +15126,29 @@ class DispatchClient {
|
|
|
15126
15126
|
}
|
|
15127
15127
|
}
|
|
15128
15128
|
|
|
15129
|
+
// ../envoy-client/src/dispatch-owner.ts
|
|
15130
|
+
function documentLabel(project, slug) {
|
|
15131
|
+
return `${project}/${slug}`;
|
|
15132
|
+
}
|
|
15133
|
+
function issueTopic(key) {
|
|
15134
|
+
return { label: key, topic: dispatchIssueSubject(key, ">") };
|
|
15135
|
+
}
|
|
15136
|
+
function documentTopicOf(project, slug) {
|
|
15137
|
+
return {
|
|
15138
|
+
label: documentLabel(project, slug),
|
|
15139
|
+
topic: dispatchDocumentSubject(project, slug, ">")
|
|
15140
|
+
};
|
|
15141
|
+
}
|
|
15142
|
+
function dispatchIssueRef(key) {
|
|
15143
|
+
return `dispatch://${key}`;
|
|
15144
|
+
}
|
|
15145
|
+
function dispatchDocumentRef(owner, slug) {
|
|
15146
|
+
return `dispatch://${owner}/artifact/${slug}`;
|
|
15147
|
+
}
|
|
15148
|
+
function dispatchChildRef(ownerRef, kind, id) {
|
|
15149
|
+
return `${ownerRef}/${kind}/${id}`;
|
|
15150
|
+
}
|
|
15151
|
+
|
|
15129
15152
|
// ../envoy-client/src/tool-input-errors.ts
|
|
15130
15153
|
class ToolInputError extends Error {
|
|
15131
15154
|
tool;
|
|
@@ -15237,14 +15260,8 @@ function formatZodIssues(issues, schema) {
|
|
|
15237
15260
|
}
|
|
15238
15261
|
|
|
15239
15262
|
// ../envoy-client/src/dispatch-execute.ts
|
|
15240
|
-
function issueTopic(key) {
|
|
15241
|
-
return { label: key, topic: dispatchIssueSubject(key, ">") };
|
|
15242
|
-
}
|
|
15243
15263
|
function documentTopic(artifact) {
|
|
15244
|
-
return
|
|
15245
|
-
label: `${artifact.project}/${artifact.slug}`,
|
|
15246
|
-
topic: dispatchDocumentSubject(artifact.project, artifact.slug, ">")
|
|
15247
|
-
};
|
|
15264
|
+
return documentTopicOf(artifact.project, artifact.slug);
|
|
15248
15265
|
}
|
|
15249
15266
|
function resolvedTopic(resolved) {
|
|
15250
15267
|
if (resolved.owner.kind === "project")
|
|
@@ -15263,7 +15280,7 @@ function documentResultDetails(artifact) {
|
|
|
15263
15280
|
return {
|
|
15264
15281
|
project: artifact.project,
|
|
15265
15282
|
artifact: artifact.id,
|
|
15266
|
-
document:
|
|
15283
|
+
document: documentLabel(artifact.project, artifact.slug)
|
|
15267
15284
|
};
|
|
15268
15285
|
}
|
|
15269
15286
|
function writeResultDetails(resolved, fields) {
|
|
@@ -15274,15 +15291,15 @@ function writeResultDetails(resolved, fields) {
|
|
|
15274
15291
|
throw new Error("issue document is missing its issue");
|
|
15275
15292
|
return { issue: resolved.issue.key, ...fields };
|
|
15276
15293
|
}
|
|
15277
|
-
async function askOwnerDetails(client, ask,
|
|
15294
|
+
async function askOwnerDetails(client, ask, artifact) {
|
|
15278
15295
|
if (ask.issue_key !== null) {
|
|
15279
15296
|
return { issue: ask.issue_key, ask: ask.id };
|
|
15280
15297
|
}
|
|
15281
15298
|
if (ask.artifact_id === undefined || ask.artifact_id === null) {
|
|
15282
15299
|
throw new Error("document ask is missing its artifact ID");
|
|
15283
15300
|
}
|
|
15284
|
-
const
|
|
15285
|
-
return { ...documentResultDetails(
|
|
15301
|
+
const owner = artifact ?? await client.getArtifact(ask.artifact_id);
|
|
15302
|
+
return { ...documentResultDetails(owner), ask: ask.id };
|
|
15286
15303
|
}
|
|
15287
15304
|
async function commentOwnerResult(client, comment, artifact) {
|
|
15288
15305
|
if (comment.issue_key !== null) {
|
|
@@ -15293,12 +15310,12 @@ async function commentOwnerResult(client, comment, artifact) {
|
|
|
15293
15310
|
}
|
|
15294
15311
|
const owner = artifact ?? await client.getArtifact(comment.artifact_id);
|
|
15295
15312
|
return {
|
|
15296
|
-
label:
|
|
15313
|
+
label: documentLabel(owner.project, owner.slug),
|
|
15297
15314
|
details: { ...documentResultDetails(owner), comment: comment.id }
|
|
15298
15315
|
};
|
|
15299
15316
|
}
|
|
15300
|
-
async function followedAskDetails(client, ask,
|
|
15301
|
-
return { ...await askOwnerDetails(client, ask,
|
|
15317
|
+
async function followedAskDetails(client, ask, artifact) {
|
|
15318
|
+
return { ...await askOwnerDetails(client, ask, artifact), follows: { ask: ask.id } };
|
|
15302
15319
|
}
|
|
15303
15320
|
var nativeIssueKeyPattern = /^[A-Z][A-Z0-9]{1,9}-[0-9]+$/;
|
|
15304
15321
|
var externalIssueRefPattern = /^([^/\s]+)\/([^/\s#]+)#([1-9][0-9]*)$/;
|
|
@@ -15373,7 +15390,7 @@ function searchResultLine(result, baseUrl) {
|
|
|
15373
15390
|
const href = new URL(result.href, baseUrl).toString();
|
|
15374
15391
|
const { owner } = result;
|
|
15375
15392
|
if (owner.kind === "document") {
|
|
15376
|
-
const reference =
|
|
15393
|
+
const reference = dispatchDocumentRef(owner.project, owner.slug);
|
|
15377
15394
|
return `${reference} [document] ${owner.name} - ${result.kind}: ${snippetText(result.snippet)} -> ${href}`;
|
|
15378
15395
|
}
|
|
15379
15396
|
const artifactName = result.artifact ? ` ${result.artifact.name}` : "";
|
|
@@ -15384,7 +15401,6 @@ function askUrgency(args) {
|
|
|
15384
15401
|
const value = args.urgency;
|
|
15385
15402
|
return ASK_URGENCIES.find((urgency) => urgency === value);
|
|
15386
15403
|
}
|
|
15387
|
-
var maxAskQuestion16 = 800;
|
|
15388
15404
|
function questionWithRef(question, ref) {
|
|
15389
15405
|
return ref === undefined || question.includes(ref) ? question : `${question}
|
|
15390
15406
|
|
|
@@ -15394,7 +15410,7 @@ function askQuestionWithRef(args) {
|
|
|
15394
15410
|
return questionWithRef(stringArg(args, "question"), optionalString(args, "ref"));
|
|
15395
15411
|
}
|
|
15396
15412
|
function askQuestionProblem(withRef) {
|
|
15397
|
-
return withRef.length >
|
|
15413
|
+
return withRef.length > ASK_QUESTION_MAX ? `question plus ref ${overCapMessage(withRef.length, ASK_QUESTION_MAX)}; shorten the question or drop the ref` : undefined;
|
|
15398
15414
|
}
|
|
15399
15415
|
function parseDispatchRef(ref) {
|
|
15400
15416
|
const projectDocument = ref.match(/^dispatch:\/\/([A-Z][A-Z0-9]{1,9})\/artifact\/([^/@]+)(?:@v(\d+))?(?:\/(ask|comment)\/([^/]+))?$/);
|
|
@@ -15449,6 +15465,10 @@ function parseDispatchRef(ref) {
|
|
|
15449
15465
|
return { owner, kind: "message", id: message };
|
|
15450
15466
|
return { owner, kind: "issue", id: issue };
|
|
15451
15467
|
}
|
|
15468
|
+
function refTarget(ref, kind, id) {
|
|
15469
|
+
const ownerRef = ref.owner.kind === "issue" ? dispatchIssueRef(ref.owner.issue) : dispatchDocumentRef(ref.owner.project, `${ref.artifact}`);
|
|
15470
|
+
return dispatchChildRef(ownerRef, kind, id);
|
|
15471
|
+
}
|
|
15452
15472
|
var uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15453
15473
|
var askIdProblem = "ask must be a bare ask id or a dispatch://.../ask/<id> reference";
|
|
15454
15474
|
var commentIdProblem = "comment must be a bare comment id or a dispatch://.../comment/<id> reference";
|
|
@@ -15492,7 +15512,7 @@ function argumentProblems(tool, args) {
|
|
|
15492
15512
|
case "dispatch_ask": {
|
|
15493
15513
|
const question = optionalString(args, "question");
|
|
15494
15514
|
const ref = optionalString(args, "ref");
|
|
15495
|
-
if (question !== undefined && ref !== undefined && question.length <=
|
|
15515
|
+
if (question !== undefined && ref !== undefined && question.length <= ASK_QUESTION_MAX) {
|
|
15496
15516
|
const problem = askQuestionProblem(questionWithRef(question, ref));
|
|
15497
15517
|
if (problem !== undefined)
|
|
15498
15518
|
problems.push(problem);
|
|
@@ -15582,11 +15602,17 @@ function dispatchRefFromUrl(value, serverUrl) {
|
|
|
15582
15602
|
}
|
|
15583
15603
|
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";
|
|
15584
15604
|
var ownerRequiredProblem = "issue is required; supply issue or set LEGION_ISSUE";
|
|
15605
|
+
var toolSchemas = new Map;
|
|
15585
15606
|
function toolSchema(tool) {
|
|
15607
|
+
const cached = toolSchemas.get(tool);
|
|
15608
|
+
if (cached !== undefined)
|
|
15609
|
+
return cached;
|
|
15586
15610
|
const spec = dispatchToolSpecs.find((candidate) => candidate.name === tool);
|
|
15587
15611
|
if (!spec)
|
|
15588
15612
|
throw new Error(`Unknown Dispatch tool: ${tool}`);
|
|
15589
|
-
|
|
15613
|
+
const schema = dispatchToolSchema(spec, zodSchemaApi(exports_external), { strict: true });
|
|
15614
|
+
toolSchemas.set(tool, schema);
|
|
15615
|
+
return schema;
|
|
15590
15616
|
}
|
|
15591
15617
|
async function resolveOwnerArguments(tool, input, cwd, env, exec, serverUrl, problems) {
|
|
15592
15618
|
if (issueFreeTools[tool] === true)
|
|
@@ -15794,11 +15820,21 @@ function referenceLines(edges) {
|
|
|
15794
15820
|
return `- ${edge.kind} ${edge.node.kind} ${edge.node.ref ?? edge.node.id} (${excerpt}${edge.created_at})`;
|
|
15795
15821
|
});
|
|
15796
15822
|
}
|
|
15823
|
+
function unavailableReason(error) {
|
|
15824
|
+
return error instanceof DispatchServiceError && error.status === 404 ? "unavailable" : `unavailable: ${messageFor(error)}`;
|
|
15825
|
+
}
|
|
15797
15826
|
async function graphEdges(client, query) {
|
|
15798
15827
|
try {
|
|
15799
15828
|
return (await client.getReferences(query)).edges;
|
|
15800
15829
|
} catch (error) {
|
|
15801
|
-
return
|
|
15830
|
+
return unavailableReason(error);
|
|
15831
|
+
}
|
|
15832
|
+
}
|
|
15833
|
+
async function issueReferencesOrUnavailable(client, key) {
|
|
15834
|
+
try {
|
|
15835
|
+
return await client.getIssueReferences(key);
|
|
15836
|
+
} catch (error) {
|
|
15837
|
+
return unavailableReason(error);
|
|
15802
15838
|
}
|
|
15803
15839
|
}
|
|
15804
15840
|
async function graphSections(client, ref) {
|
|
@@ -16008,8 +16044,9 @@ async function executeDispatchTool(input) {
|
|
|
16008
16044
|
problems.push(...argumentProblems(input.tool, ownerArguments.args));
|
|
16009
16045
|
if (problems.length > 0)
|
|
16010
16046
|
throw new ToolInputError(input.tool, problems);
|
|
16047
|
+
const dispatchClient = () => new DispatchClient(configUrl, configToken, fetchImpl, input.signal);
|
|
16011
16048
|
if (input.tool === "dispatch_open_asks") {
|
|
16012
|
-
const client =
|
|
16049
|
+
const client = dispatchClient();
|
|
16013
16050
|
const project = optionalString(ownerArguments.args, "project");
|
|
16014
16051
|
if (project !== undefined) {
|
|
16015
16052
|
const response = await client.openAsksForProject(project);
|
|
@@ -16025,7 +16062,7 @@ async function executeDispatchTool(input) {
|
|
|
16025
16062
|
const sessionId = input.sessionId?.trim();
|
|
16026
16063
|
if (!sessionId)
|
|
16027
16064
|
throw new Error("host session id is required for dispatch_whoami");
|
|
16028
|
-
const client =
|
|
16065
|
+
const client = dispatchClient();
|
|
16029
16066
|
const identity = await client.whoami();
|
|
16030
16067
|
const owner = identity.kind === "agent" ? identity.owner : identity.login.toLowerCase();
|
|
16031
16068
|
return {
|
|
@@ -16035,7 +16072,7 @@ async function executeDispatchTool(input) {
|
|
|
16035
16072
|
}
|
|
16036
16073
|
const args = parsed.success ? parsed.data : ownerArguments.args;
|
|
16037
16074
|
const actor = toolActor(await resolveOrigin(env, exec, input.cwd), input);
|
|
16038
|
-
const client =
|
|
16075
|
+
const client = dispatchClient();
|
|
16039
16076
|
const owner = ownerArguments.owner?.kind === "issue" ? {
|
|
16040
16077
|
kind: "issue",
|
|
16041
16078
|
issue: await ensureIssue(client, ownerArguments.owner.issue, actor)
|
|
@@ -16274,7 +16311,7 @@ async function executeDispatchTool(input) {
|
|
|
16274
16311
|
return {
|
|
16275
16312
|
text: `Asked ${ask.id} on ${askOwner.label} (urgency ${ask.urgency}): ${ask.question}
|
|
16276
16313
|
${followsAsk(askOwner)}`,
|
|
16277
|
-
details: await followedAskDetails(client, ask, resolved)
|
|
16314
|
+
details: await followedAskDetails(client, ask, resolved?.artifact)
|
|
16278
16315
|
};
|
|
16279
16316
|
}
|
|
16280
16317
|
case "dispatch_edit_ask": {
|
|
@@ -16356,7 +16393,7 @@ ${followsAsk(askOwner)}`,
|
|
|
16356
16393
|
...inReplyTo === undefined ? {} : { in_reply_to: inReplyTo },
|
|
16357
16394
|
actor
|
|
16358
16395
|
});
|
|
16359
|
-
const messageRef =
|
|
16396
|
+
const messageRef = dispatchChildRef(dispatchIssueRef(issueKey), "message", message.id);
|
|
16360
16397
|
return {
|
|
16361
16398
|
text: `Posted message ${message.id} (${messageRef}) ${notSubscribed(issueTopic(issueKey))}`,
|
|
16362
16399
|
details: { issue: issueKey, message: message.id }
|
|
@@ -16418,7 +16455,7 @@ ${trailer.join(`
|
|
|
16418
16455
|
}
|
|
16419
16456
|
};
|
|
16420
16457
|
}
|
|
16421
|
-
const details = await followedAskDetails(client, result.ask, resolved);
|
|
16458
|
+
const details = await followedAskDetails(client, result.ask, resolved.artifact);
|
|
16422
16459
|
return {
|
|
16423
16460
|
text: `Approval requested for ${resolved.artifact.name} (document id ${resolved.artifact.id}) at version ${result.version} (ask ${result.ask.id}). The answer arrives as artifact.approved or artifact.changes_requested; an edit after approval makes it stale, so request again for the new version.`,
|
|
16424
16461
|
details: { ...details, artifact: resolved.artifact.id, version: result.version }
|
|
@@ -16441,7 +16478,7 @@ ${trailer.join(`
|
|
|
16441
16478
|
};
|
|
16442
16479
|
const artifactOwner = documentOwner();
|
|
16443
16480
|
const result = artifactOwner.kind === "project" ? await client.projectArtifact(artifactOwner.project, artifactInput) : await client.artifact(issue(), artifactInput);
|
|
16444
|
-
const artifactRef = artifactOwner.kind === "project" ?
|
|
16481
|
+
const artifactRef = dispatchDocumentRef(artifactOwner.kind === "project" ? artifactOwner.project : issue(), result.artifact.slug);
|
|
16445
16482
|
const uploadOwner = artifactOwner.kind === "project" ? documentTopic(result.artifact) : issueTopic(issue());
|
|
16446
16483
|
return {
|
|
16447
16484
|
text: `Uploaded ${result.artifact.name} as version ${result.version.number} (artifact slug ${result.artifact.slug}; ${artifactRef}) ${notSubscribed(uploadOwner)}`,
|
|
@@ -16477,7 +16514,7 @@ ${trailer.join(`
|
|
|
16477
16514
|
const ref = ownerArguments.ref;
|
|
16478
16515
|
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"));
|
|
16479
16516
|
const askRead = await client.getAsk(id);
|
|
16480
|
-
const askRef = ref
|
|
16517
|
+
const askRef = refTarget(ref, "ask", id);
|
|
16481
16518
|
return {
|
|
16482
16519
|
text: askSummary(askRead, await graphSections(client, askRef)),
|
|
16483
16520
|
details: ref.owner.kind === "project" ? { project: ref.owner.project } : { issue: ref.owner.issue }
|
|
@@ -16487,7 +16524,7 @@ ${trailer.join(`
|
|
|
16487
16524
|
const ref = ownerArguments.ref;
|
|
16488
16525
|
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));
|
|
16489
16526
|
const comment = await client.getComment(id);
|
|
16490
|
-
const commentRef = ref
|
|
16527
|
+
const commentRef = refTarget(ref, "comment", id);
|
|
16491
16528
|
return {
|
|
16492
16529
|
text: commentSummary(comment, await graphSections(client, commentRef)),
|
|
16493
16530
|
details: ref.owner.kind === "project" ? { project: ref.owner.project } : { issue: comment.comment.issue_key }
|
|
@@ -16498,7 +16535,7 @@ ${trailer.join(`
|
|
|
16498
16535
|
throw new Error("message references are issue-scoped");
|
|
16499
16536
|
}
|
|
16500
16537
|
const messageRead = await client.getMessage(ownerArguments.ref.owner.issue, ownerArguments.ref.id);
|
|
16501
|
-
const messageRef =
|
|
16538
|
+
const messageRef = dispatchChildRef(dispatchIssueRef(ownerArguments.ref.owner.issue), "message", ownerArguments.ref.id);
|
|
16502
16539
|
return {
|
|
16503
16540
|
text: messageSummary(messageRead, await graphSections(client, messageRef)),
|
|
16504
16541
|
details: { issue: messageRead.message.issue_key }
|
|
@@ -16506,7 +16543,7 @@ ${trailer.join(`
|
|
|
16506
16543
|
}
|
|
16507
16544
|
if (documentOwner().kind === "project") {
|
|
16508
16545
|
const resolved = await resolveArtifact(client, documentOwner(), stringArg(args, "artifact"));
|
|
16509
|
-
const documentRef =
|
|
16546
|
+
const documentRef = dispatchDocumentRef(resolved.artifact.project, resolved.artifact.slug);
|
|
16510
16547
|
return {
|
|
16511
16548
|
text: [
|
|
16512
16549
|
`Document: ${resolved.artifact.project} / ${resolved.artifact.name}`,
|
|
@@ -16518,7 +16555,7 @@ ${trailer.join(`
|
|
|
16518
16555
|
`),
|
|
16519
16556
|
details: {
|
|
16520
16557
|
project: resolved.artifact.project,
|
|
16521
|
-
document:
|
|
16558
|
+
document: documentLabel(resolved.artifact.project, resolved.artifact.slug)
|
|
16522
16559
|
}
|
|
16523
16560
|
};
|
|
16524
16561
|
}
|
|
@@ -16535,14 +16572,12 @@ ${trailer.join(`
|
|
|
16535
16572
|
details: { issue: read.issue.key }
|
|
16536
16573
|
};
|
|
16537
16574
|
}
|
|
16538
|
-
|
|
16539
|
-
|
|
16540
|
-
|
|
16541
|
-
|
|
16542
|
-
references = error instanceof DispatchServiceError && error.status === 404 ? "unavailable" : `unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
16543
|
-
}
|
|
16575
|
+
const [references, graph] = await Promise.all([
|
|
16576
|
+
issueReferencesOrUnavailable(client, read.issue.key),
|
|
16577
|
+
graphSections(client, dispatchIssueRef(read.issue.key))
|
|
16578
|
+
]);
|
|
16544
16579
|
return {
|
|
16545
|
-
text: issueSummary(read.issue, read.events, references,
|
|
16580
|
+
text: issueSummary(read.issue, read.events, references, graph),
|
|
16546
16581
|
details: { issue: read.issue.key }
|
|
16547
16582
|
};
|
|
16548
16583
|
}
|
|
@@ -16685,6 +16720,7 @@ var envoyToolSpecs = [
|
|
|
16685
16720
|
requiresSubscriptionCapability: false
|
|
16686
16721
|
}
|
|
16687
16722
|
];
|
|
16723
|
+
var envoyToolSchemas = new Map;
|
|
16688
16724
|
|
|
16689
16725
|
// ../envoy-client/src/transport.ts
|
|
16690
16726
|
var DEFAULT_TIMEOUT_MS = 5000;
|
package/package.json
CHANGED
package/skills/dispatch/SKILL.md
CHANGED
|
@@ -15,6 +15,21 @@ the 800-character limit (850/800)`); it never truncates it. A tool call with sev
|
|
|
15
15
|
(`<tool> was not called: N problems`), so one corrected call lands. GitHub threads and markers no
|
|
16
16
|
longer exist.
|
|
17
17
|
|
|
18
|
+
## Design changes are brainstormed here
|
|
19
|
+
|
|
20
|
+
Sami, 2026-09-17, verbatim: "Make sure your agents know that they should be doing brainstorming with me
|
|
21
|
+
through dispatch for major design changes." For a major design change the design conversation itself
|
|
22
|
+
happens in Dispatch: write the spec document early, while it is still a draft with real alternatives, and
|
|
23
|
+
put each open question in it as an `ask` block beside the options and trade-offs it depends on
|
|
24
|
+
([Writing a spec](#writing-a-spec), [Typed blocks](#typed-blocks)). He answers in place and the document
|
|
25
|
+
grows into the record. A finished spec dropped after a chat-only design, or a set of one-line issue asks
|
|
26
|
+
pointing at a document, is not brainstorming with him.
|
|
27
|
+
Sami, 2026-09-17, verbatim: "Can you please stop doing this thing where you have these one-off,
|
|
28
|
+
shorthand, compressed decision asks that are completely disconnected from any discussion of the
|
|
29
|
+
design or the trade-offs? This is just very obviously not the most effective way to have a design
|
|
30
|
+
communication." A question lives beside the options and trade-offs it depends on, in the spec or
|
|
31
|
+
discussion it came from — never as a compressed standalone ask.
|
|
32
|
+
|
|
18
33
|
## Writing for the human
|
|
19
34
|
|
|
20
35
|
Sami, 2026-09-12, on what Legion had been producing: "It's completely incomprehensible. It's just
|
|
@@ -26,10 +41,14 @@ vocabulary, and is often on a phone. Write for that person.
|
|
|
26
41
|
not "fix 8c", "READY-target", "PR B", "spec@v3", "the pair", "the packet" — say what the thing is.
|
|
27
42
|
- Expand every identifier the first time it appears: an issue key gets its title, a PR number its
|
|
28
43
|
title, a file what it is for, a session id who it is. Link a URL rather than pasting a bare id.
|
|
29
|
-
-
|
|
30
|
-
|
|
44
|
+
- A question lives beside the options and trade-offs it depends on, in the spec or discussion it
|
|
45
|
+
came from — never a compressed standalone ask (his words are quoted under [Design changes are
|
|
46
|
+
brainstormed here](#design-changes-are-brainstormed-here)). Give the reader the options, what
|
|
47
|
+
each costs, and your recommendation with its reason; do not prescribe yourself a form.
|
|
31
48
|
- Before posting, test it: could Sami, reading only this text on his phone, know what he is being
|
|
32
49
|
told or asked? If not, rewrite it. Length is not the problem; density is.
|
|
50
|
+
- When an ask or message communicates a judgment, lead with that judgment in one sentence and put the mechanism underneath it. Do not make the reader ask a second time whether the result is a win. This shapes communication only when a judgment exists; it does not pre-decide an open question or remove its genuine options. Inferred from the AGENTC-186 12-hour-cap incident (platform PO, 2026-09-17).
|
|
51
|
+
- When a Dispatch message states a root cause, include the reproducing command or test in that same message. Without it, label the diagnosis a hypothesis; a diagnosis still in progress may say so plainly. This boundary applies to causal claims, not to reporting that an investigation has started. Inferred from the astro lane's 2026-09-16 retro (platform PO, 2026-09-17).
|
|
33
52
|
|
|
34
53
|
## Agent authentication
|
|
35
54
|
|
|
@@ -62,7 +81,7 @@ rest. Use these headings in this order.
|
|
|
62
81
|
| Section | Required content | Form |
|
|
63
82
|
| --- | --- | --- |
|
|
64
83
|
| **Summary** | The problem, what changes for whom, and how we will know it worked — in plain words. | Three sentences at most. |
|
|
65
|
-
| **Decisions needed** | Only decisions that need human authority, taste, or risk appetite
|
|
84
|
+
| **Decisions needed** | Only decisions that need human authority, taste, or risk appetite: each one plain question, two or three options with what each costs, and your recommendation with its reason — written as an `ask` block directly under those options, so it is answered in context (see [Design changes are brainstormed here](#design-changes-are-brainstormed-here)). An answered item moves into Requirements with its provenance. | One `ask` block per decision; empty is fine. |
|
|
66
85
|
| **New since we talked** | Every design point the human did not settle in conversation, marked `inferred:` with the reasoning. Empty is fine. | One plain sentence per point. |
|
|
67
86
|
| **Acceptance** | Each outcome names what a user will observe and the check that proves it (browser scenario, API call, or command). An outcome without a check is not acceptance. | Numbered lines. |
|
|
68
87
|
| **Requirements** | What must hold, and where each came from: a quoted human sentence, or `inferred:` plus the reasoning. Readers treat inferred requirements as hypotheses. | `requirement \| where it comes from` table, or prose if the reader follows it more easily. |
|
|
@@ -178,7 +197,7 @@ production import). Every `dispatch_ask` passes three gates first:
|
|
|
178
197
|
2. **Is there genuine uncertainty?** If not, it is a plan you execute. The one legitimate ask
|
|
179
198
|
without uncertainty is permission for an action only a human can authorise — a production
|
|
180
199
|
write, an external send, a console action — and then the question is that action in one
|
|
181
|
-
sentence with
|
|
200
|
+
sentence, with options that name its outcomes (below).
|
|
182
201
|
3. **Can someone who has not read the code answer it on a phone?** What he can see today, what
|
|
183
202
|
changes for a reader, two options with what each costs, your recommendation. No slice or
|
|
184
203
|
decision numbers, no coined nouns, no internal identifiers he has never used, no jargon you
|
|
@@ -240,9 +259,10 @@ that follow from it:
|
|
|
240
259
|
- Expand every term the reader has not used first. A product name, an internal setting, an
|
|
241
260
|
acronym, a value you coined this session — write what it is in the ask, in his words.
|
|
242
261
|
- A runbook the human must execute is one ask per step, each self-contained: what to do, where,
|
|
243
|
-
what result proves it,
|
|
244
|
-
answered and states that step's verified result in one line ("Step 1
|
|
245
|
-
Cloud Identity Free on the admin console.") — never a pointer to the
|
|
262
|
+
what result proves it, and options that name the step's outcomes. Each later step opens only
|
|
263
|
+
after the previous is answered and states that step's verified result in one line ("Step 1
|
|
264
|
+
done: the licence shows Cloud Identity Free on the admin console.") — never a pointer to the
|
|
265
|
+
earlier ask.
|
|
246
266
|
|
|
247
267
|
**A decision about an uploaded artifact links it.** If the human must read an artifact to answer,
|
|
248
268
|
the question carries `dispatch://KEY/artifact/<slug>` (or `ref`), never just its filename. Text
|
|
@@ -267,6 +287,8 @@ It returns the imported commit, or the recorded error when the model was rejecte
|
|
|
267
287
|
|
|
268
288
|
Before saying you are waiting for human input, call `dispatch_open_asks`. With no arguments it lists this session's active asks across open issues and project documents, including whether the human or agent owes the next reply. With `dispatch_open_asks({ project })` it lists every open ask in that project — on its issues and on its documents, whoever authored them — which is how you audit what a whole project is waiting on rather than just your own asks.
|
|
269
289
|
|
|
290
|
+
**Unsettled product shape needs a decision before implementation.** When a page, navigation entry, table key, customer-scoping rule, or persisted sidecar would set product shape that Sami has not already settled, send a one-line ask before the first implementation commit. A platform-PO schema or contract ruling does not settle product shape. This does not turn a user-specified decision or routine implementation into an approval request; it is inferred from AGENTC-186's 2026-09-16 retro (platform PO, 2026-09-17).
|
|
291
|
+
|
|
270
292
|
**Anything that needs the human is an ask, or it does not exist.** An approval, a credential,
|
|
271
293
|
a setting only they can change, a review click, a conflict between two of their own rules - if
|
|
272
294
|
your work waits on it, open a `dispatch_ask` the moment you know, the action as the question.
|
|
@@ -278,13 +300,13 @@ click. One ask per item, `urgency: "high"` when work is stopped on it; while it
|
|
|
278
300
|
working on everything that is not.
|
|
279
301
|
|
|
280
302
|
A to-do handed to a human is an ordinary question: phrase the to-do as the question and give it
|
|
281
|
-
the options
|
|
282
|
-
server
|
|
283
|
-
free-text answer carries it:
|
|
303
|
+
the options that name its outcomes, in the human's words - there is no fixed vocabulary and the
|
|
304
|
+
server treats no label specially. If an outcome needs a reason, say so in that option's
|
|
305
|
+
description, and the human's free-text answer carries it:
|
|
284
306
|
```ts
|
|
285
307
|
dispatch_ask({ issue: "DSP-42",
|
|
286
|
-
question: "
|
|
287
|
-
options: [{ label: "
|
|
308
|
+
question: "Run the production deploy for #19125?",
|
|
309
|
+
options: [{ label: "Deployed" }, { label: "Blocked", description: "Say what is missing." }] })
|
|
288
310
|
```
|
|
289
311
|
|
|
290
312
|
Correct or refine an open ask in place instead of opening a second question:
|
|
@@ -242,24 +242,18 @@ Preserve this order exactly:
|
|
|
242
242
|
3. retro commits its learnings under `docs/solutions/` on top of the approved head; that
|
|
243
243
|
commit does not void the approval and never returns the tree to the tester or reviewer;
|
|
244
244
|
4. the merger verifies the current head is the reviewer-approved head plus only commits that
|
|
245
|
-
change `docs/solutions/` (`jj diff --from <approved-sha> --to <tip-sha> --summary`, quoted in
|
|
246
|
-
and
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
5. the controller merges; you then `spawn_worker` the **implementer** once more with the
|
|
258
|
-
production-check task. It drives the changed path in production through the user's own access
|
|
259
|
-
path and records what it saw on the pull request and on this issue. Close only after the implementer's production report exists.
|
|
260
|
-
A defect it finds is a corrective child issue of this tree, not a note on a closed one; a deploy
|
|
261
|
-
the implementer cannot perform is its `dispatch_ask` with `Done` / `Can't` options, and the
|
|
262
|
-
issue waits for it.
|
|
245
|
+
change `docs/solutions/` (`jj diff --from <approved-sha> --to <tip-sha> --summary`, quoted in
|
|
246
|
+
READY) and posts `READY #<n> at <current sha> (approved at <approved sha>) for <KEY> (<pr url>)`
|
|
247
|
+
on the Dispatch issue. When its `Legion addressing` line names the project's merge queue, it
|
|
248
|
+
publishes the same packet there too. Legion never merges; a human merges under the repository's
|
|
249
|
+
GitHub branch-protection and CODEOWNERS rules. If the merger reports a failed verification,
|
|
250
|
+
treat it like `pr-blocked`: fix through the phases, never bypass.
|
|
251
|
+
5. a human merges; you then `spawn_worker` the **implementer** once more with the production-check
|
|
252
|
+
task. It drives the changed path in production through the user's own access path and records
|
|
253
|
+
what it saw on the pull request and on this issue. Close only after the implementer's production
|
|
254
|
+
report exists. A defect it finds is a corrective child issue of this tree, not a note on a
|
|
255
|
+
closed one; a deploy the implementer cannot perform is its `dispatch_ask` naming that deploy,
|
|
256
|
+
with options for its outcomes, and the issue waits for it.
|
|
263
257
|
|
|
264
258
|
What returns the tree to review: a changed diff — a commit above the approved head that
|
|
265
259
|
touches anything outside `docs/solutions/`, or a rebase whose fingerprint (the `legion-worker`
|
|
@@ -275,9 +269,9 @@ PR gets no CI and no wake announces it, and send the implementer to rebase the m
|
|
|
275
269
|
it. Do not let the merger publish `READY` for an obsolete approval.
|
|
276
270
|
|
|
277
271
|
If a worker reports that `legion threads resolve` exited 1 naming a review thread GitHub refused
|
|
278
|
-
to resolve, open a `dispatch_ask`
|
|
279
|
-
|
|
280
|
-
open. That is the one review-thread step a human takes: the review App cannot resolve threads,
|
|
272
|
+
to resolve, open a `dispatch_ask` that names the thread's URL and GitHub's message for a human to
|
|
273
|
+
resolve it by hand, with options for resolved / could not; the merger does not publish while it
|
|
274
|
+
is open. That is the one review-thread step a human takes: the review App cannot resolve threads,
|
|
281
275
|
and the implementer's and merger's runs of the command close every accepted one.
|
|
282
276
|
|
|
283
277
|
## 7. Close
|
|
@@ -317,7 +311,7 @@ active phase worker.
|
|
|
317
311
|
| `pr-ready` | Verify the live PR head, green status, and review state. Continue the review/retro/merger order only for that current head. |
|
|
318
312
|
| `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. |
|
|
319
313
|
| `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. |
|
|
320
|
-
| `pr-merged` | Payload `{type:"pr-merged", pr, mergeCommitSha}`. The
|
|
314
|
+
| `pr-merged` | Payload `{type:"pr-merged", pr, mergeCommitSha}`. The PR merged because a human merged it under the repository's rules. `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. |
|
|
321
315
|
| `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. |
|
|
322
316
|
| `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. |
|
|
323
317
|
| `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,
|
|
3
|
+
description: Use when handling Legion controller wakes for root-issue triage, backlog admission, architect escalation, resync healing, or human interaction.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Legion Controller
|
|
@@ -19,15 +19,10 @@ server (the pane runs plain `omp`, not `--mode rpc`, and no `legion worker-shim`
|
|
|
19
19
|
it with `tmux -L legion-<project> select-window -t <window id> \; attach -t legion-<project>`,
|
|
20
20
|
the window id being `controllerLocator.tmuxWindowId` in `legion state --json` — every window
|
|
21
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
22
|
+
type into this session at any time. The pane carries no GitHub credential: its GitHub token
|
|
23
|
+
variables are emptied, and both `legion gh -- <args>` and `legion threads resolve` are refused.
|
|
24
|
+
The controller reads Dispatch and applies its controller capability with `legion status <KEY>
|
|
25
|
+
<status>`; it never reads GitHub or merges a pull request.
|
|
31
26
|
|
|
32
27
|
For an interactive takeover from a hand-started OMP session, start OMP with
|
|
33
28
|
`LEGION_CONTROLLER_SECRET` (or `LEGION_CONTROLLER_SECRET_FILE`, a path to a file holding it),
|
|
@@ -43,10 +38,10 @@ claims at startup and reports its transcript as the pane's. Then run:
|
|
|
43
38
|
|
|
44
39
|
The command resolves the project from daemon state, claims the Envoy role for the current
|
|
45
40
|
session, and posts readiness before controller commands can act. From then on this session's
|
|
46
|
-
shell commands are wrapped with a controller grant
|
|
47
|
-
`legion
|
|
48
|
-
|
|
49
|
-
|
|
41
|
+
shell commands are wrapped with a controller grant, but the grant holds no GitHub credential;
|
|
42
|
+
`legion status <KEY> <status>` works through the controller secret in this session's environment
|
|
43
|
+
(`LEGION_CONTROLLER_SECRET` or its `_FILE`), not the grant — if it fails, that is the variable to check.
|
|
44
|
+
The takeover moves the role
|
|
50
45
|
and the daemon's recorded session id to this session; it never replaces the transcript the
|
|
51
46
|
daemon recorded for its own pane, so a later respawn of that pane resumes the pane's own
|
|
52
47
|
conversation, not yours. Never pass a secret as a command argument or copy it into a transcript.
|
|
@@ -79,17 +74,16 @@ registeredAt}` and reads your liveness from the Envoy role registry (the holder
|
|
|
79
74
|
`legion-<project>-controller` and its `last_seen`), not from a pane: keep the session running.
|
|
80
75
|
Exiting it leaves the project without a controller until the operator runs the command again —
|
|
81
76
|
the daemon logs `controller not registered; run legion controller start` once per boot-timeout
|
|
82
|
-
interval and launches nothing itself. `legion state
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
the role moves to the new session.
|
|
77
|
+
interval and launches nothing itself. `legion state` and `legion status <KEY> <status>` work here
|
|
78
|
+
over `LEGION_DAEMON_URL` (the port-forward). A second `legion controller start` replaces you: it
|
|
79
|
+
mints a new secret, so your grants stop working and the role moves to the new session.
|
|
86
80
|
|
|
87
81
|
## Deployment instructions
|
|
88
82
|
|
|
89
83
|
Deployment instructions, when present, are the operator's standing rules for this repository —
|
|
90
|
-
required checks, deploy/smoke commands, code-owner expectations, standing roles you may
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
required checks, deploy/smoke commands, code-owner expectations, and standing roles you may
|
|
85
|
+
consult. They override this skill's defaults where they conflict; they never override a Sami ruling
|
|
86
|
+
quoted here.
|
|
93
87
|
|
|
94
88
|
## Turn discipline
|
|
95
89
|
|
|
@@ -117,8 +111,7 @@ override a Sami ruling quoted here.
|
|
|
117
111
|
| 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) |
|
|
118
112
|
| `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` |
|
|
119
113
|
| Mention | Slack/GitHub PR @mention text | Answer, or route to the owning issue's architect role |
|
|
120
|
-
| READY
|
|
121
|
-
| `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 |
|
|
114
|
+
| READY packet seen on a Dispatch issue (via issue subscription) | READY line + gate facts | No action: a human merges; the merger has already notified the queue role if the project has one |
|
|
122
115
|
| 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 |
|
|
123
116
|
| Direct user message | — | Always first |
|
|
124
117
|
|
|
@@ -202,188 +195,3 @@ gate at all runs `gates.design: off` in its `legion.yaml`. Otherwise resolve the
|
|
|
202
195
|
owning architect role and route the verified context with `envoy_publish`. Do not route raw
|
|
203
196
|
event traffic or invent a role token from a partial issue reference.
|
|
204
197
|
|
|
205
|
-
## Merge queue
|
|
206
|
-
|
|
207
|
-
The controller is the project's merge queue. A merger reports a pull request ready by
|
|
208
|
-
publishing to the controller topic; the controller re-reads every gate from live GitHub and
|
|
209
|
-
merges, or tells the tree's architect exactly which gate failed. The merger's report is a
|
|
210
|
-
claim, never evidence.
|
|
211
|
-
|
|
212
|
-
**READY message shape.** Defined once in `packages/pi-envoy/roles/merger.md` and mirrored here
|
|
213
|
-
verbatim. The first line is
|
|
214
|
-
`READY #<n> at <current sha> (approved at <approved sha>) for <KEY> (<pr url>)`: the pull
|
|
215
|
-
request number, the sha of the pull request's current head, the sha the reviewer's head-pinned
|
|
216
|
-
approval names, the issue key, and the pull request URL. The rest of the message is the PR
|
|
217
|
-
body's gate facts (the `## Verification` block). You need every field: the URL addresses the
|
|
218
|
-
pull request from this pane's working directory (which is not a checkout), the key finds the
|
|
219
|
-
tree's architect (below), the current sha is the only head you may merge, and the approved sha
|
|
220
|
-
anchors the two path-only compares in gates 5 and 6. The controller never verifies the
|
|
221
|
-
approval itself: whether a review must exist before merge is the repository's own
|
|
222
|
-
branch-protection or CODEOWNERS rule, which GitHub enforces at `pr merge` time and Legion
|
|
223
|
-
neither reads nor writes.
|
|
224
|
-
|
|
225
|
-
**Three shas.** This repository's flow leaves three commits that matter, and they are normally
|
|
226
|
-
all different. The *verified* sha is the head the tester and the reviewer worked at: the
|
|
227
|
-
`## Verification` block's own `CI`, `Thermo`, and `E2E` lines name it, and they must agree. After
|
|
228
|
-
that head is found clean the implementer pushes the `.legion/` handoff deletion and the reviewer
|
|
229
|
-
approves *that* head by name — the *approved* sha, one commit later. Retro then commits its
|
|
230
|
-
`docs/solutions/` learning on top — the *current* sha. READY carries the current and approved
|
|
231
|
-
shas; the verified sha you read from the block. The gates check the block at the verified sha
|
|
232
|
-
and prove, with two compares, that nothing but the `.legion/` deletion lies between verified and
|
|
233
|
-
approved, and nothing but `docs/solutions/` between approved and current.
|
|
234
|
-
|
|
235
|
-
**Gates.** Read them from live GitHub, never from the message or the PR body alone. Every `gh`
|
|
236
|
-
command takes the pull request URL, or `--repo <owner>/<repo>` taken from it, because this
|
|
237
|
-
session's working directory has no git remote to resolve a bare number against:
|
|
238
|
-
|
|
239
|
-
```text
|
|
240
|
-
legion gh -- pr view <pr url> --json headRefOid,baseRefName,mergeable,body,files
|
|
241
|
-
legion gh -- pr checks <pr url> --required --json name,state,bucket,link
|
|
242
|
-
legion gh -- api repos/<owner>/<repo>/rules/branches/<baseRefName from pr view> --jq '[.[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context]'
|
|
243
|
-
legion gh -- api repos/<owner>/<repo>/branches/<baseRefName from pr view> --jq '.protection.required_status_checks.contexts'
|
|
244
|
-
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>
|
|
245
|
-
legion gh -- api repos/<owner>/<repo>/compare/<verified sha>...<approved sha> --jq '{status, files: [.files[].filename]}'
|
|
246
|
-
legion gh -- api repos/<owner>/<repo>/compare/<approved sha>...<current sha> --jq '{status, files: [.files[].filename]}'
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
1. **head**: `headRefOid` equals the `<current sha>` in the READY. Any other head is a different
|
|
250
|
-
pull request as far as this READY is concerned.
|
|
251
|
-
2. **checks**: `pr checks --required --json …` exits 0 with at least one row, and every row's
|
|
252
|
-
`bucket` is `pass` or `skipping`. That is the only green. The five buckets the CLI emits
|
|
253
|
-
(`gh pr checks --help`): `pass` and `skipping` are green — a job skipped by its `if:` (a
|
|
254
|
-
path-filtered workflow skips the jobs whose paths a pull request does not touch, and GitHub
|
|
255
|
-
treats a skipped job as satisfying a required check);
|
|
256
|
-
`pending` is pending; `fail` and `cancel` never merge (the Flake rule applies to both). With
|
|
257
|
-
`--json` the command exits 0 whenever rows exist, whatever their buckets, so the buckets
|
|
258
|
-
decide, never the exit code. Exit 1 comes only with no rows: `no checks reported on the
|
|
259
|
-
'<branch>' branch` when nothing has reported at the head yet (a freshly pushed head has no
|
|
260
|
-
check runs for a few seconds; a head that conflicts with the base never gets any, but that
|
|
261
|
-
head fails gate 4 — report it, do not subscribe), or `no required checks reported on the
|
|
262
|
-
'<branch>' branch` when checks exist but none is required. Either message is **pending**,
|
|
263
|
-
never green: subscribe to the pull request's `pr.<n>` and `pr.<n>.checks` topics exactly as
|
|
264
|
-
the Pending READY paragraph below says, and re-run the gates on that wake. (Without `--json`
|
|
265
|
-
the CLI exits 8 for pending rows and 1 for a failing row or no rows; you never run it that
|
|
266
|
-
way — the rows are what you read.) Two exceptions, both read from the repository, never from
|
|
267
|
-
the absence of rows:
|
|
268
|
-
- A repository that genuinely requires no checks. Required checks live in two places, and
|
|
269
|
-
both must be empty: the `rules/branches/<baseRefName>` query above (rulesets) returns `[]`
|
|
270
|
-
**and** the `branches/<baseRefName>` query above (the classic branch-protection summary,
|
|
271
|
-
which the implement App can read; the admin endpoint
|
|
272
|
-
`branches/<baseRefName>/protection/required_status_checks` is not readable under your credentials
|
|
273
|
-
and is not used) returns `[]`. Only then does the `no required checks reported` exit let
|
|
274
|
-
this gate hold with no check rows. A repository whose required checks are classic
|
|
275
|
-
protection answers `[]` for rulesets and the check names in the classic summary; `null`
|
|
276
|
-
from the classic query (no `protection` object in the answer) is not `[]` and leaves this
|
|
277
|
-
gate pending.
|
|
278
|
-
- A private repository on GitHub's free plan cannot define required checks at all: the
|
|
279
|
-
rulesets query answers HTTP 403 with a JSON body whose `message` **contains** the phrase
|
|
280
|
-
`make this repository public to enable this feature` (`gh` prints the whole message with
|
|
281
|
-
`(HTTP 403)` appended). Match that phrase as a substring — it is the stable tail; the head
|
|
282
|
-
names the plan (`Upgrade to GitHub Pro` for a user-owned repository, `Upgrade to GitHub
|
|
283
|
-
Team` for an organization-owned one) and the sentence ends with a period inside a JSON
|
|
284
|
-
wrapper, so literal equality never matches. Any other 403 — `Resource not accessible by
|
|
285
|
-
integration` included — is a permission error and stays an error, never "no required
|
|
286
|
-
checks". Under this exception gate 2 requires every check reported on the head to be green
|
|
287
|
-
instead: `legion gh -- pr checks <pr url> --json name,state,bucket,link` (without
|
|
288
|
-
`--required`) exits 0 with at least one row and every row's `bucket` is `pass` or
|
|
289
|
-
`skipping`. A `pending` row is pending, a `fail` or `cancel` row never merges, and no rows
|
|
290
|
-
(the exit-1 `no checks reported`) stays pending exactly as above. This is stricter than
|
|
291
|
-
"no required checks, merge", and GitHub still enforces whatever protection the repository
|
|
292
|
-
does have at `pr merge` time, so a wrong read costs a refused merge reported to the
|
|
293
|
-
architect, never an unprotected one.
|
|
294
|
-
Where each of these reads was observed — the CLI version, the two repositories, the exact
|
|
295
|
-
answers — is recorded in
|
|
296
|
-
`docs/solutions/legion/controller-gate-2-required-checks-live-reads.md`. The rule above is
|
|
297
|
-
what you execute; the live answers are what you read.
|
|
298
|
-
3. **threads**: zero unresolved review threads across every page. Start with `after: null`, then
|
|
299
|
-
repeat the query with the prior page's `pageInfo.endCursor` until `hasNextPage` is false; the
|
|
300
|
-
count of `isResolved: false` across all pages must be 0. A missing `pageInfo`, a missing cursor
|
|
301
|
-
while `hasNextPage` is true, or any failed page is a failed gate: do not merge. This follows the
|
|
302
|
-
pagination `legion threads resolve` uses, but the controller reads only and never resolves a
|
|
303
|
-
review thread.
|
|
304
|
-
4. **mergeable**: `mergeable` is not `CONFLICTING` and not `UNKNOWN`.
|
|
305
|
-
5. **cleanup only**: `compare/<verified sha>...<approved sha>` reports `status` `identical` or
|
|
306
|
-
`ahead`, and every path in `files` starts with `.legion/` — the handoff deletion the reviewer
|
|
307
|
-
directed, and nothing else. Anything else between the two is the failed gate
|
|
308
|
-
`cleanup changed more than .legion`.
|
|
309
|
-
6. **retro only**: `compare/<approved sha>...<current sha>` reports `status` `identical` or
|
|
310
|
-
`ahead`, and every path in `files` starts with `docs/solutions/`. Anything else between the
|
|
311
|
-
two is the failed gate `head moved beyond retro`: the approval no longer covers the head.
|
|
312
|
-
7. **verification block**: the PR body's `## Verification` block (the template in
|
|
313
|
-
`skills/legion-worker/SKILL.md`) is complete and current at the verified sha. The tester
|
|
314
|
-
fills the `E2E` line before review; the reviewer writes the `Thermo` line at the head it
|
|
315
|
-
audited; approval lands one commit later on the cleanup head; so the block names the verified
|
|
316
|
-
sha, never the approved or the current one. Line by line: the `CI` line names a run and
|
|
317
|
-
reports success at one sha; the `Thermo` line names the same sha and a verdict, unless the
|
|
318
|
-
pull request is docs-only, in which case the template omits that line entirely — docs-only
|
|
319
|
-
is a fact you read, never one you take from the omission itself: every `path` in the `files`
|
|
320
|
-
list of the `pr view` command above starts with `docs/` or ends with `.md`
|
|
321
|
-
(`--jq '[.files[].path | select((startswith("docs/") or endswith(".md")) | not)]'` is `[]`);
|
|
322
|
-
a missing `Thermo` line on any other pull request fails this gate; the `E2E`
|
|
323
|
-
line names the same sha and has a `Negative control` line — those lines agreeing on one sha
|
|
324
|
-
is what defines the verified sha; the `Threads` line reports `0 unresolved` (its per-thread
|
|
325
|
-
lines name fixing commits, never the head — do not look for a sha there); the `Fast-follow`
|
|
326
|
-
and `Chain` lines are filled in. No `<placeholder>` text remains anywhere in the block.
|
|
327
|
-
|
|
328
|
-
When all seven hold, merge:
|
|
329
|
-
`legion gh -- pr merge <pr url> --squash --match-head-commit <current sha>`. The head pin makes
|
|
330
|
-
GitHub refuse the merge if a push landed after gate 1 read the head; that refusal is a failed
|
|
331
|
-
`head` gate, reported like any other. The grant your `bash` call carries is the controller's
|
|
332
|
-
own, the only grant the daemon honours for a merge; the merge runs under the implement App's
|
|
333
|
-
identity and the repository's own rules (branch protection, CODEOWNERS). Whether a human must
|
|
334
|
-
approve first is that repository's setting — you neither read nor bypass it, and you never
|
|
335
|
-
admin-merge without an explicit deployment grant from Sami for that specific merge.
|
|
336
|
-
|
|
337
|
-
**Failed gate.** Reply to the tree's architect naming the gate (`head`, `checks`, `threads`,
|
|
338
|
-
`mergeable`, `cleanup changed more than .legion`, `head moved beyond retro`, or
|
|
339
|
-
`verification block`) and the evidence you read (the shas, the check name and run link, the
|
|
340
|
-
thread count, the `mergeable` value, the offending paths from the compare). Do not merge, do not
|
|
341
|
-
retry on a timer. The architect fixes through the phases.
|
|
342
|
-
|
|
343
|
-
**Flake.** A required check that failed or was cancelled (`bucket` `fail` or `cancel`) for a
|
|
344
|
-
reason unrelated to the change (a runner outage, a rate limit, a known-flaky job) may be rerun
|
|
345
|
-
once: `legion gh -- run rerun <run-id> --failed --repo <owner>/<repo>`, the run id taken from
|
|
346
|
-
the failing row's `link` (`https://github.com/<owner>/<repo>/actions/runs/<run-id>/job/<job-id>`).
|
|
347
|
-
Then stop. The rerun's result reaches you as a `pr.<n>.checks` wake; re-run the gates then.
|
|
348
|
-
A second failure is a failed gate, reported as above.
|
|
349
|
-
|
|
350
|
-
**Conflicts and unknown mergeability.** `mergeable == CONFLICTING` is the only reason to ask
|
|
351
|
-
for a rebase: reply to the tree's architect asking for one. Never request a rebase for any other
|
|
352
|
-
reason — the CI queue is long and slow, and an unnecessary rebase clogs it for every other pull
|
|
353
|
-
request. `mergeable == UNKNOWN` means GitHub has not finished computing it: do not merge, do
|
|
354
|
-
not poll; re-read on the next `pr.<n>.checks` wake.
|
|
355
|
-
|
|
356
|
-
**Pending READY.** A READY that cannot merge yet only because checks are still running (a
|
|
357
|
-
`pending` row), none has reported at the head yet (gate 2's `no checks reported` exit), a flake
|
|
358
|
-
rerun was issued, or `mergeable` is `UNKNOWN` is pending. Subscribe to that pull request's
|
|
359
|
-
events so its settlement wakes you:
|
|
360
|
-
|
|
361
|
-
```text
|
|
362
|
-
envoy_subscribe({ topics: ["notifications.github.<owner>.<repo>.pr.<n>", "notifications.github.<owner>.<repo>.pr.<n>.checks"] })
|
|
363
|
-
```
|
|
364
|
-
|
|
365
|
-
On that wake, re-run the gates against the shas from the READY in your conversation, then
|
|
366
|
-
`envoy_unsubscribe` those topics once you have merged or reported a failed gate. The controller
|
|
367
|
-
never polls; READY and `pr.<n>.checks` are the only wakes. If you were resumed and no longer
|
|
368
|
-
have the READY in your conversation, ask that issue's merger (its token is the `roles` key in
|
|
369
|
-
`legion state --json` whose `issue` is `<KEY>` and whose `role` is `merger`; publish to
|
|
370
|
-
`notifications.role.` followed by that key) to republish it; never guess a sha.
|
|
371
|
-
|
|
372
|
-
**Finding the tree's architect.** Never hand-format a role token: the daemon lower-cases the
|
|
373
|
-
issue key inside it (`LEGION-16` becomes `legion-16`) and rejects any other shape, so a token
|
|
374
|
-
you assemble from `<KEY>` never matches a live role. Read it instead: `legion state --json`
|
|
375
|
-
gives `issues[<KEY>].parent`; follow `parent` until it is absent — that key is the root (the
|
|
376
|
-
`trees` map lists the same roots). Then take the `roles` key whose `issue` equals that root and
|
|
377
|
-
whose `role` is `architect`, and publish to `notifications.role.` followed by that exact key.
|
|
378
|
-
Every registered root architect and phase worker appears in `roles`, so the lookup is
|
|
379
|
-
unambiguous. (Phase workers get an addressing line in their system prompt; the controller does
|
|
380
|
-
not, so state is your only source.)
|
|
381
|
-
|
|
382
|
-
**After a successful merge, publish nothing to the architect.** The daemon derives
|
|
383
|
-
`{type:"pr-merged", pr, mergeCommitSha}` from GitHub's own merged webhook and routes it to the
|
|
384
|
-
tree's architect itself. A second copy from you would make the architect run its sign-off twice.
|
|
385
|
-
|
|
386
|
-
**Policy questions go to Sami.** Whether a pull request should merge at all, whether an admin
|
|
387
|
-
merge is warranted, or a gate that looks wrong for this repository is not a controller judgment:
|
|
388
|
-
ask with `dispatch_ask` on the issue, in plain sentences, and leave the READY pending until the
|
|
389
|
-
answer arrives.
|
|
@@ -22,10 +22,11 @@ retrospective's durable output.
|
|
|
22
22
|
Retro writes **no `.legion` file**, so it never re-dirties the cleaned handoff tree.
|
|
23
23
|
4. The merger verifies the tip is the approved head plus commits that change only
|
|
24
24
|
`docs/solutions/` — `jj diff --from <approved-sha> --to <tip-sha> --summary`, quoted in READY —
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
posts `READY` on the Dispatch issue, and publishes the same packet to the project's merge-queue
|
|
26
|
+
role when configured. A human merges under the repository's GitHub branch-protection and
|
|
27
|
+
CODEOWNERS requirements; GitHub's merge queue participates only when the repository enables it.
|
|
28
|
+
5. After that merge, the implementer — not the reviewer or merger — verifies the change in production
|
|
29
|
+
and records it on the PR and the issue (Sami, 2026-09-13, verbatim:
|
|
29
30
|
"the agent that developed it should be responsible for testing in production"). The
|
|
30
31
|
architect's sign-off waits for that record.
|
|
31
32
|
The record is the pull request's `Production:` line, one pull-request comment, and a
|
|
@@ -176,8 +176,8 @@ Four facts about `gh` in a worker pane. The `gh` on your `PATH` is a shim
|
|
|
176
176
|
that execs `legion gh -- "$@"`, so `gh …` and `legion gh -- …` are the same call, and each call
|
|
177
177
|
redeems a fresh token from your session's grant — identity is supplied per call, never stored.
|
|
178
178
|
Never run `gh auth login` or `gh auth setup-git`; there is no login state to create. The shim
|
|
179
|
-
refuses `pr merge` (and a raw `gh api …/merge`
|
|
180
|
-
|
|
179
|
+
refuses `pr merge` (and a raw `gh api …/merge` or a GraphQL mutation) for every role: Legion never
|
|
180
|
+
merges. It also refuses every GitHub-issue write — the `issue`
|
|
181
181
|
subcommand's `comment`, `create`, `edit`, `close`, `reopen`, `delete`, `pin`, `unpin`, `transfer`,
|
|
182
182
|
`lock`, `unlock`, and `develop`, and any raw `gh api` call to an `/issues` path whose method is not
|
|
183
183
|
GET (an explicit `-X`, or the POST that `-f`/`-F`/`--input` imply; pull-request conversation
|
|
@@ -244,10 +244,10 @@ branch name alone is ambiguous. The credential helper and `legion gh` provide th
|
|
|
244
244
|
identity; never export, fetch, or replace a token. Other phases advance the existing branch
|
|
245
245
|
rather than creating a replacement bookmark or PR.
|
|
246
246
|
|
|
247
|
-
## PR body and
|
|
247
|
+
## PR body and READY discipline
|
|
248
248
|
|
|
249
|
-
The implementer writes the PR body in the
|
|
250
|
-
|
|
249
|
+
The implementer writes the PR body in the READY format from the moment the PR opens, and every
|
|
250
|
+
later phase keeps it current rather than replacing it:
|
|
251
251
|
|
|
252
252
|
```
|
|
253
253
|
## Verification
|
|
@@ -282,13 +282,13 @@ Verified the implementer's proof by <re-running its command | driving the same s
|
|
|
282
282
|
|
|
283
283
|
**A proof** is the changed behaviour exercised on the surface a user reaches it through, recorded
|
|
284
284
|
as the exact command or run id, what was observed, the head SHA, and one negative control —
|
|
285
|
-
a deliberately broken input and the refusal or failure
|
|
285
|
+
a deliberately broken input and the refusal or failure observed. The surface is
|
|
286
286
|
**production-like** — the repository's real-process test harness and fixtures, a sandbox
|
|
287
287
|
repository, a real browser, a devN stack, staging, or a local stack with real migrations, one that
|
|
288
288
|
has the resource the change touches — and each `E2E` line carries a **link** to that run,
|
|
289
|
-
screenshot, or e2e;
|
|
290
|
-
|
|
291
|
-
|
|
289
|
+
screenshot, or e2e; human review does not replace user-facing verification, and a green unit suite
|
|
290
|
+
is not it. A unit or integration test is a regression lock, never proof of a criterion. Sami,
|
|
291
|
+
2026-09-13, verbatim: "They need to test everything in a production-like
|
|
292
292
|
environment before merging, and it is the agent that develops the feature that is responsible
|
|
293
293
|
for doing that. If there's anything blocking that, we need to fix it: if it's infrastructure, we
|
|
294
294
|
need to fix it; if it's tooling, we need to develop it; if it's skills, we need to fix the skills
|
|
@@ -315,7 +315,7 @@ this proof.
|
|
|
315
315
|
whose newest comment is the opener's own `Accepted:` reply, one `resolveReviewThread` per
|
|
316
316
|
thread, prints `resolved <url>` or `left open <url> — newest reply by <login> is not an acceptance`,
|
|
317
317
|
and exits 1 naming the thread's URL and GitHub's message when GitHub refuses one; report that
|
|
318
|
-
exit to the architect, which opens
|
|
318
|
+
exit to the architect, which opens an ask for a human to resolve the thread by hand —
|
|
319
319
|
never skip it silently. The merger runs the same command once more before publishing READY
|
|
320
320
|
and does not publish while any `left open` line remains.
|
|
321
321
|
- **Correctness fixes land in this PR; cleanup is one named fast-follow.** A finding that
|
|
@@ -401,21 +401,19 @@ this proof.
|
|
|
401
401
|
- The merger runs `legion threads resolve --pr <n> --repo <owner>/<repo>` (it acts as the same
|
|
402
402
|
code-writing App as the implementer; resolving a thread changes no commit, so this run never
|
|
403
403
|
invalidates the approval), does not publish while any `left open` line remains or the command
|
|
404
|
-
exits 1 (report the thread to the architect instead), then
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
which must print nothing. Then it publishes
|
|
404
|
+
exits 1 (report the thread to the architect instead), then proves that rule with two commands.
|
|
405
|
+
First `cd -- "$LEGION_WORKSPACE" && jj -R "$LEGION_WORKSPACE" git fetch && jj -R
|
|
406
|
+
"$LEGION_WORKSPACE" diff --from <approved-sha> --to <tip-sha> --summary`, whose output is quoted
|
|
407
|
+
in READY (an empty output is quoted as `no file changes above the approved head`); then the same
|
|
408
|
+
with `'~docs/solutions'` appended, which must print nothing. The merger always posts
|
|
410
409
|
`READY #<n> at <current sha> (approved at <approved sha>) for <KEY> (<pr url>)` (the shape
|
|
411
|
-
`packages/pi-envoy/roles/merger.md` defines)
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
tester's `E2E` lines; a missing one is reported to the architect instead of published.
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
- **After the queue merges, the implementer verifies in production.** Sami, 2026-09-13,
|
|
410
|
+
`packages/pi-envoy/roles/merger.md` defines), its summary, and the PR body's gate facts as a
|
|
411
|
+
`dispatch_message` on the issue. When the `Legion addressing` line names a merge queue, it also
|
|
412
|
+
publishes the same packet there with `envoy_publish`; a 404 means the Dispatch message remains
|
|
413
|
+
the durable notice and the merger stays idle. The READY packet names both the implementer's and
|
|
414
|
+
tester's `E2E` lines; a missing one is reported to the architect instead of published. Legion
|
|
415
|
+
never merges.
|
|
416
|
+
- **After a human merges, the implementer verifies in production.** Sami, 2026-09-13,
|
|
419
417
|
verbatim: "the agent that developed it should be responsible for testing in production."
|
|
420
418
|
The architect sends the implementer back once the merge lands; the implementer watches the
|
|
421
419
|
deploy slot that carries the merge to `production-apply` (or the equivalent publish step),
|
|
@@ -428,11 +426,10 @@ this proof.
|
|
|
428
426
|
carrying the Legion footer, and a `dispatch_message` on the issue — the reviewer and merger
|
|
429
427
|
read GitHub, the architect reads the issue. When the deploy that carries the merge has not
|
|
430
428
|
happened (a shared profile still holding the previous plugin release, a daemon still running
|
|
431
|
-
the previous commit, a slot nobody has run), open a `dispatch_ask`
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
line.
|
|
429
|
+
the previous commit, a slot nobody has run), open a `dispatch_ask` naming the exact install or
|
|
430
|
+
restart step, with options for its outcomes, keep the `Production:` line at `pending <what is
|
|
431
|
+
missing>`, and complete the check once the human answers that it is done. Never record a
|
|
432
|
+
staging pass as the production check, and never let the architect sign off on a `pending` line.
|
|
436
433
|
|
|
437
434
|
## When no surface reaches the changed path
|
|
438
435
|
|