@sjawhar/opencode-legion-envoy 1.23.0 → 1.25.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
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 DELIVERY_CAPABILITIES = ["aside", "btw", "steer"];
|
|
13555
13556
|
function searchOwnerOf(result) {
|
|
13556
13557
|
return result.owner ?? { kind: "issue", key: result.issue.key };
|
|
13557
13558
|
}
|
|
@@ -13634,6 +13635,8 @@ var CommentEventPayloadSchema = object({
|
|
|
13634
13635
|
ask_id: string2().nullish(),
|
|
13635
13636
|
ask_question: string2().optional(),
|
|
13636
13637
|
ask_state: _enum2(["open", "answered", "resolved"]).optional(),
|
|
13638
|
+
ask_waiting_on: _enum2(["human", "agent"]).optional(),
|
|
13639
|
+
turn: _enum2(["human", "agent"]).nullish(),
|
|
13637
13640
|
anchor: object({ block_id: string2().nullable().optional(), quote: string2().optional() }).nullish(),
|
|
13638
13641
|
suggestion: object({ replace_with: string2().optional() }).nullish(),
|
|
13639
13642
|
author: object({ kind: string2(), id: string2() }).optional(),
|
|
@@ -13660,7 +13663,7 @@ var DispatchTargetedMessagePayloadSchema = MessageEventPayloadSchema.extend({
|
|
|
13660
13663
|
var MessageDeliveryEventPayloadSchema = object({
|
|
13661
13664
|
message_id: string2().optional(),
|
|
13662
13665
|
attempt: number2().int().positive().optional(),
|
|
13663
|
-
delivery: _enum2(
|
|
13666
|
+
delivery: _enum2(DELIVERY_CAPABILITIES).optional(),
|
|
13664
13667
|
session_id: string2().optional(),
|
|
13665
13668
|
target: string2().optional(),
|
|
13666
13669
|
title: string2().optional(),
|
|
@@ -13751,6 +13754,16 @@ function documentOwnerValidation(requireArtifact, alwaysRequireArtifact = false)
|
|
|
13751
13754
|
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."
|
|
13752
13755
|
};
|
|
13753
13756
|
}
|
|
13757
|
+
var commentValidation = (() => {
|
|
13758
|
+
const owner = documentOwnerValidation(true);
|
|
13759
|
+
return {
|
|
13760
|
+
check: (value) => {
|
|
13761
|
+
const input = value;
|
|
13762
|
+
return owner.check(value) && (input.turn === undefined || typeof input.reply_to_ask === "string");
|
|
13763
|
+
},
|
|
13764
|
+
message: `${owner.message} turn requires reply_to_ask.`
|
|
13765
|
+
};
|
|
13766
|
+
})();
|
|
13754
13767
|
var SPEC_SECTIONS = [
|
|
13755
13768
|
"Summary",
|
|
13756
13769
|
"Decisions needed",
|
|
@@ -13846,9 +13859,10 @@ var dispatchToolSpecs = [
|
|
|
13846
13859
|
occurrence: z.number({ int: true, min: 0 }).describe("Optional zero-based occurrence of quote.").optional(),
|
|
13847
13860
|
body: z.string({ max: 2000 }).describe("Review comment, at most 2,000 characters."),
|
|
13848
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(),
|
|
13849
|
-
reply_to_ask: z.string().describe("Optional ask id to reply to, threading this comment under that question. Mutually " + "exclusive with reply_to.").optional()
|
|
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
|
+
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()
|
|
13850
13864
|
}),
|
|
13851
|
-
validation:
|
|
13865
|
+
validation: commentValidation
|
|
13852
13866
|
},
|
|
13853
13867
|
{
|
|
13854
13868
|
name: "dispatch_suggest",
|
|
@@ -14220,7 +14234,7 @@ var LegionDaemonApi = {
|
|
|
14220
14234
|
queue: array(nonEmptyString)
|
|
14221
14235
|
}),
|
|
14222
14236
|
gates: record(string2(), stateGate),
|
|
14223
|
-
controllerLocator:
|
|
14237
|
+
controllerLocator: stateTreeLocator.optional(),
|
|
14224
14238
|
roles: record(string2(), stateRole),
|
|
14225
14239
|
controllerPendingNotices: number2().int().nonnegative(),
|
|
14226
14240
|
pendingStatusWrites: array(nonEmptyString),
|
|
@@ -14228,7 +14242,11 @@ var LegionDaemonApi = {
|
|
|
14228
14242
|
})
|
|
14229
14243
|
},
|
|
14230
14244
|
ControllerReady: {
|
|
14231
|
-
request: strictObject({
|
|
14245
|
+
request: strictObject({
|
|
14246
|
+
secret: nonEmptyString,
|
|
14247
|
+
sessionId: nonEmptyString,
|
|
14248
|
+
ompSessionFile: nonEmptyString.optional()
|
|
14249
|
+
}),
|
|
14232
14250
|
response: object({})
|
|
14233
14251
|
},
|
|
14234
14252
|
ProcessStarted: {
|
|
@@ -14348,17 +14366,23 @@ var LegionDaemonApi = {
|
|
|
14348
14366
|
response: object({})
|
|
14349
14367
|
},
|
|
14350
14368
|
Grant: {
|
|
14351
|
-
request:
|
|
14352
|
-
|
|
14353
|
-
|
|
14354
|
-
|
|
14355
|
-
|
|
14356
|
-
|
|
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
|
+
]),
|
|
14357
14378
|
response: object({ grantId: nonEmptyString, expiresAt: nonEmptyString })
|
|
14358
14379
|
},
|
|
14359
14380
|
GitHubToken: {
|
|
14360
|
-
request: strictObject({ grantId: nonEmptyString }),
|
|
14381
|
+
request: strictObject({ grantId: nonEmptyString, merge: literal(true).optional() }),
|
|
14361
14382
|
response: object({ token: nonEmptyString, appLogin: string2().endsWith("[bot]") })
|
|
14383
|
+
},
|
|
14384
|
+
GitCredential: {
|
|
14385
|
+
request: strictObject({ grantId: nonEmptyString })
|
|
14362
14386
|
}
|
|
14363
14387
|
};
|
|
14364
14388
|
// ../contracts/src/repo.ts
|
|
@@ -15613,21 +15637,34 @@ async function executeDispatchTool(input) {
|
|
|
15613
15637
|
if (replyTo !== undefined && replyToAsk !== undefined) {
|
|
15614
15638
|
throw new Error("reply_to and reply_to_ask cannot both be set");
|
|
15615
15639
|
}
|
|
15640
|
+
const requestedTurn = optionalString(args, "turn");
|
|
15641
|
+
if (requestedTurn !== undefined && replyToAsk === undefined) {
|
|
15642
|
+
throw new Error("turn requires reply_to_ask");
|
|
15643
|
+
}
|
|
15644
|
+
if (requestedTurn !== undefined && requestedTurn !== "agent" && requestedTurn !== "human") {
|
|
15645
|
+
throw new Error("turn must be agent or human");
|
|
15646
|
+
}
|
|
15616
15647
|
const commentInput = {
|
|
15617
15648
|
body: stringArg(args, "body"),
|
|
15618
15649
|
...anchored === undefined ? {} : { anchor: anchored },
|
|
15619
15650
|
...replyTo === undefined ? {} : { reply_to: replyTo },
|
|
15620
15651
|
...replyToAsk === undefined ? {} : { ask_id: replyToAsk },
|
|
15652
|
+
...requestedTurn === undefined ? {} : { turn: requestedTurn },
|
|
15621
15653
|
actor
|
|
15622
15654
|
};
|
|
15623
15655
|
const comment = resolved?.owner.kind === "project" ? await client.artifactComment(resolved.artifact.id, commentInput) : await client.comment(issue(), commentInput);
|
|
15656
|
+
const askState = comment.turn === null ? "" : ` (ask now waiting on ${comment.turn})`;
|
|
15624
15657
|
return {
|
|
15625
|
-
text: `Posted comment ${comment.id}`,
|
|
15658
|
+
text: `Posted comment ${comment.id}${askState}`,
|
|
15626
15659
|
details: resolved === undefined ? {
|
|
15627
15660
|
issue: comment.issue_key,
|
|
15628
15661
|
topic: dispatchIssueSubject(issue(), ">"),
|
|
15629
|
-
comment: comment.id
|
|
15630
|
-
|
|
15662
|
+
comment: comment.id,
|
|
15663
|
+
...comment.turn === null ? {} : { ask_waiting_on: comment.turn }
|
|
15664
|
+
} : writeResultDetails(resolved, {
|
|
15665
|
+
comment: comment.id,
|
|
15666
|
+
...comment.turn === null ? {} : { ask_waiting_on: comment.turn }
|
|
15667
|
+
})
|
|
15631
15668
|
};
|
|
15632
15669
|
}
|
|
15633
15670
|
case "dispatch_suggest": {
|
package/package.json
CHANGED
package/skills/dispatch/SKILL.md
CHANGED
|
@@ -42,6 +42,16 @@ agent's `envoy.json` to set the GitHub OAuth callback origin: the value must
|
|
|
42
42
|
be the exact URL humans type in their browser, and the GitHub App callback is
|
|
43
43
|
`<DISPATCH_SERVER_URL>/auth/callback`.
|
|
44
44
|
|
|
45
|
+
### Finding a route
|
|
46
|
+
|
|
47
|
+
The tools cover the everyday surface. For anything else, ask the server: `GET /api/v1` (no
|
|
48
|
+
credential) returns every route as `{method, path, auth, description}` sorted by path — `auth`
|
|
49
|
+
is `public`, `any` (a human or a bearer), `human` (a bearer gets `403 HUMAN_ONLY`), or `bearer`.
|
|
50
|
+
A path Dispatch does not serve under `/api` or `/v1` answers
|
|
51
|
+
`404 {"code":"NOT_FOUND","error":"no route for GET /v1/issues","hint":"GET /api/v1 lists every
|
|
52
|
+
route"}`; when you see that, you typed the path wrong — read the index rather than guessing. Every
|
|
53
|
+
`/api/v1` error body carries a `code`; branch on the code, never on the text.
|
|
54
|
+
|
|
45
55
|
## Writing a spec
|
|
46
56
|
|
|
47
57
|
A spec has two readers: the human who decides reads the top; the implementer who builds reads the
|
|
@@ -194,6 +204,14 @@ an answer: the human did not understand the question or needs more before choosi
|
|
|
194
204
|
in the same thread with `dispatch_comment({ reply_to_ask })`, or reword the question itself with `dispatch_edit_ask` when the wording
|
|
195
205
|
was the problem; either puts the ask back in front of them. Do not open a second ask.
|
|
196
206
|
|
|
207
|
+
Every reply to an open ask says whose turn it is next, and the Inbox files the ask by that, not by who spoke last. Your plain reply
|
|
208
|
+
(`turn` omitted, or `turn: "human"`) hands the turn to the human: the ask returns to their `Waiting on you`. When you are not done
|
|
209
|
+
yet — "dispatched two auditors, back with results", "checking the release branch, back shortly", any working-on-it note — reply with
|
|
210
|
+
`turn: "agent"`: the note lands in the thread, the ask stays under `Waiting on agents`, and the human is not told to act. Use
|
|
211
|
+
`turn: "agent"` for every progress note and `turn: "human"` (the default) only when you need them. A human's reply always hands the
|
|
212
|
+
turn to you. The result names the state (`ask now waiting on agent` / `human`), the delivered `comment.created` carries it as
|
|
213
|
+
`ask_waiting_on`, and every ask read carries it as `waiting_on`.
|
|
214
|
+
|
|
197
215
|
## Approval of a spec
|
|
198
216
|
|
|
199
217
|
Approval is a property of a document, not a question you phrase: a human approves a specific version, the way a pull-request
|
|
@@ -300,7 +318,7 @@ an ask block without a question or with a blank option is rejected with `INVALID
|
|
|
300
318
|
Add feedback with:
|
|
301
319
|
|
|
302
320
|
```ts
|
|
303
|
-
dispatch_comment({ issue?, project?, artifact?, ref?, quote?, occurrence?, body, reply_to?, reply_to_ask? })
|
|
321
|
+
dispatch_comment({ issue?, project?, artifact?, ref?, quote?, occurrence?, body, reply_to?, reply_to_ask?, turn? })
|
|
304
322
|
```
|
|
305
323
|
|
|
306
324
|
It returns issue or project-document owner details plus `comment` and, for writes, `topic`.
|
|
@@ -309,8 +327,10 @@ It returns issue or project-document owner details plus `comment` and, for write
|
|
|
309
327
|
for display. Omit both for a floating issue comment. A reply (`reply_to`/`reply_to_ask`) takes no
|
|
310
328
|
`quote`; it belongs to its parent's anchor. Reply to any comment in a thread; the server keeps
|
|
311
329
|
threads flat. A reply to a resolved thread reopens it. Use `reply_to_ask` to reply directly under a
|
|
312
|
-
question asked with `dispatch_ask
|
|
313
|
-
|
|
330
|
+
question asked with `dispatch_ask`; `turn` (only with `reply_to_ask`) says who holds the turn after
|
|
331
|
+
the reply — `agent` for a progress note that keeps the ask waiting on you, `human` (the default) when
|
|
332
|
+
the human needs to act; see [Asking](#asking). Comments are edited only by their author from the
|
|
333
|
+
dashboard. A delivered `comment.created` event carries the comment `id`; reply to it with
|
|
314
334
|
`dispatch_comment({ reply_to: <id> })`.
|
|
315
335
|
|
|
316
336
|
Propose an exact replacement instead of describing it:
|
|
@@ -395,7 +415,10 @@ dispatch_message({
|
|
|
395
415
|
delivery can post its answer automatically; use this call when the frame asks the primary agent to
|
|
396
416
|
reply. `dispatch_message` itself never carries `target` or `delivery`: agent-to-agent traffic goes
|
|
397
417
|
through Envoy or the hub. A bearer that targets over HTTP names its own session in `actor`
|
|
398
|
-
(`{kind: "session", id}`), and the card shows that session as the author.
|
|
418
|
+
(`{kind: "session", id}`), and the card shows that session as the author. `GET /api/v1/agents`
|
|
419
|
+
(any authenticated caller) lists live sessions with their capabilities (`aside`, `btw`, `steer`);
|
|
420
|
+
target only a session that advertises the mode you want. Sending to a session with no issue
|
|
421
|
+
(`POST /api/v1/agents/{session_id}/messages`) stays human-only.
|
|
399
422
|
|
|
400
423
|
## What comes back
|
|
401
424
|
|
package/skills/envoy/SKILL.md
CHANGED
|
@@ -90,6 +90,16 @@ envoy_send(
|
|
|
90
90
|
)
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
### Delivery capabilities
|
|
94
|
+
|
|
95
|
+
Each session row from `envoy_sessions` carries `capabilities`, the targeted-delivery modes that
|
|
96
|
+
session's host honours: `aside` (a message queued beside the model's work), `btw` (an ephemeral
|
|
97
|
+
question the host answers without disturbing the current turn), and `steer` (an interjection at
|
|
98
|
+
the next tool boundary). An OMP session advertises `aside`, `btw`, and `steer` (`aside` and
|
|
99
|
+
`steer` on a host without `askEphemeral`); a Claude Code session advertises `aside` only, because
|
|
100
|
+
its channel notifications queue for the next turn. Target a session only with a mode it
|
|
101
|
+
advertises.
|
|
102
|
+
|
|
93
103
|
## Waiting for CI or a merge
|
|
94
104
|
|
|
95
105
|
Subscribe to `notifications.github.example-org.example-repo.pr.42.>` and end the turn. The single
|
|
@@ -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>-<
|
|
32
|
-
`architect` encodes to `legion-acme-
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
|
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
|
|
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
|
|
120
|
+
legion status <issue> backlog
|
|
88
121
|
```
|
|
89
122
|
|
|
90
|
-
(or `
|
|
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
|
|
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
|
|
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
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
of
|
|
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
|