@sjawhar/claude-envoy 1.11.2 → 1.12.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.
@@ -2,5 +2,5 @@
2
2
  "name": "claude-envoy",
3
3
  "description": "Deliver Envoy events as Claude Code channel notifications and expose Envoy MCP tools.",
4
4
  "mcpServers": "./.mcp.json",
5
- "version": "1.11.2"
5
+ "version": "1.12.0"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-envoy",
3
- "version": "1.11.2",
3
+ "version": "1.12.0",
4
4
  "description": "Deliver Envoy events as Claude Code channel notifications and expose Envoy MCP tools.",
5
5
  "mcpServers": {}
6
6
  }
package/dist/dispatch.js CHANGED
@@ -16849,6 +16849,7 @@ var LEGION_ROLES = [
16849
16849
  "reviewer",
16850
16850
  "merger"
16851
16851
  ];
16852
+ var GATED_STEPS = ["decisions", "criteria", "spec"];
16852
16853
  var PROJECT_TOKEN = /^[a-z0-9]+$/;
16853
16854
  var DISPATCH_KEY_PATTERN = /^([A-Z][A-Z0-9]*)-([0-9]+)$/;
16854
16855
  function roleForPhase(phase) {
@@ -38498,6 +38498,7 @@ var LEGION_ROLES = [
38498
38498
  "reviewer",
38499
38499
  "merger"
38500
38500
  ];
38501
+ var GATED_STEPS = ["decisions", "criteria", "spec"];
38501
38502
  var PROJECT_TOKEN = /^[a-z0-9]+$/;
38502
38503
  var DISPATCH_KEY_PATTERN = /^([A-Z][A-Z0-9]*)-([0-9]+)$/;
38503
38504
  function roleForPhase(phase) {
@@ -52032,6 +52033,15 @@ var legionGateView = object({
52032
52033
  currentVersion: number2().int().positive(),
52033
52034
  approvedVersion: number2().int().positive().nullable()
52034
52035
  });
52036
+ var legionDesignStep = _enum2(GATED_STEPS);
52037
+ var legionGatedStepView = object({
52038
+ step: legionDesignStep,
52039
+ artifactId: nonEmptyString.optional(),
52040
+ currentVersion: number2().int().positive().optional(),
52041
+ approvedVersion: number2().int().positive().nullable(),
52042
+ status: _enum2(["unregistered", "waiting", "approved", "changes_requested"]),
52043
+ changesReason: nonEmptyString.optional()
52044
+ });
52035
52045
  var legionSlotView = object({
52036
52046
  index: number2().int().nonnegative(),
52037
52047
  admittedAt: timestamp,
@@ -52119,6 +52129,7 @@ var legionIssue = object({
52119
52129
  workers: partialRecord(string2(), legionPhaseView),
52120
52130
  pullRequest: legionPullRequestView.optional(),
52121
52131
  designGate: legionGateView.optional(),
52132
+ gatedSteps: array(legionGatedStepView).optional(),
52122
52133
  slot: legionSlotView.optional(),
52123
52134
  waitingOn: legionWaitingOn.optional(),
52124
52135
  check: legionCheckView.optional(),
@@ -52301,7 +52312,7 @@ var LegionControllerRegisterResponse = object({
52301
52312
  });
52302
52313
  var LegionControllerSecretResponse = object({
52303
52314
  secret: nonEmptyString,
52304
- designGate: _enum2(["root-issues", "off"])
52315
+ designGateSteps: array(legionDesignStep)
52305
52316
  });
52306
52317
  var LegionControllerCredentialResponse = object({
52307
52318
  owners: record(nonEmptyString, object({
@@ -52405,7 +52416,8 @@ var LegionGateRegisterRequest = strictObject({
52405
52416
  grantId: nonEmptyString,
52406
52417
  issue: nonEmptyString,
52407
52418
  artifactId: uuid2(),
52408
- version: number2().int().positive()
52419
+ version: number2().int().positive(),
52420
+ step: legionDesignStep.optional()
52409
52421
  });
52410
52422
  var LegionWaveReleaseRequest = strictObject({
52411
52423
  grantId: nonEmptyString,
@@ -55133,9 +55145,9 @@ var import_nats2 = __toESM(require_mod4(), 1);
55133
55145
 
55134
55146
  // package.json
55135
55147
  var name = "@sjawhar/claude-envoy";
55136
- var version2 = "1.11.2";
55148
+ var version2 = "1.12.0";
55137
55149
  var legion = {
55138
- daemonApiVersion: 54
55150
+ daemonApiVersion: 55
55139
55151
  };
55140
55152
 
55141
55153
  // src/channel-forwarder.ts
@@ -15848,6 +15848,7 @@ var LEGION_ROLES = [
15848
15848
  "reviewer",
15849
15849
  "merger"
15850
15850
  ];
15851
+ var GATED_STEPS = ["decisions", "criteria", "spec"];
15851
15852
  var PROJECT_TOKEN = /^[a-z0-9]+$/;
15852
15853
  var DISPATCH_KEY_PATTERN = /^([A-Z][A-Z0-9]*)-([0-9]+)$/;
15853
15854
  function roleForPhase(phase) {
@@ -29,9 +29,9 @@ You own one issue tree from its first decision to the merge that closes it. The
29
29
  - A child issue is a separately deliverable change: its own pull request in its own repository, or its own deploy. Never use a child to track a to-do, a fix found in review, a proof of the root's acceptance, or a part of your own work: do it in this tree. Each child costs a workspace, an admission wait and a review loop. Before any `dispatch issue` that creates a child, and before `release_children`, run the red-team check in `skill://legion-architect/references/decomposition.md`.
30
30
  - Every child names the running system its criteria are proven on (the command, service or page the tester drives) and the repository skill that drives it. If the repository cannot exercise a criterion end to end, building that path is work of this tree.
31
31
  - The issue's primary document is the root spec: extend it in place, because a second "spec" artifact replaces the human's document. Mark each requirement of it as a `requirement` block, and write the issue's Verification document (`verification.md`) beside it: one `check` block per thing that must be proved, each with `serves` naming the requirements it serves, its `surface` (`browser`, `cli` or `api`), its `run`, its `when` (`before_merge`, `after_merge` or `default_branch`) and its `negative_control`. The checks are the gate every later role reads: the implementer and the tester record a result against each, and the reviewer approves only once every `before_merge` check of the issue has a passing result current at the head. The waves and the plan's tasks go in the children and the planner's `plan` handoff, not the root spec.
32
- - Read `skill://legion-architect/references/design-gate.md` before you write the root spec and before any edit to it after approval, because any new version closes the gate. It also says which gate runs, and that a sub-architect runs none.
33
- - File every child of your decomposition before `dispatch request-approval`, and before `register_gate` under `gates.design: off`: the gate's opening starts your root's own first phase unless Dispatch shows an open child under it, so a child filed after it opens leaves the root working beside its children.
34
- - A root architect then ends its turn and releases no wave until a delivered wake shows `design-approved`: the daemon starts no phase in the tree before it. A root that waited on a person's answer gets no `design-approved` notice: a resume task saying "this resume is your design-approved wake" is that wake. A sub-architect waits for none and releases its children with `release_children`.
32
+ - Read `skill://legion-architect/references/design-gate.md` before you write the root spec and before any edit to it after approval, because any new version closes the gate. It also names the design gate steps your `Legion addressing` line declares, the document each step records, and says that a sub-architect runs none.
33
+ - File every child of your decomposition before `dispatch request-approval`, and before `register_gate` where your line gates no step: the gate's opening starts your root's own first phase unless Dispatch shows an open child under it, so a child filed after it opens leaves the root working beside its children.
34
+ - A root architect then ends its turn and releases no wave until every gated step is open: a delivered wake shows `design-approved` on the root, and under more than one step `gatedSteps` in your record (the `legion` tool's `read_record`) shows each step `approved`. The daemon starts no phase in the tree before that. A root that waited on a person's answer gets no `design-approved` notice: a resume task saying "this resume is your design-approved wake" is that wake. A sub-architect waits for none and releases its children with `release_children`.
35
35
 
36
36
  ## 2. Children in flight
37
37
 
@@ -30,21 +30,35 @@ answer, Slack message or direct message of a `Filed for <person> at their reques
30
30
  evidence of a controller's `Filed for Legion from <role> on <issue>:` line
31
31
  ([The `Filed for` line](skill://legion-controller/references/finding-asks.md#the-filed-for-line)).
32
32
 
33
- ## Which gate runs
33
+ ## Which steps are gated
34
34
 
35
- The approval step runs only when the `Design gate policy` sentence of your `Legion addressing` line
36
- says `gates.design: root-issues`. When it says `gates.design: off`, write the spec and register it
37
- with `register_gate` at its current version, with no approval step: do not request approval and do
38
- not wait for `design-approved`; the gate opens at registration. File every child of your
39
- decomposition before that registration, since the gate's opening starts the root's own first phase
40
- unless Dispatch shows an open child under it. A sub-architect on a child issue
41
- has no policy line and never runs the gate.
35
+ The `Design gate steps` sentence of your `Legion addressing` line names the steps this deployment
36
+ gates, in the order it gates them, each in backticks: ``Design gate steps: `spec`.``,
37
+ ``Design gate steps: `decisions`, `criteria`.``, or `Design gate steps: none.` A sub-architect on
38
+ a child issue has no such sentence and never runs the gate.
39
+
40
+ Each step is one document of the root issue, and the daemon keeps one gate record for each step:
41
+
42
+ | Step | The document it records |
43
+ | --- | --- |
44
+ | `decisions` | the issue's decisions document (`decisions.md`) |
45
+ | `criteria` | its Verification document (`verification.md`) |
46
+ | `spec` | its primary document |
47
+
48
+ The tree's work starts when every step the sentence names is open, so run the armed sequence below
49
+ once for each of them, each on that step's own document. Under `Design gate steps: none.` write
50
+ the spec and register it with `register_gate` at its current version, with no `step` and no
51
+ approval step: do not request approval and do not wait for `design-approved`; the gate opens at
52
+ registration. File every child of your decomposition before that registration, since the gate's
53
+ opening starts the root's own first phase unless Dispatch shows an open child under it.
42
54
 
43
55
  ## The armed gate
44
56
 
45
- When the gate is armed, run this exact sequence before the tree's work starts, with every child of
46
- your decomposition filed before step 4, since the gate's opening starts the root's own first phase
47
- unless Dispatch shows an open child under it:
57
+ Run this exact sequence for each gated step, before the tree's work starts, with every child of
58
+ your decomposition filed before step 4, since the gate's opening starts the root's own first
59
+ phase unless Dispatch shows an open child under it. One approval request covers the `criteria`
60
+ and the `spec` step together (step 4), and each of them is registered on its own document
61
+ (step 5); the `decisions` step is a request of its own.
48
62
 
49
63
  1. Extend the primary document in place:
50
64
 
@@ -74,22 +88,31 @@ unless Dispatch shows an open child under it:
74
88
  ```
75
89
 
76
90
  `--include-verification` asks for the spec and the Verification document in one request, each
77
- at its own current settled version, and the human's answer records a review on each. An issue
78
- with no `verification.md` is refused `NO_VERIFICATION_DOCUMENT`, and one whose Verification
79
- document has no settled version `NO_VERSION`.
91
+ at its own current settled version, and the human's answer records a review on each: that one
92
+ request covers the `criteria` and the `spec` step together. An issue with no `verification.md`
93
+ is refused `NO_VERIFICATION_DOCUMENT`, and one whose Verification document has no settled
94
+ version `NO_VERSION`.
95
+
96
+ The `decisions` step is a request of its own, since `--include-verification` is for the issue's
97
+ spec alone and any other document is refused `NOT_SPEC_DOCUMENT`:
98
+
99
+ ```bash
100
+ dispatch request-approval --issue <root issue> --artifact decisions.md --summary '<what the human is approving> Reviews: <gap result>, <claim result>; <what they do not show>.'
101
+ ```
80
102
 
81
103
  `--summary` says in one to three sentences what the human is approving and nothing else (no
82
104
  commentary and no open question), then one sentence with both reviews' results and what they do
83
105
  not show, for example `Reviews: no open gap, 14 of 14 claims verified; neither shows that the
84
106
  design works once built.` The primary document is the default; the
85
107
  command prints `Approval requested for spec.md (document id <UUID>) at version <N> (ask <id>)`.
86
- 5. Register the spec's version with the daemon:
108
+ 5. Register each gated step's document and version with the daemon, one call for each step:
87
109
 
88
110
  ```ts
89
111
  legion({
90
112
  op: "register_gate",
91
113
  issue: "<root issue>",
92
- artifactId: "<UUID>", // the spec's document id, which the request printed
114
+ step: "<decisions|criteria|spec>", // the step this document is for
115
+ artifactId: "<UUID>", // that step's document id, which the request printed
93
116
  version: <N>, // the version the human is asked to approve
94
117
  })
95
118
  ```
@@ -99,10 +122,12 @@ unless Dispatch shows an open child under it:
99
122
  document's approval events by that id alone; the `legion` tool looks a slug or file name up in
100
123
  Dispatch and hands the daemon the id.
101
124
 
102
- The gate records one document. A request naming the spec and the Verification document sends
103
- an `artifact.approved` event for each, and the gate opens on the spec's alone, so register the
104
- spec's id and version and never the Verification document's: a Verification-only version
105
- closes and opens nothing. INFRA-175 is where that changes.
125
+ Each step records its own document, so register the spec's id and version under `step: "spec"`
126
+ and the Verification document's under `step: "criteria"`: the request that named both sent an
127
+ `artifact.approved` event for each, and each step opens on its own document's alone. Leave
128
+ `step` out only where your line names one step, which is the whole list a one-step deployment
129
+ gates. Register no step your line does not name: a step the deployment does not gate is
130
+ refused, and a record of it would hold nothing open.
106
131
 
107
132
  Facts about the request:
108
133
 
@@ -124,18 +149,38 @@ Facts about the request:
124
149
  latest version Dispatch holds for that document (the message names both), and a version Dispatch
125
150
  never wrote is no registration: the gate is as it was. Read the document's latest version, check
126
151
  it is the spec the human is asked to approve, and `register_gate` that version.
127
-
128
- Then end your turn. The registration hands the spec to the human: when nothing else of the tree is
129
- at work, the root returns its admission slot and waits on its design gate (`awaiting_person`), as a
130
- root question handed to the human does ("Every turn" in `skill://legion-architect/SKILL.md`). Stay
131
- live to read the human's comments and replies. Their approval or changes request delivers your next
132
- task, and so does a newer spec version once Dispatch shows the request back on your turn. While the
133
- root waits on a question instead, the human's approval or changes request of the spec answers that
134
- wait too, and the task says how the spec stands. Release no wave until a delivered wake shows
135
- `design-approved` on the root, or a task says its design gate is open: the daemon sends a root
136
- waiting on a person no `design-approved` notice, and "this resume is your design-approved wake" is
137
- that wake. On `design-changes-requested`: revise the spec as the human's reason asks, run step 3's
138
- two reviews on the new version, resolve every finding, request approval again and end your turn. A
152
+ - `register_gate` refused 400 `GATE_STEP_REQUIRED` means this deployment gates more than one step
153
+ and your call named none; the message names the steps it gates. Call again with `step` naming
154
+ the one that document is for.
155
+ - `register_gate` refused 409 `GATE_STEP_NOT_CONFIGURED` means the `step` you named is not one
156
+ this deployment gates; the message names the steps it does gate. Register those and no other.
157
+ - `register_gate` refused 409 `GATE_STEP_DOCUMENT_MISMATCH` means the document you named is not
158
+ that step's document of that issue; the message names the step and the document it records.
159
+ Register that step's own document, as the table in "Which steps are gated" says.
160
+
161
+ Each of those four refusals is given before anything is recorded, so the gate is as it was.
162
+
163
+ A `design-approved` or `design-changes-requested` notice carries a version and a reason and names
164
+ no step. Read your own issue record with the `legion` tool's `read_record` to see which step the
165
+ review was of: `gatedSteps` carries one entry for each gated step, with its `artifactId`, its
166
+ `currentVersion`, its `approvedVersion`, its `status` (`unregistered`, `waiting`, `approved` or
167
+ `changes_requested`) and the `changesReason` of a step whose reviewer asked for changes. That
168
+ list, and not the notice, says which steps are open and which still hold the tree's work.
169
+
170
+ Then end your turn. The registrations hand those documents to the human: when nothing else of the
171
+ tree is at work, the root returns its admission slot and waits on its design gate
172
+ (`awaiting_person`), as a root question handed to the human does ("Every turn" in
173
+ `skill://legion-architect/SKILL.md`). Stay live to read the human's comments and replies. Their
174
+ approval or changes request delivers your next task, and so does a newer spec version once
175
+ Dispatch shows the request back on your turn. While the root waits on a question instead, the
176
+ human's approval or changes request of the spec answers that wait too, and the task says how the
177
+ spec stands. Release no wave until every gated step is open: a delivered wake shows
178
+ `design-approved` on the root, or a task says its design gate is open, and under more than one
179
+ step `gatedSteps` in your record shows each one `approved`. The daemon sends a root waiting on a
180
+ person no `design-approved` notice, and "this resume is your design-approved wake" is that wake.
181
+ On `design-changes-requested`: revise the document that step records as the human's reason asks,
182
+ run step 3's two reviews on the new version, resolve every finding, request approval again and
183
+ end your turn. A
139
184
  `design-changes-requested` may name a version below the latest, or arrive after another for the
140
185
  same version: it is a request the human made that reached the daemon late, and no approval came
141
186
  after it. Each request arrives once. The gate is as the newest review left it; still read the
@@ -143,9 +188,11 @@ reason, and answer it in the spec if the latest version does not already.
143
188
 
144
189
  ## After approval
145
190
 
146
- Approval is pinned to the spec version: any new version closes the gate again, and no
147
- `design-approved`-type wake says so; you know from your own edit, or from the `artifact.version`
148
- event on your issue. The exceptions are the version a human's own answer to a decision block
191
+ Each step's approval is pinned to the version of the document that step records: a new version of
192
+ that document closes that step again, and no `design-approved`-type wake says so; you know from
193
+ your own edit, or from the `artifact.version` event on your issue. One step closing holds the
194
+ tree's work whatever the other steps stand at. The exceptions are the version a human's own
195
+ answer to a decision block
149
196
  writes when that answer is the whole change since the version they approved, and a version whose
150
197
  text is exactly the text of the version they approved, with no decision block in it decided
151
198
  since, such as an accidental edit undone exactly: for either, Dispatch records an `approved`
@@ -18,14 +18,14 @@ and 7 (a conflict, the hand-over, the close) is in
18
18
 
19
19
  | Wake | Procedure |
20
20
  | --- | --- |
21
- | `carried-gate-opened` | Only a root whose catch-up said `carried` gets it. Its `version` and `reason`: a human approved the old root's spec document at `version`, which opened the gate this root carried; the daemon has released any READY it refused and started any child waiting on it. It is not `design-approved`: do not proceed to section 2 or release a wave because of it, and start nothing. |
21
+ | `carried-gate-opened` | Only a root whose catch-up said `carried` gets it. Its `version` and `reason`: a human approved the old root's spec document at `version`, which opened the gate this root carried; the daemon has released any READY it refused and started any child waiting on it. It names no step: the carry brings the old root's gate records, and the catch-up reports every gated step the carry did not bring as unregistered, so register each of those on this issue's own document for that step (`skill://legion-architect/references/design-gate.md`). It is not `design-approved`: do not proceed to section 2 or release a wave because of it, and start nothing. |
22
22
  | `child-status` | A child of your tree left the workflow or re-entered it, or joined your tree from the admission queue; the reason names the child and its new status, or reads `<KEY> joined the tree of <ROOT> from the admission queue`. `todo` (a human's move, your `release_children`, or your `rerun_child`) means it runs again under your tree from planning, and the daemon starts it; you start nothing. A child held for a blocker starts the same way once nothing holds it, and the reason, naming it and its root, says what cleared the hold: its last blocker's close, naming the issues it waited on, now done, or a person or you taking its blocked-by links off; you start nothing for it either. `backlog`, `icebox` or `triage` (a human's move, or your `park_child`) means the daemon has suspended its workers, and it advances no further until it is set back to `todo`. Joined from the admission queue means a root that waited for a slot and never ran, which a person made a child of your tree or your `release_children` adopted, now runs in your tree with no architect of its own and takes an admission slot of its own when its work starts, from planning when it is `todo` and parked otherwise; one that waits on an unfinished blocked-by issue is held for that blocker like any other child, and starts when nothing holds it; you start nothing. A reason saying an issue is beneath an ancestor whose reviewed head the daemon has already accepted is a person's `todo` the daemon recorded and holds at `admitted`: nothing starts it until that ancestor closes, and a `request_backward_move` of the ancestor is what runs it under the ancestor now. What the rest of the tree does is your decision. When a park leaves no child that an issue in `accepting` waits on, run that issue's last round (`skill://legion-architect/references/acceptance.md`). |
23
23
  | `child-closed` | Read the child completion and remaining open children. Re-scope open work, park obsolete work for good with `park_child` (first take its link off each sibling that waits on it, or park that sibling too, as section 2 says), and file any new child the result calls for, after the red-team check in `skill://legion-architect/references/decomposition.md`, and release it at once with `release_children`, blocked by what it builds on (section 2). When a worker waits on this child for a missing surface (see `phase-finished`), tell it to continue with `envoy_publish` to its role topic. The last child's close is not the end-game (section 3). When it was the last child an issue in `accepting` waits on, run that issue's last round (`skill://legion-architect/references/acceptance.md`). |
24
24
  | `child-release-stalled` | Its `reason` names a child your `release_children` released that your tree has not recorded, its parent, and why, one of three: the parent's release has not reached the daemon after three tries; or GitHub did not answer the App installation read of a repository on the child's parent chain, and the wave left the child unrecorded because a parent on that chain is not `todo`, or did not write the child `todo` because its parent is not recorded; or the daemon's recovery of such a repository, once GitHub answered, still left children unrecorded (one notice per tree lists them all, each with its parent). Read the parent's record with the `legion` tool's `read_record` (`issue` the parent); release the parent first when the `reason` says to, then run `release_children` for the child again once the parent is recorded in your tree, and tell the controller if it never is. |
25
25
  | `child-not-recorded` | Its `reason` names a child your tree did not record and why: its repository resolves to none (neither it nor an ancestor names one, and the project has no default), or to one not mapped to the project, or to one the daemon leaves out for a missing GitHub App. The daemon said why on the child too. Set the child's repository to one of the project's (`dispatch issue-update --repo owner/name`), then release it again with `release_children`; tell the controller when the repository it needs is not served. |
26
26
  | `children-first` | An issue of your tree starts no phase of its own while Dispatch shows an open child under it, a child you filed and have not released included. The reason names the parent and each open child with the Dispatch status that holds it, and each cycle it found (a child `blocked_by` an issue above it on its own parent chain, which only a person taking that link off ends: open a `dispatch ask`). It comes once per issue and run of its tree. Release every child that should run, and close, `park_child` or re-parent the rest; the daemon starts the parent's first phase at its next admission slot once the last open child has left the workflow. You start nothing. |
27
- | `design-approved` | Its `version` is the approved one. A human approved the root spec document at its current version; the gate is open. Proceed to section 2 — except on a root whose catch-up said `handed_over`: its workers already run their phases, so start nothing; the approval releases any READY the gate refused. One whose `version` is the version a human's own answer to a decision block wrote, after an earlier approval (Dispatch carried that approval to it, "After approval" in `skill://legion-architect/references/design-gate.md`), opens nothing new: carry on, and release no wave because of it. A root that was waiting on a question in `awaiting_person` gets no such notice: the task that resumes it says "Your spec is approved at version N and the design gate is open: this resume is your design-approved wake", and that resume is this wake — proceed to section 2 on it. |
28
- | `design-changes-requested` | Its `version` and `reason`. A human asked for changes to the root spec at `version`, for `reason`. Revise the spec as the reason asks and request approval again as "The armed gate" in `skill://legion-architect/references/design-gate.md` says; end your turn; the gate is closed. On a root that was waiting on a question in `awaiting_person`, a review the human gave after the wait began comes in the resume task, not in this notice, whether it answered the wait or came after something else did: the task names the review with its version and reason, unless a later approval superseded it. A `version` below the latest, or a second request for one version, is a request that reached the daemon late: read it as the same section's last paragraph says. Each request arrives once. |
27
+ | `design-approved` | Its `version` is the approved one, and it names no step: under more than one gated step, read `gatedSteps` in your own record (the `legion` tool's `read_record`) to see which step that version opened and whether another still holds the tree. A human approved the document of one gated step at its current version; that step is open. Proceed to section 2 once every gated step is `approved` — except on a root whose catch-up said `handed_over`: its workers already run their phases, so start nothing; the approval releases any READY the gate refused. One whose `version` is the version a human's own answer to a decision block wrote, after an earlier approval (Dispatch carried that approval to it, "After approval" in `skill://legion-architect/references/design-gate.md`), opens nothing new: carry on, and release no wave because of it. A root that was waiting on a question in `awaiting_person` gets no such notice: the task that resumes it says "Your spec is approved at version N and the design gate is open: this resume is your design-approved wake", and that resume is this wake — proceed to section 2 on it. |
28
+ | `design-changes-requested` | Its `version` and `reason`, and no step: under more than one gated step, read `gatedSteps` in your own record (the `legion` tool's `read_record`) and take the step whose `status` is `changes_requested`. A human asked for changes to that step's document at `version`, for `reason`. Revise that document as the reason asks and request approval again as "The armed gate" in `skill://legion-architect/references/design-gate.md` says; end your turn; that step is closed. On a root that was waiting on a question in `awaiting_person`, a review the human gave after the wait began comes in the resume task, not in this notice, whether it answered the wait or came after something else did: the task names the review with its version and reason, unless a later approval superseded it. A `version` below the latest, or a second request for one version, is a request that reached the daemon late: read it as the same section's last paragraph says. Each request arrives once. |
29
29
  | `phase-finished` | The daemon has already moved the issue to its next phase by its fixed table and started that phase's role; you start nothing. Read the finishing phase's handoff on the Dispatch issue (`dispatch handoff-read --issue <issue> --phase <word>`); if it shows unresolved gaps, tell the role now working the issue (`envoy_publish` to its role topic). A `planner` notice that names a departure from the spec's design defers to the full condition in "After approval" of `skill://legion-architect/references/design-gate.md`: when the approved Summary, the requirements, the checks, the scope and the settled decisions still hold, the plan is the record; otherwise change the root spec and request approval again as that section says. A `reviewer` notice whose GitHub review is `CHANGES_REQUESTED` needs nothing from you: the daemon has returned the issue to `implementing` (Dispatch `in_progress`) and started the **implementer**, whose correction goes through the tester and the reviewer again, never straight to retro. A `reviewer` notice with an `APPROVED` review means the issue is in `accepting` and the acceptance is yours (section 6): read the evidence, then `sign_off` (a parent only after its last round, "The last round" in `skill://legion-architect/references/acceptance.md`) or send it back with `request_backward_move`. A `tester` notice with `verdict: "fail"` — its handoff carries `implementerProof.verdict: "rejected"`, or a failure naming a check with no current passing result — has gone back to the **implementer** by the daemon's table; never record that check's result from another role. A worker that reports no surface reaches the changed path sends that report instead of completing its phase, so the daemon starts nothing more on that issue and the worker stays idle in its session, not suspended: build the surface in this tree: a child issue (infrastructure, tooling, or a skill) after the red-team check in `skill://legion-architect/references/decomposition.md`, or, when the red team folds it, tell the waiting worker to move the issue back to `implementing` with `request_backward_move`, naming the surface. When it is a child, tell the waiting worker to continue with `envoy_publish` to its role topic once that child's `child-closed` arrives. That report is never a reason to advance the phase. |
30
30
  | `pr-blocked` | Its `reason` names the pull request and the `max_fix_attempts` it reached, or reads `Issue reached review_round_cap=<N>.` (the end of this row). The count is of heads pushed onto a red verdict: no handoff is a commit, so every such push counts except a push by the review App (a planner's, tester's, reviewer's or architect's), and the head pushed onto a red the tester's red tests earned (a planned red) does not count either. Published once per exhausted count, not on every later red verdict for that count. Read the failed CI evidence and recovery attempts. The notice moves nothing, and no later CI red within the attempt that ran the count out (the counted head, and every head pushed on top of it until a push lands on a head CI judged green or on a settled head whose base branch requires no check and no workflow, the tester's red tests onto the held red included) sends the work back from `testing`, `reviewing`, `accepting`, `retro` or `merging` either: the issue stays in its phase until you move it, or a worker's own decision does (a tester's `fail`, a reviewer's request for changes). A head pushed onto green CI, or onto a settled head whose base branch requires no check and no workflow, leaves that attempt, so its red still comes as a `checks-red`. In `implementing`, give the implementer the failing checks (`envoy_publish` to its role topic). In any later phase a worker holds, tell that worker (`envoy_publish` to its role topic) to move the issue back to `implementing` with `request_backward_move`, naming the failing checks, and the daemon starts the implementer. In `accepting`, where no worker holds a phase, the move is yours: `request_backward_move` with `issue`, naming the failing checks. In `awaiting_merge`, where no worker holds a phase and a backward move is refused, open a `dispatch ask` naming the failing checks for the human who merges. Do not treat the blocked PR as final. A `review_round_cap` reason is the implementer's round count instead: every move back to an earlier phase counts one, and the notice comes again with each round past the cap. It moves nothing either, since the round that reached the cap has already started its worker. Tell the rounds apart by what announced each: a tester's fail by its `phase-finished` with `verdict: "fail"`; a request for changes by the reviewer's `phase-finished` over a `CHANGES_REQUESTED` review; the daemon's own move by a `checks-red` notice, whose reason names a conflict (`the head conflicts with <base>`), the required checks that failed (`CI is red at <head>`), or the merge queue's failed merge and its failing run (`the merge queue took pull request #<n> out at READY's head …`); a worker's `request_backward_move` by a `phase-finished` from the role that asked, whose reason reaches only the task of the worker the move started, so ask that worker; your own send-back from `accepting` by the reason you gave. Judge the cap by the rounds that are not conflicts. Conflict returns need nothing from you beyond the next round. Tester fails, requests for changes and CI-red returns (a `checks-red` notice is raised for CI only when a required check fails on code the branch pushed, or when the merge queue fails the merge) are the rounds the cap is for. Each `review_round_cap` notice is a decision you make in the turn it wakes, never one left for the next round to make: read what every counted round found, choose one of the three below, act on it in the same turn, and post it as a `dispatch comment` on the issue naming the rounds you read and the choice. **Narrow**: the change itself is sound but each round finds more around it; tell the implementer and the reviewer (`envoy_publish` to each one's role topic) which checks of the issue the next round answers; a correctness finding is still fixed in this pull request, and so is a wording nit or any clear fix in a file it touches, so the `Fast-follow:` line carries only cleanup in files this pull request does not touch. **Split**: part of the work cannot settle in this pull request and is a separately deliverable change (`skill://legion-architect`, section 1). On a child, run the red-team check in `skill://legion-architect/references/decomposition.md`, then file that part as a child of this issue's parent, never of this one (this issue closes at its own merge, and the split part usually builds on this pull request's unmerged code): `dispatch issue --project <project> --parent <parent> --title '<outcome>' --blocked-by <this issue> --spec-file -`. Release it now with `release_children`: Legion holds it on that `--blocked-by` link until this issue closes, and starts it then. On the root, narrow it to what settles here and open a `dispatch ask` for the rest (`skill://legion-worker/references/findings.md`): a person decides whether that becomes an issue. Tell the implementer and the reviewer that this pull request no longer carries it. **Accept the rounds**: each round found less than the one before it, and the last found no defect in the change itself (a stale line, a comment that states an old default); let the loop run and say why in the comment. A later notice is a new decision: accept again only when its round found less than the round you last accepted, and otherwise narrow or split. A finding that changes what the spec requires or what a check proves is a decision block in the root spec ("After approval" in `skill://legion-architect/references/design-gate.md`), and `dispatch ask` is only for a decision that needs a human's authority; neither replaces the choice. Judge a worker's backward move by the reason that worker gives. A move you ordered for a conflict found before `awaiting_merge` ("A conflicting head" in `skill://legion-architect/references/acceptance.md`) is a conflict return. The merger's move back to `reviewing` for a READY refusal (`READY_UNREVIEWED_CHANGE`, or `READY_NOT_APPROVED` when its task names no approved head), and the reviewer's no-fix request for changes that follows it so the tester runs the head, are not the work going in circles either. A move that sends back code found wrong counts with the tester fails. |
31
31
  | `pr-merged` | The pull request merged and the issue did not close; its `reason` says why. Merged before the issue reached `awaiting_merge`: the workflow runs on, and the merge closes the issue the moment it gets there, your acceptance and retro first. Merged while the rest of the tree still runs: the reason names the pull request and every issue the close waits for, and the root waits in `awaiting_merge`, its workers kept and live and no admission slot held, until the last of them is done or parked ("The close" in `skill://legion-architect/references/acceptance.md`). Either way you start nothing and no further notice follows. |
@@ -39,7 +39,7 @@ and 7 (a conflict, the hand-over, the close) is in
39
39
  | A reply on an ask you follow | Interpret the reply in the issue's design context. Answer in its thread (`dispatch comment --reply-to <comment id>`; under an open ask whose next move is yours, such as the approval request you must revise or hand back, `--reply-to-ask <ask id> --turn agent`, since a default-turn reply hands that request back to the human and a corrected `--summary` is then refused), then adjust the plan or relay it via `envoy_publish` to the responsible worker's role token; scope and product decisions remain with you. |
40
40
  | `worker-died` | Its `role` and `phase`. That role's claim failed: its launches or prompts ran out. For a phase worker the daemon holds the issue (phase `held`) and starts nothing more on it. Reassess the work, then decide with `retry_or_escalate`: `retry` when the failure looks agent-specific or transient, `escalate` to hand the held issue to the controller when it looks environmental. |
41
41
  | `held` | Its `role` and `phase` (the phase the issue left), or `phase` with `reason: "escalated"`. Without `reason`, that phase's worker ran out of launches or prompts: the issue is held (phase `held`), the `worker-died` that comes with it is yours to answer with `retry_or_escalate` as that row says, and the controller hears of the hold too. With `reason: "escalated"`, it records your own `escalate`: the controller has the issue now, and you start nothing for it. |
42
- | `ready-refused` | Its `version` and `reason`. The merger's READY was refused, and its packet is kept with the completion so no second READY is ever needed. With `version` greater than 0, `reason` reads `READY refused: approve design version <N> before requesting READY.`: the root's spec is already registered at that version, so get it approved (`skill://legion-architect/references/design-gate.md`), and the daemon advances the merge and posts the kept packet the moment it is. With `version` 0, `reason` reads `READY refused: no design version is approved; register and approve the tree's spec before requesting READY.`: the root has no spec registered yet, so register it and get it approved (`skill://legion-architect/references/design-gate.md`) exactly as above; the daemon releases the kept packet the same way — at registration itself when that opens the gate (`gates.design: off`, or Dispatch already shows the version approved), otherwise the moment a human approves the version you registered. Either way, nothing else is needed once the gate opens. On a root whose catch-up said `carried`, the gate is a copy on the old root's spec document: run nothing of `skill://legion-architect/references/design-gate.md`; tell the controller the READY waits on a human's approval of that document, and `carried-gate-opened` follows the approval. A `reason` starting `READY_PACKET_MISSING` names a READY refused before the daemon kept packets: that READY is void and the issue stays in merging, so start it over (`park_child` then `rerun_child`, for a child) or end it. |
42
+ | `ready-refused` | Its `version` and `reason`, and the `reason` names the gated step the READY waits on. The merger's READY was refused, and its packet is kept with the completion so no second READY is ever needed. With `version` greater than 0, `reason` reads `READY refused: approve design version <N> of the tree's <step> document before requesting READY.`: that step's document is already registered at that version, so get it approved (`skill://legion-architect/references/design-gate.md`), and the daemon advances the merge and posts the kept packet the moment every gated step is open. With `version` 0, `reason` reads `READY refused: no design version of the tree's <step> document is approved; register and approve it before requesting READY.`: that step is not registered yet, so register it and get it approved (`skill://legion-architect/references/design-gate.md`) exactly as above; the daemon releases the kept packet the same way — at registration itself when that opens the step (a deployment that gates no step, or Dispatch already shows the version approved), otherwise the moment a human approves the version you registered. The refusal names the first closed step, in the order the deployment gates them, and no second READY is asked for however many steps were closed: read `gatedSteps` in your own record (the `legion` tool's `read_record`) for every step still closed, and open each one. Nothing else is needed once every gated step is open. On a root whose catch-up said `carried`, the gate is a copy on the old root's spec document: run nothing of `skill://legion-architect/references/design-gate.md` for that step; tell the controller the READY waits on a human's approval of that document, and `carried-gate-opened` follows the approval. A `reason` starting `READY_PACKET_MISSING` names a READY refused before the daemon kept packets: that READY is void and the issue stays in merging, so start it over (`park_child` then `rerun_child`, for a child) or end it. |
43
43
 
44
44
  ## A root that joins your tree, and a refused release
45
45
 
@@ -21,7 +21,7 @@ except a clear defect a role reports ([Issues handed to Legion](#issues-handed-t
21
21
  - Keep no notes between turns, because a restarted session has only `read_state` and Dispatch.
22
22
  - Reach the daemon only through the `legion` tool: `read_state`, and `set_status` to `todo`, `backlog` or `icebox`. Run no `legion` from bash, because the tool holds the controller grant.
23
23
  - Never edit a repository, push a commit or call `handoff_complete`, and never forward a raw event to an architect, because the daemon runs the phases and raw events only fill the architect's context.
24
- - Never open a design gate, because only a person's approval of the root's spec in Dispatch opens it.
24
+ - Never open a design gate, because only a person's approval in Dispatch opens one: the deployment gates the steps your `Design gate steps:` sentence names, each step records one document of the root issue (`decisions.md`, `verification.md` or the primary document), and the tree's work starts when a person has approved every one of them.
25
25
  - When a `dispatch` command fails, or a `curl` read of Dispatch fails or prints no JSON array, act on nothing it would decide and name its first line in your summary (the turn's last message), because an empty read is no evidence. A Slack read answers a JSON object, and `skill://legion-controller/references/slack-mentions.md` holds its own success test.
26
26
  - Before you say that a service or an API is out of reach, run the call through the secrets broker's client (`skill://agent-secrets`), and report it unreachable only with the command you ran and its output.
27
27
  - Write every Dispatch comment, message and ask as `skill://dispatch-writing-specs`'s "Rules for the text" say.
@@ -97,7 +97,10 @@ an approval of a document that is not the root's primary one.
97
97
  on the issue where the decision was made, has judged it: do nothing.
98
98
  4. **Judge the words, not the kind.** Read the decision (`dispatch read --ref <ref>` for an ask or
99
99
  a reply; for a request for changes, the notice's `reason` or the issue's `Spec approval:` line;
100
- for an approval, `issues.<root>.designGate`). An approval or a request for changes is two
100
+ for an approval, `issues.<root>.designGate` and `issues.<root>.gatedSteps` beside it, which
101
+ carries one entry for each design gate step the deployment gates, with that step's
102
+ `artifactId`, `currentVersion`, `approvedVersion` and `status`). An approval or a request for
103
+ changes is two
101
104
  events at one time, the approval request's `ask.answered` and `artifact.approved` or
102
105
  `artifact.changes_requested`: judge them as one decision, whose `ref` is `dispatch://<KEY>/spec`.
103
106
  - It gives the work a next step: an approval of the spec, a request for changes that says what
@@ -105,6 +108,9 @@ an approval of a document that is not the root's primary one.
105
108
  the agent to go on. An approval counts when `issues.<root>.designGate.approvedVersion` equals
106
109
  `issues.<root>.designGate.currentVersion`, and also when it is lower: then the architect's
107
110
  start task names both versions, and the architect requests approval of the current one.
111
+ Where `gatedSteps` carries more than one entry, read each one the same way: the tree runs
112
+ again only once every entry is `approved`, and an entry still `waiting`, `unregistered` or
113
+ `changes_requested` is work the architect holds.
108
114
  - It is your own hold (step 2): set the root to `todo` (`set_status`); admission queues it and
109
115
  its architect resumes. Post one comment on the issue where the decision was made
110
116
  (`dispatch comment --issue <KEY> --body-file -`): `Parked decision read: <ref> by <login>
@@ -68,9 +68,9 @@ Read this on a `triage` wake, at every start, and when a person hands you an iss
68
68
  who will get this tree's questions and its design approval`, or, when `Assignee:` says
69
69
  `unassigned`, `Unassigned — nobody's Inbox shows this tree's questions or its design approval
70
70
  until someone takes it from the issue header (Assignee, beside Priority)`. Promise the design
71
- approval only when your system prompt's `Design gate policy:` line says
72
- `gates.design: root-issues`; under `gates.design: off` nobody approves a design, so drop "and
73
- its design approval" (and "or its design approval"). Take the assignee from the read, never
71
+ approval only when your system prompt's `Design gate steps:` sentence names at least one step;
72
+ when it reads `Design gate steps: none.` nobody approves a design, so drop "and its design
73
+ approval" (and "or its design approval"). Take the assignee from the read, never
74
74
  from how the issue was created, because an issue filed with no assignee still lands on the
75
75
  project's default assignee where a person set one. An unassigned root still runs, but its
76
76
  architect's asks reach only the Inbox's `Unassigned` filter.
@@ -132,7 +132,9 @@ Send any other to the owning architect: find its role in `read_state` and `envoy
132
132
  mention's URL, its text and the issue key you checked. Never build a role token from a partial
133
133
  issue reference, because a wrong token reaches nobody.
134
134
 
135
- A person who asks how to let a root past its design gate approves the root's spec document in
136
- Dispatch: the `Approve` control in the document's header, or the approval question the architect's
137
- request opened in the Inbox. No operator command opens a gate; a project that wants no gate sets
138
- `gates.design: off` in `legion.yaml`.
135
+ A person who asks how to let a root past its design gate approves, in Dispatch, the document of
136
+ each step the deployment gates, which your system prompt's `Design gate steps:` sentence names:
137
+ the `Approve` control in that document's header, or the approval question the architect's request
138
+ opened in the Inbox. The root's work starts when every one of those steps is approved. No
139
+ operator command opens a gate; a project that wants no gate sets `gates.design` to the empty list
140
+ (`design: []`) in `legion.yaml`.
@@ -145,7 +145,9 @@ reviewing until you finish.
145
145
  the architect as a gap the tester and the reviewer named and neither refused, so the
146
146
  architect's acceptance is the only gate on it: the acceptance comment names that gap and
147
147
  decides there, accepting the issue or sending it back with the checks the architect writes,
148
- which closes no design gate while dispatch://INFRA-175 has not shipped. The architect also
148
+ which closes the root's `criteria` step where the deployment gates that step, since that step
149
+ records the root's Verification document and each new version of a step's document closes it;
150
+ a child's Verification document is gated by nothing. The architect also
149
151
  reads the reviewer's own prose commits here, with the rest of the evidence, and that read is no
150
152
  new round. It does not
151
153
  accept while the issue holds a finding ask no controller has taken (`findings.md`, "When no
@@ -259,8 +261,10 @@ reviewing until you finish.
259
261
  ordinary arm cannot merge the person-only head. Ordinary READY does not wait for the disarm. The
260
262
  daemon tells that READY with the note at its head and the packet's `Merge:` line replaced, so the
261
263
  issue and the merge queue role are never asked to arm it. A READY the design gate refused is
262
- submitted by the daemon when the approval that opens the gate moves the issue to
263
- `awaiting_merge`. A push that moves the head past a READY the gate holds voids it before then:
264
+ submitted by the daemon once every gated step of the root's design gate is open, when the
265
+ approval that opens the last closed one moves the issue to `awaiting_merge`; the refusal named
266
+ one step and the version to approve, and no second READY is asked for however many steps held
267
+ it. A push that moves the head past a READY the gate holds voids it before then:
264
268
  the architect is told `READY_HEAD_MOVED` and has the merger build a new READY at the new head.
265
269
  The packet's second line records the step,
266
270
  `Merge: the daemon submits pull request #<n> at <head> once READY is accepted`.
@@ -14,7 +14,10 @@ limit, and one command that fills it ends every role's turn, not only yours.
14
14
  In the pod you compile and test the packages your change touches, build the program a check of
15
15
  your issue drives (`go build ./cmd/legion`), vet the module you changed (`go vet -tags e2e ./...`,
16
16
  which compiles and runs no test), and, in a package whose tests need Postgres, run only the tests
17
- your change touches by name (`go test -run`).
17
+ your change touches by name (`go test -run`). To run Go's race detector, prefix the command with
18
+ `CGO_ENABLED=1`: `CGO_ENABLED=1 go test -race -count=1 -run '<TestName>' ./internal/<package>/`.
19
+ The worker image sets `CGO_ENABLED=0` and carries gcc (both in
20
+ `packages/daemon/docker/worker.Dockerfile`), and Go refuses `-race` without cgo.
18
21
 
19
22
  A whole module's or whole package's test run goes to CI on the pull request, where a job of that
20
23
  pull request runs it: `go test ./...`, a whole Postgres-backed package such as
@@ -28,14 +31,31 @@ heavy run goes where your deployment's instructions say.
28
31
  file `OMP_SESSION_SQL_DSN_FILE`: that is the live sessions database every pod shares, and it
29
32
  holds every role's transcript.
30
33
  - Give every Postgres variable a test reads `$LEGION_TEST_PG_DSN`, your pod's own server:
31
- `LEGION_TEST_PG_DSN`, `DISPATCH_TEST_DATABASE_URL` and `BROKER_TEST_DATABASE_URL`. Each makes
32
- and drops a database of its own per test, so your pod's roles can share the server.
34
+ `LEGION_TEST_PG_DSN`, `DISPATCH_TEST_DATABASE_URL` and `BROKER_TEST_DATABASE_URL`. Most suites
35
+ make and drop one of their own per test on that server — a database through `internal/testpg`,
36
+ a schema through `internal/broker/store/storetest`'s `URL` — so your pod's roles can share the
37
+ server. Four write the database the URL itself names instead, and take a database of their own
38
+ from the next bullet's `createdb` line: `packages/envoy/internal/broker/store`, whose
39
+ `store_test.go` helpers call `Open(ctx, testDatabaseURL(t))` and then `Migrate` on the database
40
+ `BROKER_TEST_DATABASE_URL` names; and three that open the database `DISPATCH_TEST_DATABASE_URL`
41
+ names and migrate it, `packages/envoy/internal/dispatch/store` (`openTestStore`, in its
42
+ `store_test.go`), `packages/envoy/internal/dispatch/delivery` (`deliveryTestPool`, which also
43
+ deletes every delivery table's rows) and `packages/envoy/internal/dispatch/redeliver`
44
+ (`redeliverTestLeases`). In an empty database of its own, one run of the broker's left 24 tables
45
+ on 2026-11-06, and one whole run of the Dispatch store's left 104 on 2026-11-07, a count that
46
+ grows with Dispatch's migrations.
33
47
  - Anything that migrates into the database its DSN names, rather than making one per test, needs
34
48
  a database of your own, because a sibling role writing the same one collides with you: the
35
49
  Dispatch browser suite's `DATABASE_URL`, which resets what it is given, and the stage scripts'
36
50
  `LEGION_E2E_PG_DSN`, which a daemon's `postgres_dsn` becomes and migrates Legion's schema into.
37
51
  Make one and point the variable at it:
38
52
  `createdb -h 127.0.0.1 -p 5433 -U legion_test "e2e_$(date +%s)"`.
53
+ The DSN's own database is owned by your login and writable, so what keeps two roles out of it is
54
+ this rule rather than the server. `packages/daemon/internal/daemon` is the third case, whose
55
+ `TestMain` takes one database for the package's run through `internal/testpg/testdb`.
56
+ Empty the served database by dropping your own suite's schemas and your own objects in `public`,
57
+ never `public` itself: `vector` goes with it, your login may not create it again, and
58
+ `ensure` only ever installs it at a database's creation.
39
59
  - The server is empty in every new pod and dies with it. Every database made from `template1`
40
60
  has pgvector.
41
61
  - `LEGION_TEST_PG_DSN` set and the connection refused: that is not yours to repair. Starting the
@@ -48,12 +68,14 @@ heavy run goes where your deployment's instructions say.
48
68
  is starting one and nothing will appear while you wait. With `legion-testpg` on `PATH`, start
49
69
  one yourself on the issue's volume: the directory it defaults to is a 1 MiB filesystem your
50
70
  role's own mounts cover, so `export LEGION_TESTPG_DIR=$XDG_DATA_HOME/testpg` first, then
51
- `export LEGION_TEST_PG_DSN=postgres://legion_test@127.0.0.1:5433/postgres?sslmode=disable`
52
- and `legion-testpg ensure`, which makes the cluster, the role and `vector` in `template1`. That
71
+ `export LEGION_TEST_PG_DSN=postgres://legion_test@127.0.0.1:5433/legion_test?sslmode=disable`
72
+ and `legion-testpg ensure`, which makes the cluster, the role, `vector` in `template1` and the
73
+ database the DSN names, owned by that role. That
53
74
  server lives in your generation: it dies when your generation ends, which takes a sibling
54
75
  role's connections and databases with it mid-test, and the next `ensure` of either role finds
55
- the same cluster on disk and serves it again. With no
56
- `legion-testpg` either, stand up one with the harness below (**The worked case**) and set the DSN.
76
+ the same cluster on disk and serves it again. With no `legion-testpg` either, stand up one with
77
+ the harness of `skill://legion-worker/references/withheld-capability.md` (**The worked case**)
78
+ and set the DSN.
57
79
 
58
80
  ## Reading a shared database
59
81
 
@@ -160,169 +182,23 @@ So that writable `$HOME`, and the issue's disk, are where an install for yoursel
160
182
  the mechanics the answer is not a flat no: `redis-cli`, `sqlite3` and `dig` did run from an
161
183
  extraction prefix under `$HOME` with `LD_LIBRARY_PATH` set, and `psql` did not — Debian's
162
184
  `/usr/bin/psql` is a Perl wrapper that loads `PgCommon.pm` through Perl's own search path, which no
163
- extraction prefix is on. What rules such an attempt out is not the unpacking: it is the next
164
- section's test ("The test: where it lands, and what it leaves resolving"), and an extraction under
165
- `$HOME` fails that test's first condition whatever you scope it to, because `/home/legion` is the
166
- pod's disk rather than the issue's volume — the placement `roleEnvironment`
167
- (`packages/daemon/internal/runtime/sandbox/manifest.go`) exists to move writes off. The sanctioned
168
- extraction is the harness's: onto the issue's volume, and exported around its own commands and no
169
- further (the Dispatch e2e stack, below, is the worked case). For everything the image does not
170
- carry, the one documented step is a pinned run through one of the three package runners it does
171
- — plus `uv run --with` when what you need is a library rather than a command — and a tool
172
- no runner reaches is an image change ("When no runner reaches it", below).
185
+ extraction prefix is on. What rules such an attempt out is not the unpacking: it is the test of
186
+ `skill://legion-worker/references/withheld-capability.md#the-test-where-it-lands-and-what-it-leaves-resolving`,
187
+ and an extraction under `$HOME` fails that test's first condition whatever you scope it to,
188
+ because `/home/legion` is the pod's disk rather than the issue's volume — the placement
189
+ `roleEnvironment` (`packages/daemon/internal/runtime/sandbox/manifest.go`) exists to move writes
190
+ off. The sanctioned extraction is the harness's: onto the issue's volume, and exported around its
191
+ own commands and no further (the Dispatch e2e stack of that reference is the worked case). For
192
+ everything the image does not carry, the one documented step is a pinned run through one of the
193
+ three package runners it does — plus `uv run --with` when what you need is a library rather than
194
+ a command — and a tool no runner reaches is an image change ("When no runner reaches it", below).
173
195
 
174
196
  ## What the withheld capability is, and what it is not
175
197
 
176
- The withheld row reads `installs or configures its own dependencies in the pod: Legion owns its
177
- dependencies` (`packages/daemon/internal/capabilities/capabilities.go`, ruling
178
- dispatch://LEGION-200). Read as a sentence on its own, that wording would also forbid `uvx`,
179
- `bunx --package` and `go run`, each of which fetches a package and writes a cache. It does not, and
180
- three artefacts in this repository are what draw the line — not a claim about what the ruling meant:
181
-
182
- - **What the image ships for a role to use.** `packages/daemon/docker/worker.Dockerfile` installs
183
- `uv` and `uvx`, `bun` with its `bunx` link, and the Go toolchain, and the `toolchain` capability
184
- row (`packages/daemon/internal/capabilities/capabilities.go`) grants them by name. A runner put
185
- in the image deliberately and granted by a capability row is not a route around a withheld one.
186
- - **What the pod configures for those runners to write.** A role's environment relocates
187
- `UV_CACHE_DIR` and `UV_PYTHON_INSTALL_DIR` (`podkind.go`) and `BUN_INSTALL_CACHE_DIR`, `GOCACHE`
188
- and `GOMODCACHE` (`roleEnvironment`, `packages/daemon/internal/runtime/sandbox/manifest.go`)
189
- onto the issue's volume, shared by the issue's roles. Legion provisions the caches a pinned run
190
- fills; filling them is what they are for.
191
- - **What Legion itself does at launch.** `installBlock`
192
- (`packages/daemon/internal/runtime/sandbox/plugins.go`) renders a `bun add` under
193
- `/usr/bin/flock` into that shared bun install cache on every installing launch. Fetching a pinned
194
- package at run time is something Legion does, not something it withholds.
195
-
196
- So the withheld row is about a dependency **the pod carries**: something that outlives the command
197
- that created it, and that a later launch or another role's process then resolves *without asking
198
- for it*.
199
-
200
- ### The test: where it lands, and what it leaves resolving
201
-
202
- An install of your own — an interpreter, a browser, a second toolchain, an extraction prefix — is
203
- on the sanctioned side when both of these hold: what it writes lands on the issue's own volume,
204
- under `/legion`, which outlives a role's relaunch in its pod and is shared by the issue's roles —
205
- whether through a variable the runtime sets for you there or under a path on that volume your
206
- harness names itself — but outside the issue's workspace (`$LEGION_WORKSPACE`, under
207
- `/legion/workspaces`) and the runtime's own directories (`/legion/repos`, `/legion/feed`); and
208
- after the command exits nothing else resolves differently. The workspace is excluded because its jj
209
- working copy tracks every new file under jj's `snapshot.max-new-file-size` (1 MiB for a role), so
210
- a root written there is added to the change every role shares, and a larger new
211
- file is left untracked, every jj command in the workspace then warning `Refused to snapshot some
212
- files`. The one write into it that is sanctioned is a repository's own dependency install into
213
- its ignored directories, below. The variables the runtime sets on the volume, where a tool's own
214
- cache already goes, are
215
- `XDG_CACHE_HOME` (`/legion/scratch/cache`), `XDG_DATA_HOME`, `XDG_STATE_HOME`, `TMPDIR`, `GOCACHE`,
216
- `GOMODCACHE`, `BUN_INSTALL_CACHE_DIR`, `UV_CACHE_DIR` (`/legion/uv/cache`) and
217
- `UV_PYTHON_INSTALL_DIR` (`/legion/uv/python/<issue>`). (`XDG_CONFIG_HOME` is set too and is not
218
- one of them: it is your role's own config home, a copy of the pod's made for your role on the
219
- pod's disk, which no other role reads.) `$HOME` and `/tmp` are not on the volume:
220
- both are your role's own, on the pod's disk.
221
-
222
- It is the withheld capability when either condition fails — anything that writes into a
223
- system prefix, and anything that leaves a toolchain, shim or binary which a later launch or
224
- another role's process will resolve without asking for it. Between two installs that both land in
225
- the right place, the test is resolution, not disk, and it reads as scope: a second toolchain
226
- exported for the commands of your own run — by absolute path, or on a `PATH` your harness's own
227
- script sets around its own invocations — is sanctioned; the same toolchain left where a later
228
- launch, another role or a subagent picks it up without naming it is operator setup, whoever typed
229
- the command.
230
-
231
- What each shape leaves resolving:
232
-
233
- - A pinned runner run: nothing. `uvx --from httpie@3.2.4 http --version` leaves a cache entry
234
- addressed by that coordinate and that version, which the next command reaches only by writing the
235
- same pin; no `PATH`, no interpreter and no library search of any other process changes. That is
236
- the sanctioned side, and it is the side the forms below are on.
237
- - A repository's own dependency set: nothing either, which is why the caches are provisioned at
238
- all. `uv sync` or `bun install` in your workspace materialises what that project already
239
- declares, into that project's own directory, which a process reaches by asking for that project
240
- and no other way — and the runtime puts `UV_PYTHON_INSTALL_DIR` and the workspace itself on the
241
- issue's volume exactly so every role of the issue's pod reuses it.
242
- - An extraction prefix: whatever you leave resolving through it, which is yours to decide. Unpack
243
- Debian packages under `$HOME`, export `LD_LIBRARY_PATH` in the shell you go on working in, and
244
- you change what every later command in that shell — and every subagent that inherits the
245
- environment — resolves `psql` or its libraries to, asked for or not, on a prefix nothing records,
246
- pins or rebuilds: that fails both conditions, and it is the withheld capability. The harness
247
- below is the shape that passes. The opposite shape is in the tree too, and superseded: telling
248
- every subagent to prepend a directory to `PATH` because a subagent's shell starts without it
249
- hands over an environment to processes that never named it. That is the difference between
250
- standing a harness up and getting yourself a CLI.
251
-
252
- The test is about what a command leaves for other processes, never about getting the same bytes
253
- twice — because a pinned line does not promise the same bytes. What a pin binds is the named
254
- distribution at that version; its dependency closure is resolved when the command runs. With
255
- `UV_CACHE_DIR` emptied, `uvx -v --from httpie@3.2.4 http --version` logs `Selecting:
256
- requests==2.34.2 [compatible]` and resolves `urllib3` and `certifi` from ranges, where the warm run
257
- prints `3.2.4` at once off the cache that first run filled. When your work needs the closure pinned
258
- too — a comparison that has to hold across days or across pods — that is a project's own lockfile
259
- with `uv run` or `bun install` in a workspace, not a one-off runner line.
260
-
261
- A pinned run whose tool then installs something of its own — an interpreter, a browser, a second
262
- toolchain — is where the first condition does the work. `uv run --with pyyaml==6.0.2 --no-project`
263
- passes both: the wheel lands in `UV_CACHE_DIR`, under `wheels-v6/pypi/pyyaml/6.0.2-…`, and
264
- `UV_PYTHON_INSTALL_DIR` is never created, because the image's own `python3` served the run. The
265
- interpreter is the other run: a project whose `.python-version` names an interpreter uv must fetch
266
- puts about 70 MB under `UV_PYTHON_INSTALL_DIR`, where every role of the issue's pod finds it, while
267
- the cache holds no wheel. A browser download passes too, and for Playwright — the browser driver this
268
- repository's own Dispatch suite installs (`playwright install`, `packages/dispatch`'s
269
- `e2e:install`) — it passes as it stands, with nothing pointed anywhere: playwright-core resolves
270
- its browser registry under `XDG_CACHE_HOME`, which the runtime has already put on the issue's
271
- volume, so the issue's roles share one registry under the exclusive install lock that
272
- `docs/kubernetes.md` writes down in "Anatomy of a Sandbox pod", the paragraph on sharing the cache
273
- home — worth reading before an install, for the two things that follow from sharing. Pointing
274
- `PLAYWRIGHT_BROWSERS_PATH` elsewhere is what costs you: that shared registry, and the lock that
275
- makes a second role installing at the same time wait rather than race. The condition is for a tool
276
- whose cache the runtime does not already place — Puppeteer is one, resolving its browser cache to
277
- `join(homedir(), '.cache', 'puppeteer')` and so to your role's own home on the pod's disk whatever
278
- `XDG_CACHE_HOME` says. For that one, point the tool's own cache variable (`PUPPETEER_CACHE_DIR`
279
- here) at a directory under one of the paths above yourself, since an unset one leaves the download
280
- wherever the tool defaults, which is nowhere this runtime chose.
281
-
282
- Where it lands is what you pay. Under one of those variables it is on the issue's volume: it
283
- outlives a role's relaunch in its pod, and every role of the issue is told the same path — under
284
- every one of those variables but
285
- `XDG_STATE_HOME`, which is `/legion/scratch/state/<role>`, a directory named for your role
286
- (`roleEnvironment`) because Oh My Pi names its browser broker's lock from the state root and an
287
- abstract socket name is one name across every role of the pod, which share its network. It is not
288
- private: every role of the issue can write it, as it can every path above — though it does not
289
- outlive the pod: the issue's disk is the pod's own and is deleted with it, so a later pod of the
290
- issue starts without it. Under
291
- `$HOME` — `/home/legion`, which no volume is mounted on — it is your role's own home, an overlay
292
- over the image's whose writes land on the pod's disk (an `emptyDir` every role's home and `/tmp`
293
- share): one copy per role, per pod, kept across the role's relaunches in that pod and gone when
294
- the pod is replaced, and charged to the pod's ephemeral-storage limit, which every role of the pod
295
- shares and which is smaller than the volume. Moving exactly these writes off that
296
- filesystem is what `roleEnvironment` (`packages/daemon/internal/runtime/sandbox/manifest.go`)
297
- exists for, after GKE Autopilot evicted issue pods that charged their caches to it — so an install
298
- under `$HOME` fails the first condition and is outside the sanctioned form, however little else it
299
- changes. The three artefacts above are the whole of that line's ground. Disk is not: every cache is
300
- already relocated onto the issue's volume, which has the room for them (`docs/kubernetes.md`, "The
301
- tools a role's own work needs", carries the figure), and a sanctioned install is paid once per
302
- issue pod, on the issue's own disk, by the issue's roles together.
303
-
304
- **The worked case, and the one a role actually meets: the Dispatch e2e stack.** A change to
305
- `packages/dispatch` is proved in the pod by driving the specs it touches, one spec a command
306
- (`bun run e2e -- e2e/<one spec file>`); the whole browser suite runs in CI's `dispatch` job on the
307
- pull request. One spec needs the same stack as all of them: a Postgres with pgvector. On a digest
308
- carrying `legion-testpg`, use the pod's own server ("The database your tests use", above) and
309
- unpack nothing. On a digest without it, the way
310
- that stack is stood up here is Debian packages unpacked with `dpkg-deb -x` into one root on the
311
- issue's volume — the five packages and the start sequence are in
312
- `docs/solutions/testing/the-real-dispatch-stack-runs-inside-a-legion-pod-with-no-postgres-no-docker-and-no-root-INFRA-42.md`,
313
- and
314
- `docs/solutions/testing/a-browser-harness-in-an-issue-pod-lives-on-the-issue-volume-INFRA-43.md`
315
- puts it on the volume, moves the caches with it, and writes it as one idempotent script the next
316
- role re-runs. That passes both conditions, and the second is the one to get right: export the
317
- root's `PATH` and `LD_LIBRARY_PATH` around the harness's own commands — `initdb`, `pg_ctl`, the
318
- server, the Playwright run — rather than into the shell you go on working in. Handing the harness
319
- to the next role is the same test passed again, and
320
- `docs/solutions/legion/a-port-held-in-an-issue-pod-is-a-sibling-roles-harness-move-your-run-with-the-five-port-variables-and-your-own-database-and-never-stop-the-holder-LEGION-695.md`
321
- is the illustration: it leaves the server running and records the `LD_LIBRARY_PATH`, `PATH` and
322
- `DATABASE_URL` in its handoff's `trickyParts`, so what crosses to the next role is a note that
323
- role sets deliberately rather than an environment it inherits. The suite shells out to `psql`,
324
- and `psql` being in the image is what makes that harness work now without your extracting a
325
- client for yourself at all.
198
+ What the withheld row says, the three artefacts that draw the line between it and a pinned runner
199
+ form, and the worked harness case are in
200
+ `skill://legion-worker/references/withheld-capability.md`: read it before you unpack, extract or
201
+ install anything the image does not carry.
326
202
 
327
203
  ## The three pinned runner forms
328
204
 
@@ -528,8 +404,9 @@ to its role topic), with the tool, what your phase needs it for, and the runner
528
404
  ruled out. What is not the step is improvising a CLI for your own convenience — a vendored binary,
529
405
  a hand-rolled substitute, or an extraction prefix dropped on your own `PATH`: the architect decides
530
406
  whether the image change is work to schedule. An extraction a harness builds for its own runs is
531
- not improvising and is not what this rules out (the Dispatch e2e stack, above): the two cases part
532
- on "The test: where it lands, and what it leaves resolving", which the convenience case fails.
407
+ not improvising and is not what this rules out (the Dispatch e2e stack of
408
+ `skill://legion-worker/references/withheld-capability.md`): the two cases part on its test,
409
+ "The test: where it lands, and what it leaves resolving", which the convenience case fails.
533
410
 
534
411
  The operator's side of this — why the image carries those five clients rather than anything else,
535
412
  the five priced options with their measurements, and what the clients cost the image — is in
@@ -716,8 +593,9 @@ something to count.
716
593
  `go test -count=1 -timeout 40m ./internal/workflow/` runs whole in the pod, with no docker
717
594
  panic.
718
595
  - **The rest, by package**, for a named run the pod does make. A Postgres unpacked from Debian
719
- packages (the harness under **The worked case**) has only the `C`, `C.utf8` and `POSIX`
720
- locales, so its default collation is byte order, and `internal/dispatch/api`'s
596
+ packages (the harness under **The worked case** of `withheld-capability.md`) has only the
597
+ `C`, `C.utf8` and `POSIX` locales, so its default collation is byte order, and
598
+ `internal/dispatch/api`'s
721
599
  `TestIssueRanksStayOrderedPastLowercaseDigitsAndProductionShapedMoves` refuses such a database
722
600
  by its own message. The pod's own `legion-testpg` server is made with ICU `en-US`, so that
723
601
  test runs there, as it does in CI's `envoy-go` job, whose Postgres has `en_US.utf8`.
@@ -0,0 +1,158 @@
1
+ # What the withheld capability is, and what it is not
2
+
3
+ Part of `skill://legion-worker`, read from `skill://legion-worker/references/tools.md` before you
4
+ unpack, extract or install anything the worker image does not carry. Every path it cites is in
5
+ cybermonklabs/legion.
6
+
7
+ The withheld row reads `installs or configures its own dependencies in the pod: Legion owns its
8
+ dependencies` (`packages/daemon/internal/capabilities/capabilities.go`, ruling
9
+ dispatch://LEGION-200). Read as a sentence on its own, that wording would also forbid `uvx`,
10
+ `bunx --package` and `go run`, each of which fetches a package and writes a cache. It does not, and
11
+ three artefacts in this repository are what draw the line — not a claim about what the ruling meant:
12
+
13
+ - **What the image ships for a role to use.** `packages/daemon/docker/worker.Dockerfile` installs
14
+ `uv` and `uvx`, `bun` with its `bunx` link, and the Go toolchain, and the `toolchain` capability
15
+ row (`packages/daemon/internal/capabilities/capabilities.go`) grants them by name. A runner put
16
+ in the image deliberately and granted by a capability row is not a route around a withheld one.
17
+ - **What the pod configures for those runners to write.** A role's environment relocates
18
+ `UV_CACHE_DIR` and `UV_PYTHON_INSTALL_DIR` (`podkind.go`) and `BUN_INSTALL_CACHE_DIR`, `GOCACHE`
19
+ and `GOMODCACHE` (`roleEnvironment`, `packages/daemon/internal/runtime/sandbox/manifest.go`)
20
+ onto the issue's volume, shared by the issue's roles. Legion provisions the caches a pinned run
21
+ fills; filling them is what they are for.
22
+ - **What Legion itself does at launch.** `installBlock`
23
+ (`packages/daemon/internal/runtime/sandbox/plugins.go`) renders a `bun add` under
24
+ `/usr/bin/flock` into that shared bun install cache on every installing launch. Fetching a pinned
25
+ package at run time is something Legion does, not something it withholds.
26
+
27
+ So the withheld row is about a dependency **the pod carries**: something that outlives the command
28
+ that created it, and that a later launch or another role's process then resolves *without asking
29
+ for it*.
30
+
31
+ ### The test: where it lands, and what it leaves resolving
32
+
33
+ An install of your own — an interpreter, a browser, a second toolchain, an extraction prefix — is
34
+ on the sanctioned side when both of these hold: what it writes lands on the issue's own volume,
35
+ under `/legion`, which outlives a role's relaunch in its pod and is shared by the issue's roles —
36
+ whether through a variable the runtime sets for you there or under a path on that volume your
37
+ harness names itself — but outside the issue's workspace (`$LEGION_WORKSPACE`, under
38
+ `/legion/workspaces`) and the runtime's own directories (`/legion/repos`, `/legion/feed`); and
39
+ after the command exits nothing else resolves differently. The workspace is excluded because its jj
40
+ working copy tracks every new file under jj's `snapshot.max-new-file-size` (1 MiB for a role), so
41
+ a root written there is added to the change every role shares, and a larger new
42
+ file is left untracked, every jj command in the workspace then warning `Refused to snapshot some
43
+ files`. The one write into it that is sanctioned is a repository's own dependency install into
44
+ its ignored directories, below. The variables the runtime sets on the volume, where a tool's own
45
+ cache already goes, are
46
+ `XDG_CACHE_HOME` (`/legion/scratch/cache`), `XDG_DATA_HOME`, `XDG_STATE_HOME`, `TMPDIR`, `GOCACHE`,
47
+ `GOMODCACHE`, `BUN_INSTALL_CACHE_DIR`, `UV_CACHE_DIR` (`/legion/uv/cache`) and
48
+ `UV_PYTHON_INSTALL_DIR` (`/legion/uv/python/<issue>`). (`XDG_CONFIG_HOME` is set too and is not
49
+ one of them: it is your role's own config home, a copy of the pod's made for your role on the
50
+ pod's disk, which no other role reads.) `$HOME` and `/tmp` are not on the volume:
51
+ both are your role's own, on the pod's disk.
52
+
53
+ It is the withheld capability when either condition fails — anything that writes into a
54
+ system prefix, and anything that leaves a toolchain, shim or binary which a later launch or
55
+ another role's process will resolve without asking for it. Between two installs that both land in
56
+ the right place, the test is resolution, not disk, and it reads as scope: a second toolchain
57
+ exported for the commands of your own run — by absolute path, or on a `PATH` your harness's own
58
+ script sets around its own invocations — is sanctioned; the same toolchain left where a later
59
+ launch, another role or a subagent picks it up without naming it is operator setup, whoever typed
60
+ the command.
61
+
62
+ What each shape leaves resolving:
63
+
64
+ - A pinned runner run: nothing. `uvx --from httpie@3.2.4 http --version` leaves a cache entry
65
+ addressed by that coordinate and that version, which the next command reaches only by writing the
66
+ same pin; no `PATH`, no interpreter and no library search of any other process changes. That is
67
+ the sanctioned side, and it is the side the runner forms of
68
+ `skill://legion-worker/references/tools.md` are on.
69
+ - A repository's own dependency set: nothing either, which is why the caches are provisioned at
70
+ all. `uv sync` or `bun install` in your workspace materialises what that project already
71
+ declares, into that project's own directory, which a process reaches by asking for that project
72
+ and no other way — and the runtime puts `UV_PYTHON_INSTALL_DIR` and the workspace itself on the
73
+ issue's volume exactly so every role of the issue's pod reuses it.
74
+ - An extraction prefix: whatever you leave resolving through it, which is yours to decide. Unpack
75
+ Debian packages under `$HOME`, export `LD_LIBRARY_PATH` in the shell you go on working in, and
76
+ you change what every later command in that shell — and every subagent that inherits the
77
+ environment — resolves `psql` or its libraries to, asked for or not, on a prefix nothing records,
78
+ pins or rebuilds: that fails both conditions, and it is the withheld capability. The harness
79
+ below is the shape that passes. The opposite shape is in the tree too, and superseded: telling
80
+ every subagent to prepend a directory to `PATH` because a subagent's shell starts without it
81
+ hands over an environment to processes that never named it. That is the difference between
82
+ standing a harness up and getting yourself a CLI.
83
+
84
+ The test is about what a command leaves for other processes, never about getting the same bytes
85
+ twice — because a pinned line does not promise the same bytes. What a pin binds is the named
86
+ distribution at that version; its dependency closure is resolved when the command runs. With
87
+ `UV_CACHE_DIR` emptied, `uvx -v --from httpie@3.2.4 http --version` logs `Selecting:
88
+ requests==2.34.2 [compatible]` and resolves `urllib3` and `certifi` from ranges, where the warm run
89
+ prints `3.2.4` at once off the cache that first run filled. When your work needs the closure pinned
90
+ too — a comparison that has to hold across days or across pods — that is a project's own lockfile
91
+ with `uv run` or `bun install` in a workspace, not a one-off runner line.
92
+
93
+ A pinned run whose tool then installs something of its own — an interpreter, a browser, a second
94
+ toolchain — is where the first condition does the work. `uv run --with pyyaml==6.0.2 --no-project`
95
+ passes both: the wheel lands in `UV_CACHE_DIR`, under `wheels-v6/pypi/pyyaml/6.0.2-…`, and
96
+ `UV_PYTHON_INSTALL_DIR` is never created, because the image's own `python3` served the run. The
97
+ interpreter is the other run: a project whose `.python-version` names an interpreter uv must fetch
98
+ puts about 70 MB under `UV_PYTHON_INSTALL_DIR`, where every role of the issue's pod finds it, while
99
+ the cache holds no wheel. A browser download passes too, and for Playwright — the browser driver this
100
+ repository's own Dispatch suite installs (`playwright install`, `packages/dispatch`'s
101
+ `e2e:install`) — it passes as it stands, with nothing pointed anywhere: playwright-core resolves
102
+ its browser registry under `XDG_CACHE_HOME`, which the runtime has already put on the issue's
103
+ volume, so the issue's roles share one registry under the exclusive install lock that
104
+ `docs/kubernetes.md` writes down in "Anatomy of a Sandbox pod", the paragraph on sharing the cache
105
+ home — worth reading before an install, for the two things that follow from sharing. Pointing
106
+ `PLAYWRIGHT_BROWSERS_PATH` elsewhere is what costs you: that shared registry, and the lock that
107
+ makes a second role installing at the same time wait rather than race. The condition is for a tool
108
+ whose cache the runtime does not already place — Puppeteer is one, resolving its browser cache to
109
+ `join(homedir(), '.cache', 'puppeteer')` and so to your role's own home on the pod's disk whatever
110
+ `XDG_CACHE_HOME` says. For that one, point the tool's own cache variable (`PUPPETEER_CACHE_DIR`
111
+ here) at a directory under one of the paths above yourself, since an unset one leaves the download
112
+ wherever the tool defaults, which is nowhere this runtime chose.
113
+
114
+ Where it lands is what you pay. Under one of those variables it is on the issue's volume: it
115
+ outlives a role's relaunch in its pod, and every role of the issue is told the same path — under
116
+ every one of those variables but
117
+ `XDG_STATE_HOME`, which is `/legion/scratch/state/<role>`, a directory named for your role
118
+ (`roleEnvironment`) because Oh My Pi names its browser broker's lock from the state root and an
119
+ abstract socket name is one name across every role of the pod, which share its network. It is not
120
+ private: every role of the issue can write it, as it can every path above — though it does not
121
+ outlive the pod: the issue's disk is the pod's own and is deleted with it, so a later pod of the
122
+ issue starts without it. Under
123
+ `$HOME` — `/home/legion`, which no volume is mounted on — it is your role's own home, an overlay
124
+ over the image's whose writes land on the pod's disk (an `emptyDir` every role's home and `/tmp`
125
+ share): one copy per role, per pod, kept across the role's relaunches in that pod and gone when
126
+ the pod is replaced, and charged to the pod's ephemeral-storage limit, which every role of the pod
127
+ shares and which is smaller than the volume. Moving exactly these writes off that
128
+ filesystem is what `roleEnvironment` (`packages/daemon/internal/runtime/sandbox/manifest.go`)
129
+ exists for, after GKE Autopilot evicted issue pods that charged their caches to it — so an install
130
+ under `$HOME` fails the first condition and is outside the sanctioned form, however little else it
131
+ changes. The three artefacts above are the whole of that line's ground. Disk is not: every cache is
132
+ already relocated onto the issue's volume, which has the room for them (`docs/kubernetes.md`, "The
133
+ tools a role's own work needs", carries the figure), and a sanctioned install is paid once per
134
+ issue pod, on the issue's own disk, by the issue's roles together.
135
+
136
+ **The worked case, and the one a role actually meets: the Dispatch e2e stack.** A change to
137
+ `packages/dispatch` is proved in the pod by driving the specs it touches, one spec a command
138
+ (`bun run e2e -- e2e/<one spec file>`); the whole browser suite runs in CI's `dispatch` job on the
139
+ pull request. One spec needs the same stack as all of them: a Postgres with pgvector. On a digest
140
+ carrying `legion-testpg`, use the pod's own server
141
+ (`skill://legion-worker/references/tools.md#the-database-your-tests-use`) and
142
+ unpack nothing. On a digest without it, the way
143
+ that stack is stood up here is Debian packages unpacked with `dpkg-deb -x` into one root on the
144
+ issue's volume — the five packages and the start sequence are in
145
+ `docs/solutions/testing/the-real-dispatch-stack-runs-inside-a-legion-pod-with-no-postgres-no-docker-and-no-root-INFRA-42.md`,
146
+ and
147
+ `docs/solutions/testing/a-browser-harness-in-an-issue-pod-lives-on-the-issue-volume-INFRA-43.md`
148
+ puts it on the volume, moves the caches with it, and writes it as one idempotent script the next
149
+ role re-runs. That passes both conditions, and the second is the one to get right: export the
150
+ root's `PATH` and `LD_LIBRARY_PATH` around the harness's own commands — `initdb`, `pg_ctl`, the
151
+ server, the Playwright run — rather than into the shell you go on working in. Handing the harness
152
+ to the next role is the same test passed again, and
153
+ `docs/solutions/legion/a-port-held-in-an-issue-pod-is-a-sibling-roles-harness-move-your-run-with-the-five-port-variables-and-your-own-database-and-never-stop-the-holder-LEGION-695.md`
154
+ is the illustration: it leaves the server running and records the `LD_LIBRARY_PATH`, `PATH` and
155
+ `DATABASE_URL` in its handoff's `trickyParts`, so what crosses to the next role is a note that
156
+ role sets deliberately rather than an environment it inherits. The suite shells out to `psql`,
157
+ and `psql` being in the image is what makes that harness work now without your extracting a
158
+ client for yourself at all.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/claude-envoy",
3
- "version": "1.11.2",
3
+ "version": "1.12.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Claude Code channel plugin for Legion's Envoy bus",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "AGENTS.md"
20
20
  ],
21
21
  "legion": {
22
- "daemonApiVersion": 54
22
+ "daemonApiVersion": 55
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
@@ -33,7 +33,7 @@ report, a disclosure, and a reply to a maintainer who wrote first.
33
33
 
34
34
  Hold an extremely high bar. Optimise every upstream report, patch or public post for a human
35
35
  reader and for being received well, so each one builds the company's reputation rather than a
36
- name for slop.
36
+ reputation for low-quality or careless submissions.
37
37
 
38
38
  ## The four steps
39
39
 
@@ -95,7 +95,9 @@ review batch, never a per-message "can I send this" ask. Write its entry — wha
95
95
  serious; who receives it and what they do; the exact text or patch; its evidence; a non-author
96
96
  reviewer's APPROVE under your stream's reviewer-model rule; what the operator decides; its owner
97
97
  — into the batch, add it under the batch's Waiting list, and tell the batch owner. Your stream's
98
- brief names the batch and its owner.
98
+ brief names the batch and its owner. Where your stream's brief names no batch, ask the operator
99
+ for one, or hold the entry until there is a batch; a missing batch is not a reason to send a
100
+ per-message ask.
99
101
 
100
102
  ## What this skill does not cover
101
103